Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions src/filters/stat.toml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand Down
30 changes: 19 additions & 11 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
}
}

Expand Down
120 changes: 109 additions & 11 deletions src/json_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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 {
Expand All @@ -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<String> {
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<String> = 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<String> {
let value: Value = serde_json::from_str(json_str).context("Failed to parse JSON")?;
Expand Down
34 changes: 24 additions & 10 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,16 @@ enum Commands {
command: Vec<String>,
},

/// 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
Expand Down Expand Up @@ -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<String>,
/// Additional wget arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
Expand Down Expand Up @@ -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)?;
}
}

Expand Down Expand Up @@ -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 <file> 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)?;
}
}

Expand Down
Loading