diff --git a/src/filters/stat.toml b/src/filters/stat.toml index 24d9d946..8c240c05 100644 --- a/src/filters/stat.toml +++ b/src/filters/stat.toml @@ -1,21 +1,17 @@ [filters.stat] -description = "Compact stat output — strip blank lines" +description = "Compact stat output — strip device/inode/birth noise" match_command = "^stat\\b" strip_ansi = true strip_lines_matching = [ "^\\s*$", + "^\\s*Device:", + "^\\s*Birth:", ] -max_lines = 30 +truncate_lines_at = 120 +max_lines = 20 [[tests.stat]] -name = "macOS stat output kept" -input = """ -16777234 8690244974 -rw-r--r-- 1 patrick staff 0 12345 "Mar 10 12:00:00 2026" "Mar 10 11:00:00 2026" "Mar 10 11:00:00 2026" "Mar 9 10:00:00 2026" 4096 24 0 file.txt -""" -expected = "16777234 8690244974 -rw-r--r-- 1 patrick staff 0 12345 \"Mar 10 12:00:00 2026\" \"Mar 10 11:00:00 2026\" \"Mar 10 11:00:00 2026\" \"Mar 9 10:00:00 2026\" 4096 24 0 file.txt" - -[[tests.stat]] -name = "linux stat output kept" +name = "linux stat output strips device and birth" input = """ File: main.rs Size: 12345 Blocks: 24 IO Block: 4096 regular file @@ -26,7 +22,21 @@ Modify: 2026-03-10 11:00:00.000000000 +0100 Change: 2026-03-10 11:00:00.000000000 +0100 Birth: 2026-03-09 10:00:00.000000000 +0100 """ -expected = " File: main.rs\n Size: 12345 Blocks: 24 IO Block: 4096 regular file\nDevice: 801h/2049d Inode: 1234567 Links: 1\nAccess: (0644/-rw-r--r--) Uid: ( 1000/ patrick) Gid: ( 1000/ patrick)\nAccess: 2026-03-10 12:00:00.000000000 +0100\nModify: 2026-03-10 11:00:00.000000000 +0100\nChange: 2026-03-10 11:00:00.000000000 +0100\n Birth: 2026-03-09 10:00:00.000000000 +0100" +expected = " File: main.rs\n Size: 12345 Blocks: 24 IO Block: 4096 regular file\nAccess: (0644/-rw-r--r--) Uid: ( 1000/ patrick) Gid: ( 1000/ patrick)\nAccess: 2026-03-10 12:00:00.000000000 +0100\nModify: 2026-03-10 11:00:00.000000000 +0100\nChange: 2026-03-10 11:00:00.000000000 +0100" + +[[tests.stat]] +name = "macOS stat -x strips device and birth" +input = """ + File: "main.rs" + Size: 82848 FileType: Regular File + Mode: (0644/-rw-r--r--) Uid: ( 501/ patrick) Gid: ( 20/ staff) +Device: 1,15 Inode: 66302332 Links: 1 +Access: Wed Mar 18 21:21:15 2026 +Modify: Wed Mar 18 20:56:11 2026 +Change: Wed Mar 18 20:56:11 2026 + Birth: Wed Mar 18 20:56:11 2026 +""" +expected = " File: \"main.rs\"\n Size: 82848 FileType: Regular File\n Mode: (0644/-rw-r--r--) Uid: ( 501/ patrick) Gid: ( 20/ staff)\nAccess: Wed Mar 18 21:21:15 2026\nModify: Wed Mar 18 20:56:11 2026\nChange: Wed Mar 18 20:56:11 2026" [[tests.stat]] name = "empty input passes through" diff --git a/src/git.rs b/src/git.rs index 3d49fdd6..4bb7f674 100644 --- a/src/git.rs +++ b/src/git.rs @@ -297,7 +297,7 @@ pub(crate) fn compact_diff(diff: &str, max_lines: usize) -> String { let mut removed = 0; let mut in_hunk = false; let mut hunk_lines = 0; - let max_hunk_lines = 30; + let max_hunk_lines = 100; let mut was_truncated = false; for line in diff.lines() { @@ -532,17 +532,25 @@ fn filter_log_output( Some(h) => truncate_line(h.trim(), truncate_width), None => continue, }; - // Remaining lines are the body — keep first non-empty line only - let body_line = lines.map(|l| l.trim()).find(|l| { - !l.is_empty() && !l.starts_with("Signed-off-by:") && !l.starts_with("Co-authored-by:") - }); - - match body_line { - Some(body) => { - let truncated_body = truncate_line(body, truncate_width); - result.push(format!("{}\n {}", header, truncated_body)); + // Remaining lines are the body — keep up to 3 non-empty, non-trailer lines + let body_lines: Vec<&str> = lines + .map(|l| l.trim()) + .filter(|l| { + !l.is_empty() + && !l.starts_with("Signed-off-by:") + && !l.starts_with("Co-authored-by:") + }) + .take(3) + .collect(); + + if body_lines.is_empty() { + result.push(header); + } else { + let mut entry = header; + for body in &body_lines { + entry.push_str(&format!("\n {}", truncate_line(body, truncate_width))); } - None => result.push(header), + result.push(entry); } } diff --git a/src/json_cmd.rs b/src/json_cmd.rs index 76bae3ae..685c8f62 100644 --- a/src/json_cmd.rs +++ b/src/json_cmd.rs @@ -33,8 +33,8 @@ fn validate_json_extension(file: &Path) -> Result<()> { Ok(()) } -/// Show JSON structure without values -pub fn run(file: &Path, max_depth: usize, verbose: u8) -> Result<()> { +/// Show JSON (compact with values, or schema-only with --schema) +pub fn run(file: &Path, max_depth: usize, schema_only: bool, verbose: u8) -> Result<()> { validate_json_extension(file)?; let timer = tracking::TimedExecution::start(); @@ -45,19 +45,23 @@ pub fn run(file: &Path, max_depth: usize, verbose: u8) -> Result<()> { let content = fs::read_to_string(file) .with_context(|| format!("Failed to read file: {}", file.display()))?; - let schema = filter_json_string(&content, max_depth)?; - println!("{}", schema); + let output = if schema_only { + filter_json_string(&content, max_depth)? + } else { + filter_json_compact(&content, max_depth)? + }; + println!("{}", output); timer.track( &format!("cat {}", file.display()), "rtk json", &content, - &schema, + &output, ); Ok(()) } -/// Show JSON structure from stdin -pub fn run_stdin(max_depth: usize, verbose: u8) -> Result<()> { +/// Show JSON from stdin +pub fn run_stdin(max_depth: usize, schema_only: bool, verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -70,13 +74,107 @@ pub fn run_stdin(max_depth: usize, verbose: u8) -> Result<()> { .read_to_string(&mut content) .context("Failed to read from stdin")?; - let schema = filter_json_string(&content, max_depth)?; - println!("{}", schema); - timer.track("cat - (stdin)", "rtk json -", &content, &schema); + let output = if schema_only { + filter_json_string(&content, max_depth)? + } else { + filter_json_compact(&content, max_depth)? + }; + println!("{}", output); + timer.track("cat - (stdin)", "rtk json -", &content, &output); Ok(()) } -/// Parse a JSON string and return its schema representation. +/// Parse a JSON string and return compact representation with values preserved. +/// Long strings are truncated, arrays are summarized. +pub fn filter_json_compact(json_str: &str, max_depth: usize) -> Result { + let value: Value = serde_json::from_str(json_str).context("Failed to parse JSON")?; + Ok(compact_json(&value, 0, max_depth)) +} + +fn compact_json(value: &Value, depth: usize, max_depth: usize) -> String { + let indent = " ".repeat(depth); + + if depth > max_depth { + return format!("{}...", indent); + } + + match value { + Value::Null => format!("{}null", indent), + Value::Bool(b) => format!("{}{}", indent, b), + Value::Number(n) => format!("{}{}", indent, n), + Value::String(s) => { + if s.len() > 80 { + format!("{}\"{}...\"", indent, &s[..77]) + } else { + format!("{}\"{}\"", indent, s) + } + } + Value::Array(arr) => { + if arr.is_empty() { + format!("{}[]", indent) + } else if arr.len() > 5 { + let first = compact_json(&arr[0], depth + 1, max_depth); + format!("{}[{}, ... +{} more]", indent, first.trim(), arr.len() - 1) + } else { + let items: Vec = arr + .iter() + .map(|v| compact_json(v, depth + 1, max_depth)) + .collect(); + let all_simple = arr.iter().all(|v| { + matches!( + v, + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) + ) + }); + if all_simple { + let inline: Vec<&str> = items.iter().map(|s| s.trim()).collect(); + format!("{}[{}]", indent, inline.join(", ")) + } else { + let mut lines = vec![format!("{}[", indent)]; + for item in &items { + lines.push(format!("{},", item)); + } + lines.push(format!("{}]", indent)); + lines.join("\n") + } + } + } + Value::Object(map) => { + if map.is_empty() { + format!("{}{{}}", indent) + } else { + let mut lines = vec![format!("{}{{", indent)]; + let mut keys: Vec<_> = map.keys().collect(); + keys.sort(); + + for (i, key) in keys.iter().enumerate() { + let val = &map[*key]; + let is_simple = matches!( + val, + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) + ); + + if is_simple { + let val_str = compact_json(val, 0, max_depth); + lines.push(format!("{} {}: {}", indent, key, val_str.trim())); + } else { + lines.push(format!("{} {}:", indent, key)); + lines.push(compact_json(val, depth + 1, max_depth)); + } + + if i >= 20 { + lines.push(format!("{} ... +{} more keys", indent, keys.len() - i - 1)); + break; + } + } + lines.push(format!("{}}}", indent)); + lines.join("\n") + } + } + } +} + +/// Parse a JSON string and return its schema representation (types only, no values). /// Useful for piping JSON from other commands (e.g., `gh api`, `curl`). pub fn filter_json_string(json_str: &str, max_depth: usize) -> Result { let value: Value = serde_json::from_str(json_str).context("Failed to parse JSON")?; diff --git a/src/main.rs b/src/main.rs index 2bbc4bb2..176adbd8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -237,13 +237,16 @@ enum Commands { command: Vec, }, - /// Show JSON structure without values + /// Show JSON (compact values, or schema-only with --schema) Json { /// JSON file file: PathBuf, /// Max depth #[arg(short, long, default_value = "5")] depth: usize, + /// Show structure only (strip all values) + #[arg(long)] + schema: bool, }, /// Summarize project dependencies @@ -387,9 +390,9 @@ enum Commands { Wget { /// URL to download url: String, - /// Output to stdout instead of file - #[arg(short = 'O', long)] - stdout: bool, + /// Output file (-O - for stdout) + #[arg(short = 'O', long = "output-document", allow_hyphen_values = true)] + output: Option, /// Additional wget arguments #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, @@ -1501,11 +1504,15 @@ fn main() -> Result<()> { runner::run_test(&cmd, cli.verbose)?; } - Commands::Json { file, depth } => { + Commands::Json { + file, + depth, + schema, + } => { if file == Path::new("-") { - json_cmd::run_stdin(depth, cli.verbose)?; + json_cmd::run_stdin(depth, schema, cli.verbose)?; } else { - json_cmd::run(&file, depth, cli.verbose)?; + json_cmd::run(&file, depth, schema, cli.verbose)?; } } @@ -1702,11 +1709,18 @@ fn main() -> Result<()> { } } - Commands::Wget { url, stdout, args } => { - if stdout { + Commands::Wget { url, output, args } => { + if output.as_deref() == Some("-") { wget_cmd::run_stdout(&url, &args, cli.verbose)?; } else { - wget_cmd::run(&url, &args, cli.verbose)?; + // Pass -O through to wget via args + let mut all_args = Vec::new(); + if let Some(out_file) = &output { + all_args.push("-O".to_string()); + all_args.push(out_file.clone()); + } + all_args.extend(args); + wget_cmd::run(&url, &all_args, cli.verbose)?; } }