chore(release): prepare public source release
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "cjson"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
readme.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Compact JSON and JSONL into stable single-line output."
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common", default-features = false }
|
||||
lexopt.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["preserve_order"] }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd.workspace = true
|
||||
predicates.workspace = true
|
||||
@@ -0,0 +1,829 @@
|
||||
//! The `cjson` command compacts JSON and JSONL.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use common::{
|
||||
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, parse_color_choice,
|
||||
parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured,
|
||||
read_existing_stdin_paths, should_read_stdin,
|
||||
};
|
||||
use lexopt::prelude::{Long, Short, Value as ArgValue};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
const MAX_JSON_INPUT_BYTES: u64 = 64 * 1024 * 1024;
|
||||
const MAX_SORT_DEPTH: usize = 512;
|
||||
|
||||
const HELP: &str = "\
|
||||
Compact JSON and JSONL into single-line output with optional recursive key sorting.
|
||||
|
||||
Usage:
|
||||
cjson [OPTIONS] [PATH]
|
||||
|
||||
Options:
|
||||
--format <FORMAT> Structured output format: text, json, toon
|
||||
--json Shortcut for --format json
|
||||
--toon Shortcut for --format toon
|
||||
--input-format <FORMAT> Override stdin parsing mode: auto, json, lines, jsonl
|
||||
--color <WHEN> Control ANSI color output: auto, never
|
||||
--quiet Suppress non-essential status output
|
||||
--sort-keys Sort object keys recursively before rendering
|
||||
-h, --help Show this help text
|
||||
-V, --version Show the command version
|
||||
|
||||
Examples:
|
||||
cjson .\\fixtures\\cjson\\sample.json
|
||||
bat --style=plain --paging=never .\\fixtures\\cjson\\records.jsonl | cjson --input-format jsonl --sort-keys
|
||||
bat --style=plain --paging=never .\\fixtures\\cjson\\sample.json | cjson --input-format json
|
||||
'.\\fixtures\\cjson\\sample.json' | cjson --input-format lines
|
||||
cjson --sort-keys --json .\\fixtures\\cjson\\sample.json | ConvertFrom-Json
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Cli {
|
||||
common: CommonArgs,
|
||||
sort_keys: bool,
|
||||
path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ParseOutcome {
|
||||
Help,
|
||||
Version,
|
||||
Run,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CompactFormat {
|
||||
Json,
|
||||
Jsonl,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct ParsedDocuments {
|
||||
format: CompactFormat,
|
||||
documents: Vec<Value>,
|
||||
skipped_empty: usize,
|
||||
skipped_empty_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
struct CompactJsonPayload {
|
||||
format: &'static str,
|
||||
documents: usize,
|
||||
skipped_empty: usize,
|
||||
skipped_empty_paths: Vec<String>,
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum LoadedInput {
|
||||
Content(String),
|
||||
Paths(Vec<PathBuf>),
|
||||
}
|
||||
|
||||
/// Parses CLI arguments and returns a process exit code.
|
||||
#[must_use]
|
||||
pub fn main_entry() -> i32 {
|
||||
match parse_cli_from(std::env::args_os()) {
|
||||
Ok((ParseOutcome::Help, _)) => {
|
||||
print!("{HELP}");
|
||||
ExitCode::Success.as_i32()
|
||||
}
|
||||
Ok((ParseOutcome::Version, _)) => {
|
||||
println!("cjson {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::Success.as_i32()
|
||||
}
|
||||
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
|
||||
Ok(code) => code.as_i32(),
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, HELP);
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, HELP);
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString>,
|
||||
{
|
||||
let mut parser = lexopt::Parser::from_iter(args);
|
||||
let mut cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
sort_keys: false,
|
||||
path: None,
|
||||
};
|
||||
|
||||
while let Some(argument) = parser
|
||||
.next()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?
|
||||
{
|
||||
match argument {
|
||||
Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)),
|
||||
Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)),
|
||||
Long("json") => cli.common.set_render_mode(RenderMode::Json),
|
||||
Long("toon") => cli.common.set_render_mode(RenderMode::Toon),
|
||||
Long("format") => {
|
||||
let value = parser_value_string(&mut parser, "--format")?;
|
||||
cli.common.set_render_mode(parse_format_choice(&value)?);
|
||||
}
|
||||
Long("input-format") => {
|
||||
let value = parser_value_string(&mut parser, "--input-format")?;
|
||||
cli.common.input_format = parse_cjson_input_format(&value)?;
|
||||
}
|
||||
Long("color") => {
|
||||
let value = parser_value_string(&mut parser, "--color")?;
|
||||
cli.common.color = parse_color_choice(&value)?;
|
||||
}
|
||||
Long("quiet") => cli.common.quiet = true,
|
||||
Long("sort-keys") => cli.sort_keys = true,
|
||||
ArgValue(path) => {
|
||||
if cli.path.replace(PathBuf::from(path)).is_some() {
|
||||
return Err(CliError::usage("cjson accepts at most one explicit path"));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(CliError::usage(
|
||||
"unsupported argument; use --help to see available options",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((ParseOutcome::Run, cli))
|
||||
}
|
||||
|
||||
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
|
||||
let value = parser
|
||||
.value()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?;
|
||||
value.into_string().map_err(|invalid| {
|
||||
CliError::usage(format!(
|
||||
"{flag} expects UTF-8 text, got '{}'",
|
||||
invalid.to_string_lossy()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
|
||||
let input = load_input(cli)?;
|
||||
let parsed = match input {
|
||||
LoadedInput::Content(content) => {
|
||||
let input_format =
|
||||
if cli.path.is_some() && cli.common.input_format == InputFormat::Lines {
|
||||
InputFormat::Auto
|
||||
} else {
|
||||
cli.common.input_format
|
||||
};
|
||||
parse_documents(&content, input_format, None)?
|
||||
}
|
||||
LoadedInput::Paths(paths) => parse_documents_from_paths(&paths)?,
|
||||
};
|
||||
let document_count = parsed.documents.len();
|
||||
let text = compact_documents(parsed.documents, parsed.format, cli.sort_keys)?;
|
||||
|
||||
match cli.common.render_mode() {
|
||||
RenderMode::Json => print_json(&CompactJsonPayload {
|
||||
format: parsed.format.as_str(),
|
||||
documents: document_count,
|
||||
skipped_empty: parsed.skipped_empty,
|
||||
skipped_empty_paths: parsed.skipped_empty_paths,
|
||||
text,
|
||||
})?,
|
||||
RenderMode::Toon => print_structured(
|
||||
&CompactJsonPayload {
|
||||
format: parsed.format.as_str(),
|
||||
documents: document_count,
|
||||
skipped_empty: parsed.skipped_empty,
|
||||
skipped_empty_paths: parsed.skipped_empty_paths,
|
||||
text,
|
||||
},
|
||||
RenderMode::Toon,
|
||||
)?,
|
||||
RenderMode::Text => {
|
||||
write_text_output(&text)?;
|
||||
emit_skipped_empty_note(
|
||||
parsed.skipped_empty,
|
||||
&parsed.skipped_empty_paths,
|
||||
cli.common.quiet,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ExitCode::Success)
|
||||
}
|
||||
|
||||
fn load_input(cli: &Cli) -> Result<LoadedInput, CliError> {
|
||||
if should_read_stdin(cli.path.is_some(), cli.common.stdin_is_terminal()) {
|
||||
let buffer = read_to_string_limited(io::stdin(), MAX_JSON_INPUT_BYTES, "stdin")?;
|
||||
if !buffer.is_empty() {
|
||||
if cli.common.input_format != InputFormat::Jsonl
|
||||
&& let Some(paths) =
|
||||
read_existing_stdin_paths(&buffer, cli.common.input_format, "cjson")?
|
||||
{
|
||||
return Ok(LoadedInput::Paths(paths));
|
||||
}
|
||||
return Ok(LoadedInput::Content(buffer));
|
||||
}
|
||||
}
|
||||
|
||||
let Some(path) = &cli.path else {
|
||||
return Err(CliError::usage(
|
||||
"provide one JSON path or pipe JSON/JSONL into stdin",
|
||||
));
|
||||
};
|
||||
|
||||
Ok(LoadedInput::Paths(common::expand_input_patterns(
|
||||
std::slice::from_ref(path),
|
||||
"cjson",
|
||||
)?))
|
||||
}
|
||||
|
||||
fn parse_documents_from_paths(paths: &[PathBuf]) -> Result<ParsedDocuments, CliError> {
|
||||
let mut documents = Vec::new();
|
||||
let mut skipped_empty = 0_usize;
|
||||
let mut skipped_empty_paths = Vec::new();
|
||||
let mut format = if paths.len() > 1 {
|
||||
CompactFormat::Jsonl
|
||||
} else {
|
||||
CompactFormat::Json
|
||||
};
|
||||
|
||||
for path in paths {
|
||||
let content = read_path_to_string_limited(path, MAX_JSON_INPUT_BYTES)?;
|
||||
if paths.len() > 1 && content.trim().is_empty() {
|
||||
skipped_empty += 1;
|
||||
skipped_empty_paths.push(path.display().to_string());
|
||||
continue;
|
||||
}
|
||||
let parsed = parse_documents(&content, InputFormat::Auto, Some(path))?;
|
||||
if paths.len() == 1 {
|
||||
format = parsed.format;
|
||||
}
|
||||
documents.extend(parsed.documents);
|
||||
skipped_empty += parsed.skipped_empty;
|
||||
skipped_empty_paths.extend(parsed.skipped_empty_paths);
|
||||
}
|
||||
|
||||
if documents.is_empty() {
|
||||
if skipped_empty > 0 {
|
||||
return Err(CliError::runtime(format!(
|
||||
"all {skipped_empty} JSON input path(s) were empty"
|
||||
)));
|
||||
}
|
||||
return Err(CliError::runtime("JSON input is empty"));
|
||||
}
|
||||
|
||||
Ok(ParsedDocuments {
|
||||
format,
|
||||
documents,
|
||||
skipped_empty,
|
||||
skipped_empty_paths,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_path_to_string_limited(path: &Path, max_bytes: u64) -> Result<String, CliError> {
|
||||
let metadata = fs::metadata(path).map_err(|error| {
|
||||
CliError::runtime(format!(
|
||||
"failed to read metadata for {}: {error}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
if metadata.len() > max_bytes {
|
||||
return Err(CliError::runtime(format!(
|
||||
"{} is {} byte(s), above the cjson input limit of {max_bytes} byte(s)",
|
||||
path.display(),
|
||||
metadata.len()
|
||||
)));
|
||||
}
|
||||
fs::read_to_string(path)
|
||||
.map_err(|error| CliError::runtime(format!("failed to read {}: {error}", path.display())))
|
||||
}
|
||||
|
||||
fn read_to_string_limited<R: Read>(
|
||||
reader: R,
|
||||
max_bytes: u64,
|
||||
label: &str,
|
||||
) -> Result<String, CliError> {
|
||||
let mut limited = reader.take(max_bytes.saturating_add(1));
|
||||
let mut buffer = String::new();
|
||||
limited
|
||||
.read_to_string(&mut buffer)
|
||||
.map_err(|error| CliError::runtime(format!("failed to read {label}: {error}")))?;
|
||||
if buffer.len() as u64 > max_bytes {
|
||||
return Err(CliError::runtime(format!(
|
||||
"{label} exceeds the cjson input limit of {max_bytes} byte(s)"
|
||||
)));
|
||||
}
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
fn parse_documents(
|
||||
input: &str,
|
||||
input_format: InputFormat,
|
||||
source_path: Option<&std::path::Path>,
|
||||
) -> Result<ParsedDocuments, CliError> {
|
||||
if input.trim().is_empty() {
|
||||
return Err(empty_input_error("JSON", source_path));
|
||||
}
|
||||
|
||||
match input_format {
|
||||
InputFormat::Auto => parse_auto_documents(input, source_path),
|
||||
InputFormat::Jsonl => parse_jsonl_documents(input, source_path),
|
||||
InputFormat::Lines => Err(CliError::usage(
|
||||
"cjson does not support --input-format lines; use auto or jsonl",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cjson_input_format(value: &str) -> Result<InputFormat, CliError> {
|
||||
if value.eq_ignore_ascii_case("json") {
|
||||
Ok(InputFormat::Auto)
|
||||
} else {
|
||||
parse_input_format(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_auto_documents(
|
||||
input: &str,
|
||||
source_path: Option<&Path>,
|
||||
) -> Result<ParsedDocuments, CliError> {
|
||||
if source_path.is_some_and(has_jsonl_extension) {
|
||||
return parse_jsonl_documents(input, source_path);
|
||||
}
|
||||
|
||||
let trimmed = input.trim();
|
||||
match serde_json::from_str::<Value>(trimmed) {
|
||||
Ok(document) => Ok(ParsedDocuments {
|
||||
format: CompactFormat::Json,
|
||||
documents: vec![document],
|
||||
skipped_empty: 0,
|
||||
skipped_empty_paths: Vec::new(),
|
||||
}),
|
||||
Err(json_error) => {
|
||||
if source_path.is_some_and(has_json_extension) {
|
||||
return Err(invalid_json_input_error(&json_error));
|
||||
}
|
||||
|
||||
let mut non_empty_lines = input.lines().map(str::trim).filter(|line| !line.is_empty());
|
||||
if non_empty_lines.next().is_some() && non_empty_lines.next().is_some() {
|
||||
match parse_jsonl_documents(input, source_path) {
|
||||
Ok(parsed) => return Ok(parsed),
|
||||
Err(line_stream_error) => {
|
||||
if looks_like_json_document(trimmed) {
|
||||
return Err(invalid_json_input_error(&json_error));
|
||||
}
|
||||
return Err(line_stream_error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if looks_like_json_document(trimmed) {
|
||||
return Err(invalid_json_input_error(&json_error));
|
||||
}
|
||||
|
||||
parse_jsonl_documents(input, source_path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_jsonl_documents(
|
||||
input: &str,
|
||||
source_path: Option<&Path>,
|
||||
) -> Result<ParsedDocuments, CliError> {
|
||||
let mut documents = Vec::new();
|
||||
|
||||
for (index, line) in input.lines().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let document = serde_json::from_str::<Value>(trimmed).map_err(|error| {
|
||||
CliError::runtime(format!("invalid JSONL at line {}: {error}", index + 1))
|
||||
})?;
|
||||
documents.push(document);
|
||||
}
|
||||
|
||||
if documents.is_empty() {
|
||||
return Err(empty_input_error("JSONL", source_path));
|
||||
}
|
||||
|
||||
Ok(ParsedDocuments {
|
||||
format: CompactFormat::Jsonl,
|
||||
documents,
|
||||
skipped_empty: 0,
|
||||
skipped_empty_paths: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn empty_input_error(kind: &str, source_path: Option<&Path>) -> CliError {
|
||||
source_path.map_or_else(
|
||||
|| CliError::runtime(format!("{kind} input is empty")),
|
||||
|path| CliError::runtime(format!("{kind} input is empty: {}", path.display())),
|
||||
)
|
||||
}
|
||||
|
||||
fn compact_documents(
|
||||
documents: Vec<Value>,
|
||||
format: CompactFormat,
|
||||
sort_keys: bool,
|
||||
) -> Result<String, CliError> {
|
||||
match format {
|
||||
CompactFormat::Json => compact_single_document(documents, sort_keys),
|
||||
CompactFormat::Jsonl => compact_jsonl_documents(documents, sort_keys),
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_single_document(documents: Vec<Value>, sort_keys: bool) -> Result<String, CliError> {
|
||||
let mut iter = documents.into_iter();
|
||||
let Some(document) = iter.next() else {
|
||||
return Err(CliError::runtime(
|
||||
"internal error: missing JSON document for compaction",
|
||||
));
|
||||
};
|
||||
|
||||
if iter.next().is_some() {
|
||||
return Err(CliError::runtime(
|
||||
"internal error: JSON compaction received multiple documents",
|
||||
));
|
||||
}
|
||||
|
||||
serialize_document(document, sort_keys)
|
||||
}
|
||||
|
||||
fn compact_jsonl_documents(documents: Vec<Value>, sort_keys: bool) -> Result<String, CliError> {
|
||||
let mut rendered = String::with_capacity(documents.len().saturating_mul(96));
|
||||
for (index, document) in documents.into_iter().enumerate() {
|
||||
if index > 0 {
|
||||
rendered.push('\n');
|
||||
}
|
||||
rendered.push_str(&serialize_document(document, sort_keys)?);
|
||||
}
|
||||
Ok(rendered)
|
||||
}
|
||||
|
||||
fn serialize_document(mut document: Value, sort_keys: bool) -> Result<String, CliError> {
|
||||
if sort_keys {
|
||||
sort_value(&mut document, 0)?;
|
||||
}
|
||||
|
||||
serde_json::to_string(&document)
|
||||
.map_err(|error| CliError::runtime(format!("failed to render JSON: {error}")))
|
||||
}
|
||||
|
||||
fn sort_value(value: &mut Value, depth: usize) -> Result<(), CliError> {
|
||||
if depth > MAX_SORT_DEPTH {
|
||||
return Err(CliError::runtime(format!(
|
||||
"JSON nesting exceeds cjson --sort-keys limit of {MAX_SORT_DEPTH}"
|
||||
)));
|
||||
}
|
||||
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let mut entries = std::mem::take(map).into_iter().collect::<Vec<_>>();
|
||||
entries.sort_unstable_by(|left, right| left.0.cmp(&right.0));
|
||||
for (key, mut child) in entries {
|
||||
sort_value(&mut child, depth + 1)?;
|
||||
let _ = map.insert(key, child);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
sort_value(item, depth + 1)?;
|
||||
}
|
||||
}
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_text_output(text: &str) -> Result<(), CliError> {
|
||||
let mut stdout = io::stdout().lock();
|
||||
stdout
|
||||
.write_all(text.as_bytes())
|
||||
.map_err(|error| CliError::runtime(format!("failed to write stdout: {error}")))?;
|
||||
stdout
|
||||
.write_all(b"\n")
|
||||
.map_err(|error| CliError::runtime(format!("failed to write stdout: {error}")))
|
||||
}
|
||||
|
||||
fn emit_skipped_empty_note(skipped_empty: usize, skipped_empty_paths: &[String], quiet: bool) {
|
||||
if quiet || skipped_empty == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let preview = skipped_empty_paths
|
||||
.iter()
|
||||
.take(3)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let suffix = if skipped_empty_paths.len() > 3 {
|
||||
format!(" (+{} more)", skipped_empty_paths.len() - 3)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
eprintln!("note: skipped {skipped_empty} empty JSON input path(s): {preview}{suffix}");
|
||||
}
|
||||
|
||||
impl CompactFormat {
|
||||
const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Json => "json",
|
||||
Self::Jsonl => "jsonl",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_json_extension(path: &Path) -> bool {
|
||||
has_extension(path, "json")
|
||||
}
|
||||
|
||||
fn has_jsonl_extension(path: &Path) -> bool {
|
||||
has_extension(path, "jsonl") || has_extension(path, "ndjson")
|
||||
}
|
||||
|
||||
fn has_extension(path: &Path, expected: &str) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
|
||||
}
|
||||
|
||||
fn looks_like_json_document(input: &str) -> bool {
|
||||
input.starts_with('{')
|
||||
|| input.starts_with('[')
|
||||
|| input.starts_with('"')
|
||||
|| matches!(input.as_bytes().first(), Some(b'-' | b'0'..=b'9'))
|
||||
|| input == "true"
|
||||
|| input == "false"
|
||||
|| input == "null"
|
||||
}
|
||||
|
||||
fn invalid_json_input_error(error: &serde_json::Error) -> CliError {
|
||||
CliError::runtime(format!("invalid JSON input: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use common::{ColorChoice, InputFormat};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn common_args(json: bool, input_format: InputFormat) -> CommonArgs {
|
||||
CommonArgs {
|
||||
json,
|
||||
format: None,
|
||||
input_format,
|
||||
color: ColorChoice::Never,
|
||||
quiet: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_documents_supports_auto_json_and_jsonl() {
|
||||
let single = parse_documents(
|
||||
"{\n \"z\": 3,\n \"a\": {\"y\": 2, \"x\": 1}\n}\n",
|
||||
InputFormat::Auto,
|
||||
None,
|
||||
)
|
||||
.expect("single JSON document");
|
||||
assert_eq!(
|
||||
single,
|
||||
ParsedDocuments {
|
||||
format: CompactFormat::Json,
|
||||
documents: vec![json!({"z": 3, "a": {"y": 2, "x": 1}})],
|
||||
skipped_empty: 0,
|
||||
skipped_empty_paths: Vec::new(),
|
||||
}
|
||||
);
|
||||
|
||||
let stream = parse_documents(
|
||||
"{\"ok\":true,\"event\":\"login\"}\n{\"ok\":false,\"event\":\"logout\"}\n",
|
||||
InputFormat::Jsonl,
|
||||
None,
|
||||
)
|
||||
.expect("jsonl documents");
|
||||
assert_eq!(stream.format, CompactFormat::Jsonl);
|
||||
assert_eq!(
|
||||
stream.documents,
|
||||
vec![
|
||||
json!({"ok": true, "event": "login"}),
|
||||
json!({"ok": false, "event": "logout"}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_auto_jsonl_preserves_invalid_json_precedence() {
|
||||
let stream = parse_documents(
|
||||
"{\"ok\":true,\"event\":\"login\"}\n\n{\"ok\":false,\"event\":\"logout\"}\n",
|
||||
InputFormat::Auto,
|
||||
None,
|
||||
)
|
||||
.expect("auto jsonl documents");
|
||||
assert_eq!(stream.format, CompactFormat::Jsonl);
|
||||
assert_eq!(
|
||||
stream.documents,
|
||||
vec![
|
||||
json!({"ok": true, "event": "login"}),
|
||||
json!({"ok": false, "event": "logout"}),
|
||||
]
|
||||
);
|
||||
|
||||
let invalid_json = parse_documents("{\"ok\": true}\nnot-json\n", InputFormat::Auto, None)
|
||||
.expect_err("json-looking input should prefer JSON error");
|
||||
assert!(matches!(
|
||||
invalid_json,
|
||||
CliError::Runtime(message)
|
||||
if message.contains("invalid JSON input")
|
||||
&& !message.contains("JSONL")
|
||||
));
|
||||
|
||||
let invalid_jsonl = parse_documents("not-json\n{\"ok\":true}\n", InputFormat::Auto, None)
|
||||
.expect_err("non-json-looking input should report JSONL line error");
|
||||
assert!(matches!(
|
||||
invalid_jsonl,
|
||||
CliError::Runtime(message)
|
||||
if message.contains("invalid JSONL at line 1")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_documents_rejects_lines_mode_and_empty_input() {
|
||||
let lines_error =
|
||||
parse_documents("{\"ok\":true}\n", InputFormat::Lines, None).expect_err("lines mode");
|
||||
assert!(matches!(
|
||||
lines_error,
|
||||
CliError::Usage(message)
|
||||
if message.contains("does not support --input-format lines")
|
||||
));
|
||||
|
||||
let empty_error =
|
||||
parse_documents(" \n\t", InputFormat::Auto, None).expect_err("empty input");
|
||||
assert!(matches!(
|
||||
empty_error,
|
||||
CliError::Runtime(message)
|
||||
if message.contains("JSON input is empty")
|
||||
));
|
||||
|
||||
let invalid_json = parse_documents("{\"ok\": true,,}\n", InputFormat::Auto, None)
|
||||
.expect_err("invalid json should fail");
|
||||
assert!(matches!(
|
||||
invalid_json,
|
||||
CliError::Runtime(message)
|
||||
if message.contains("invalid JSON input")
|
||||
&& !message.contains("JSONL")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjson_input_format_accepts_json_alias() {
|
||||
assert_eq!(
|
||||
parse_cjson_input_format("json").expect("json alias"),
|
||||
InputFormat::Auto
|
||||
);
|
||||
assert_eq!(
|
||||
parse_cjson_input_format("jsonl").expect("jsonl"),
|
||||
InputFormat::Jsonl
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_readers_reject_payloads_above_size_limit() {
|
||||
let error = read_to_string_limited(std::io::Cursor::new("abcd"), 3, "stdin")
|
||||
.expect_err("oversize stdin rejected");
|
||||
assert!(error.to_string().contains("cjson input limit"));
|
||||
|
||||
let temp = std::env::temp_dir().join(format!(
|
||||
"cjson-large-input-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock")
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::write(&temp, "abcd").expect("fixture");
|
||||
let error = read_path_to_string_limited(&temp, 3).expect_err("oversize file rejected");
|
||||
assert!(error.to_string().contains("cjson input limit"));
|
||||
std::fs::remove_file(temp).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_keys_rejects_extreme_json_nesting() {
|
||||
let mut value = json!(true);
|
||||
for _ in 0..(MAX_SORT_DEPTH + 2) {
|
||||
value = json!({ "child": value });
|
||||
}
|
||||
|
||||
let error = serialize_document(value, true).expect_err("deep sort rejected");
|
||||
|
||||
assert!(error.to_string().contains("sort-keys limit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_can_sort_keys_recursively() {
|
||||
let rendered = compact_documents(
|
||||
vec![json!({
|
||||
"z": 3,
|
||||
"a": {"y": 2, "x": 1},
|
||||
"items": [{"b": 2, "a": 1}],
|
||||
"name": "Ada",
|
||||
})],
|
||||
CompactFormat::Json,
|
||||
true,
|
||||
)
|
||||
.expect("sorted compaction");
|
||||
|
||||
assert_eq!(
|
||||
rendered,
|
||||
"{\"a\":{\"x\":1,\"y\":2},\"items\":[{\"a\":1,\"b\":2}],\"name\":\"Ada\",\"z\":3}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_supports_text_and_json_wrapper_modes() {
|
||||
let text_exit = run(&Cli {
|
||||
common: common_args(false, InputFormat::Auto),
|
||||
sort_keys: false,
|
||||
path: Some(
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("fixtures")
|
||||
.join("cjson")
|
||||
.join("sample.json"),
|
||||
),
|
||||
})
|
||||
.expect("text run");
|
||||
assert_eq!(text_exit, ExitCode::Success);
|
||||
|
||||
let json_exit = run(&Cli {
|
||||
common: common_args(true, InputFormat::Jsonl),
|
||||
sort_keys: true,
|
||||
path: Some(
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("fixtures")
|
||||
.join("cjson")
|
||||
.join("records.jsonl"),
|
||||
),
|
||||
})
|
||||
.expect("json run");
|
||||
assert_eq!(json_exit, ExitCode::Success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_input_accepts_multiple_stdin_paths() {
|
||||
let temp = std::env::temp_dir().join(format!(
|
||||
"cjson-stdin-paths-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock")
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&temp).expect("tempdir");
|
||||
let sample = temp.join("sample.json");
|
||||
let second = temp.join("second.json");
|
||||
std::fs::write(&sample, "{\"ok\":true}\n").expect("sample");
|
||||
std::fs::write(&second, "{\"ok\":false}\n").expect("second");
|
||||
|
||||
let loaded_input = load_input_from_buffer(
|
||||
&Cli {
|
||||
common: common_args(false, InputFormat::Lines),
|
||||
sort_keys: false,
|
||||
path: None,
|
||||
},
|
||||
&format!("{}\n{}\n", sample.display(), second.display()),
|
||||
)
|
||||
.expect("stdin paths");
|
||||
let LoadedInput::Paths(paths) = loaded_input else {
|
||||
panic!("expected path stream input");
|
||||
};
|
||||
let loaded = parse_documents_from_paths(&paths).expect("parsed");
|
||||
assert_eq!(loaded.format, CompactFormat::Jsonl);
|
||||
assert_eq!(loaded.documents.len(), 2);
|
||||
|
||||
std::fs::remove_dir_all(temp).expect("cleanup");
|
||||
}
|
||||
|
||||
fn load_input_from_buffer(cli: &Cli, buffer: &str) -> Result<LoadedInput, CliError> {
|
||||
if cli.common.input_format != InputFormat::Jsonl
|
||||
&& let Some(paths) =
|
||||
read_existing_stdin_paths(buffer, cli.common.input_format, "cjson")?
|
||||
{
|
||||
return Ok(LoadedInput::Paths(paths));
|
||||
}
|
||||
Ok(LoadedInput::Content(buffer.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Binary entry point for `cjson`.
|
||||
|
||||
fn main() {
|
||||
std::process::exit(cjson::main_entry());
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Integration tests for the `cjson` command.
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
fn cargo_command() -> Command {
|
||||
Command::cargo_bin("cjson").expect("binary")
|
||||
}
|
||||
|
||||
fn cargo_binary() -> PathBuf {
|
||||
assert_cmd::cargo::cargo_bin("cjson")
|
||||
}
|
||||
|
||||
fn powershell_command(script: String) -> Command {
|
||||
let mut command = Command::new("pwsh");
|
||||
command.args(["-NoProfile", "-Command"]).arg(script);
|
||||
command
|
||||
}
|
||||
|
||||
fn ps_quote(value: impl std::fmt::Display) -> String {
|
||||
format!("'{}'", value.to_string().replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
}
|
||||
|
||||
fn fixture(path: &str) -> PathBuf {
|
||||
let fixture = workspace_root().join("fixtures").join(path);
|
||||
assert!(
|
||||
fixture.exists(),
|
||||
"missing fixture `{path}` at {}",
|
||||
fixture.display()
|
||||
);
|
||||
fixture
|
||||
}
|
||||
|
||||
fn json_stdout(output: &[u8]) -> Value {
|
||||
serde_json::from_slice(output).unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"stdout should be valid JSON: {error}\n{}",
|
||||
String::from_utf8_lossy(output)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
struct TempTestDir {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempTestDir {
|
||||
fn path(&self) -> &std::path::Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempTestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_test_dir(name: &str) -> TempTestDir {
|
||||
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
loop {
|
||||
let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("cjson-{}-{name}-{unique}", std::process::id()));
|
||||
match fs::create_dir(&path) {
|
||||
Ok(()) => return TempTestDir { path },
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(error) => panic!("temp test dir: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacts_json_file_in_text_mode() {
|
||||
let output = cargo_command()
|
||||
.arg(fixture("cjson/sample.json"))
|
||||
.assert()
|
||||
.success()
|
||||
.get_output()
|
||||
.stdout
|
||||
.clone();
|
||||
|
||||
let compacted: Value = json_stdout(&output);
|
||||
assert_eq!(
|
||||
compacted["name"],
|
||||
"Ada",
|
||||
"stdout={}",
|
||||
String::from_utf8_lossy(&output)
|
||||
);
|
||||
assert_eq!(
|
||||
compacted["a"]["x"],
|
||||
1,
|
||||
"stdout={}",
|
||||
String::from_utf8_lossy(&output)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_keys_reorders_objects_recursively() {
|
||||
cargo_command()
|
||||
.arg("--sort-keys")
|
||||
.write_stdin(
|
||||
"{\"z\":3,\"a\":{\"y\":2,\"x\":1},\"items\":[{\"b\":2,\"a\":1}],\"name\":\"Ada\"}",
|
||||
)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(
|
||||
"{\"a\":{\"x\":1,\"y\":2},\"items\":[{\"a\":1,\"b\":2}],\"name\":\"Ada\",\"z\":3}\n",
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn explicit_path_wins_over_piped_stdin_noise() {
|
||||
cargo_command()
|
||||
.arg(fixture("cjson/sample.json"))
|
||||
.write_stdin("not json from upstream pipeline")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"name\":\"Ada\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_wrapper_reports_jsonl_documents() {
|
||||
let output = cargo_command()
|
||||
.arg("--input-format")
|
||||
.arg("jsonl")
|
||||
.arg("--sort-keys")
|
||||
.arg("--json")
|
||||
.arg(fixture("cjson/records.jsonl"))
|
||||
.assert()
|
||||
.success()
|
||||
.get_output()
|
||||
.stdout
|
||||
.clone();
|
||||
|
||||
let payload = json_stdout(&output);
|
||||
assert_eq!(
|
||||
payload["format"],
|
||||
"jsonl",
|
||||
"stdout={}",
|
||||
String::from_utf8_lossy(&output)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["documents"],
|
||||
2,
|
||||
"stdout={}",
|
||||
String::from_utf8_lossy(&output)
|
||||
);
|
||||
assert!(
|
||||
payload["text"]
|
||||
.as_str()
|
||||
.is_some_and(|text| text.contains("\"event\":\"login\",\"ok\":true")),
|
||||
"stdout={}",
|
||||
String::from_utf8_lossy(&output)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_mode_treats_explicit_jsonl_and_ndjson_paths_as_line_streams() {
|
||||
let temp = temp_test_dir("auto-jsonl-paths");
|
||||
let jsonl = temp.path().join("single.jsonl");
|
||||
let ndjson = temp.path().join("single.ndjson");
|
||||
fs::write(&jsonl, "{\"ok\":true}\n").expect("jsonl fixture");
|
||||
fs::write(&ndjson, "{\"ok\":true}\n").expect("ndjson fixture");
|
||||
|
||||
for path in [&jsonl, &ndjson] {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--json")
|
||||
.arg(path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"format\":\"jsonl\""))
|
||||
.stdout(predicate::str::contains("\"documents\":1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_powershell_pipeline() {
|
||||
let binary = cargo_binary();
|
||||
let input = fixture("cjson/records.jsonl");
|
||||
let script = format!(
|
||||
"[System.IO.File]::ReadLines({}) | & {} --input-format jsonl --sort-keys",
|
||||
ps_quote(input.display()),
|
||||
ps_quote(binary.display())
|
||||
);
|
||||
|
||||
powershell_command(script)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(
|
||||
"{\"event\":\"login\",\"ok\":true}",
|
||||
))
|
||||
.stdout(predicate::str::contains(
|
||||
"{\"event\":\"logout\",\"ok\":false}",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_single_stdin_path_stream_in_lines_mode() {
|
||||
cargo_command()
|
||||
.args(["--input-format", "lines"])
|
||||
.write_stdin(format!("{}\n", fixture("cjson/sample.json").display()))
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"name\":\"Ada\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_mode_accepts_windows_paths_with_quotes_and_spaces() {
|
||||
let temp = temp_test_dir("quoted path");
|
||||
let path = temp.path().join("Ada's sample.json");
|
||||
fs::write(&path, "{\"name\":\"Ada\",\"ok\":true}\n").expect("quoted path fixture");
|
||||
|
||||
cargo_command()
|
||||
.args(["--input-format", "lines"])
|
||||
.write_stdin(format!("{}\n", path.display()))
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("\"name\":\"Ada\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_includes_examples_and_sort_keys_flag() {
|
||||
cargo_command()
|
||||
.arg("--help")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("--sort-keys"))
|
||||
.stdout(predicate::str::contains(
|
||||
"bat --style=plain --paging=never .\\fixtures\\cjson\\records.jsonl",
|
||||
))
|
||||
.stdout(predicate::str::contains("ConvertFrom-Json"));
|
||||
}
|
||||
Reference in New Issue
Block a user