chore(release): prepare public source release

This commit is contained in:
MercuryToolbox Release
2026-07-18 15:41:59 +08:00
commit e365e5df4d
508 changed files with 163373 additions and 0 deletions
+25
View File
@@ -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
+829
View File
@@ -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()))
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `cjson`.
fn main() {
std::process::exit(cjson::main_entry());
}
+247
View File
@@ -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"));
}