From 67a3576c9f2132e212977d3ae35660c16c14215a Mon Sep 17 00:00:00 2001 From: Adrien Eppling Date: Fri, 7 Aug 2026 15:41:35 +0200 Subject: [PATCH] feat(ls): cap listing with tee tail hint and standard dotfile semantics - Cap displayed entries at CAP_INVENTORY (50); overflow spills to a tee file holding ONLY the dropped entries, recoverable with one command: [see remaining: tail -n +1 ~/.local/share/rtk/tee/_ls-hidden.log] - Record RTK-filtered noise dirs (non-dot only) in the same tee file and surface counts as "... (N more, M filtered)" - Follow standard ls dotfile semantics: pass -a to the underlying ls only when the user asked for it (long format kept for parsing) - Drop the TTY-only Summary line and extension histogram: agents never saw it and it misled humans about agent-visible output Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZkPEr1jwjMViU4h4uPUpN --- src/cmds/system/ls.rs | 331 +++++++++++++++++++++++++++++------------- 1 file changed, 227 insertions(+), 104 deletions(-) diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index 767470fd19..f0f68481d6 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -2,11 +2,10 @@ use super::constants::NOISE_DIRS; use crate::core::runner::{self, RunOptions}; -use crate::core::truncate::{reduced, CAP_WARNINGS}; +use crate::core::truncate::CAP_INVENTORY; use crate::core::utils::resolved_command; use anyhow::Result; use regex::Regex; -use std::io::IsTerminal; use std::sync::LazyLock; /// Matches the date+time portion in `ls -la` output, which serves as a @@ -50,7 +49,7 @@ pub fn run(args: &[String], verbose: u8) -> Result { let mut cmd = resolved_command("ls"); cmd.env("LC_ALL", "C"); - cmd.arg("-la"); + cmd.arg(if show_all { "-la" } else { "-l" }); for flag in &flags { if flag.starts_with("--") { if *flag != "--all" { @@ -87,7 +86,7 @@ pub fn run(args: &[String], verbose: u8) -> Result { "ls", &format!("-la {}", target_display), |raw| { - let (entries, summary, parsed_count) = compact_ls(raw, show_all, show_long); + let (entries, parsed_count, truncated, noise) = compact_ls(raw, show_all, show_long); // If no lines were parsed (e.g., unrecognized locale), fall back to raw output. // This is safer than returning "(empty)" for a non-empty directory. @@ -98,13 +97,12 @@ pub fn run(args: &[String], verbose: u8) -> Result { return raw.to_string(); } - // Only show summary in interactive mode (not when piped) - let is_tty = std::io::stdout().is_terminal(); - let filtered = if is_tty { - format!("{}{}", entries, summary) - } else { - entries - }; + let mut filtered = entries; + + if let Some(hint) = hidden_hint(&truncated, &noise) { + filtered.push_str(&hint); + filtered.push('\n'); + } if verbose > 0 { eprintln!( @@ -126,6 +124,33 @@ pub fn run(args: &[String], verbose: u8) -> Result { ) } +/// Build the recovery hint for entries dropped from the listing — +/// truncated past the display cap and/or RTK-filtered noise. +/// +/// Standard RTK pattern: a truncation note plus a one-shot command to +/// retrieve the remaining. The tee file contains ONLY the dropped +/// entries (truncated first, then filtered), so `tail -n +1` (whole +/// file) retrieves nothing the agent has already seen. +fn hidden_hint(truncated: &[String], filtered: &[String]) -> Option { + if truncated.is_empty() && filtered.is_empty() { + return None; + } + let note = match (truncated.len(), filtered.len()) { + (0, f) => format!("... ({} filtered)", f), + (t, 0) => format!("... ({} more)", t), + (t, f) => format!("... ({} more, {} filtered)", t, f), + }; + let mut hidden_only = String::new(); + for line in truncated.iter().chain(filtered) { + hidden_only.push_str(line); + hidden_only.push('\n'); + } + match crate::core::tee::force_tee_tail_hint(&hidden_only, "ls-hidden", 1) { + Some(tee_hint) => Some(format!("{}\n{}", note, tee_hint)), + None => Some(note), + } +} + /// Format bytes into human-readable size fn human_size(bytes: u64) -> String { if bytes >= 1_048_576 { @@ -235,18 +260,25 @@ fn perms_to_octal(perms: &str) -> Option { /// 755 name/ (dirs) /// 644 name size (files) /// -/// Returns (entries, summary, parsed_count) so caller can suppress summary when piped. +/// Returns (entries, parsed_count, truncated, filtered) so caller can emit +/// a recovery hint when anything was dropped. /// parsed_count tracks how many non-header lines were successfully parsed. +/// truncated holds compact lines beyond the CAP_INVENTORY display cap. +/// filtered holds the display name of each entry RTK removed from view +/// (non-dot noise dirs without -a as `name/`, plus raw unparsable +/// non-dotdir lines). /// If parsed_count == 0 but raw had content, caller should fall back to raw output. -fn compact_ls(raw: &str, show_all: bool, show_long: bool) -> (String, String, usize) { - use std::collections::HashMap; - +fn compact_ls( + raw: &str, + show_all: bool, + show_long: bool, +) -> (String, usize, Vec, Vec) { let mut dirs: Vec<(String, Option)> = Vec::new(); // (name, octal_perms) let mut files: Vec<(String, String, Option)> = Vec::new(); // (name, size, octal_perms) - let mut by_ext: HashMap = HashMap::new(); let mut lines_seen: usize = 0; let mut parsed_count: usize = 0; let mut dotdirs: usize = 0; + let mut hidden: Vec = Vec::new(); for line in raw.lines() { if line.starts_with("total ") || line.is_empty() { @@ -257,6 +289,8 @@ fn compact_ls(raw: &str, show_all: bool, show_long: bool) -> (String, String, us let Some((file_type, perms, size, name)) = parse_ls_line(line) else { if is_dotdir(line) { dotdirs += 1; + } else { + hidden.push(line.trim().to_string()); } continue; }; @@ -264,6 +298,11 @@ fn compact_ls(raw: &str, show_all: bool, show_long: bool) -> (String, String, us // Filter noise dirs unless -a if !show_all && NOISE_DIRS.iter().any(|noise| name == *noise) { + // Dot-prefixed entries are hidden by standard ls without -a + // anyway — only record what RTK itself removes from view. + if !name.starts_with('.') { + hidden.push(format!("{}/", name)); + } continue; } @@ -279,12 +318,6 @@ fn compact_ls(raw: &str, show_all: bool, show_long: bool) -> (String, String, us dirs.push((name, octal)); } else { // Regular files, symlinks, character/block devices, pipes, sockets - let ext = if let Some(pos) = name.rfind('.') { - name[pos..].to_string() - } else { - "no ext".to_string() - }; - *by_ext.entry(ext).or_insert(0) += 1; files.push((name, human_size(size), octal)); } } @@ -293,60 +326,45 @@ fn compact_ls(raw: &str, show_all: bool, show_long: bool) -> (String, String, us if lines_seen > 0 && parsed_count == 0 { if dotdirs == lines_seen { // Only . and .. entries (empty directory) - return ("(empty)\n".to_string(), String::new(), 0); + return ("(empty)\n".to_string(), 0, Vec::new(), Vec::new()); } // Real content that couldn't be parsed (e.g., non-English locale) - return (String::new(), String::new(), 0); + return (String::new(), 0, Vec::new(), Vec::new()); } - return ("(empty)\n".to_string(), String::new(), 0); + // Everything parsed was filtered out (e.g., only noise dirs) — + // keep hidden so the caller can still emit a recovery hint. + return ("(empty)\n".to_string(), parsed_count, Vec::new(), hidden); } - let mut entries = String::new(); - - // Dirs first, compact + // Dirs first, then files — one compact line each + let mut all_lines: Vec = Vec::with_capacity(dirs.len() + files.len()); for (name, octal) in &dirs { - if let Some(octal) = octal { - entries.push_str(octal); - entries.push_str(" "); - } - entries.push_str(name); - entries.push_str("/\n"); + all_lines.push(match octal { + Some(octal) => format!("{} {}/", octal, name), + None => format!("{}/", name), + }); } - - // Files with size for (name, size, octal) in &files { - if let Some(octal) = octal { - entries.push_str(octal); - entries.push_str(" "); - } - entries.push_str(name); - entries.push_str(" "); - entries.push_str(size); - entries.push('\n'); + all_lines.push(match octal { + Some(octal) => format!("{} {} {}", octal, name, size), + None => format!("{} {}", name, size), + }); } - // Summary line (separate so caller can suppress when piped) - let mut summary = format!("\nSummary: {} files, {} dirs", files.len(), dirs.len()); - if !by_ext.is_empty() { - // inline single-line summary — fewer entries to avoid wrapping. - const MAX_EXT_SUMMARY: usize = reduced(CAP_WARNINGS, 5); - let mut ext_counts: Vec<_> = by_ext.iter().collect(); - ext_counts.sort_by(|a, b| b.1.cmp(a.1)); - let ext_parts: Vec = ext_counts - .iter() - .take(MAX_EXT_SUMMARY) - .map(|(ext, count)| format!("{} {}", count, ext)) - .collect(); - summary.push_str(" ("); - summary.push_str(&ext_parts.join(", ")); - if ext_counts.len() > MAX_EXT_SUMMARY { - summary.push_str(&format!(", +{} more", ext_counts.len() - MAX_EXT_SUMMARY)); - } - summary.push(')'); + // Cap the displayed listing; the rest is recoverable via the tee hint. + let truncated = if all_lines.len() > CAP_INVENTORY { + all_lines.split_off(CAP_INVENTORY) + } else { + Vec::new() + }; + + let mut entries = String::new(); + for line in &all_lines { + entries.push_str(line); + entries.push('\n'); } - summary.push('\n'); - (entries, summary, parsed_count) + (entries, parsed_count, truncated, hidden) } #[cfg(test)] @@ -361,7 +379,7 @@ mod tests { drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 Cargo.toml\n\ -rw-r--r-- 1 user staff 5678 Jan 1 12:00 README.md\n"; - let (entries, _summary, _parsed) = compact_ls(input, false, false); + let (entries, _parsed, _truncated, _hidden) = compact_ls(input, false, false); assert!(entries.contains("src/")); assert!(entries.contains("Cargo.toml")); assert!(entries.contains("README.md")); @@ -382,7 +400,7 @@ mod tests { drwxr-xr-x 2 user staff 64 Jan 1 12:00 target\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 100 Jan 1 12:00 main.rs\n"; - let (entries, _summary, _parsed) = compact_ls(input, false, false); + let (entries, _parsed, _truncated, _hidden) = compact_ls(input, false, false); assert!(!entries.contains("node_modules")); assert!(!entries.contains(".git")); assert!(!entries.contains("target")); @@ -390,12 +408,138 @@ mod tests { assert!(entries.contains("main.rs")); } + #[test] + fn test_compact_hidden_noise_dirs() { + let input = "total 8\n\ + drwxr-xr-x 2 user staff 64 Jan 1 12:00 node_modules\n\ + drwxr-xr-x 2 user staff 64 Jan 1 12:00 .git\n\ + drwxr-xr-x 2 user staff 64 Jan 1 12:00 target\n\ + drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ + -rw-r--r-- 1 user staff 100 Jan 1 12:00 main.rs\n"; + let (_entries, _parsed, _truncated, hidden) = compact_ls(input, false, false); + assert_eq!( + hidden, + vec!["node_modules/", "target/"], + "non-dot noise dirs are RTK-filtered; .git is standard ls hidden" + ); + + let (_entries, _parsed, _truncated, hidden_all) = compact_ls(input, true, false); + assert!(hidden_all.is_empty(), "-a shows noise dirs, nothing hidden"); + } + + #[test] + fn test_compact_hidden_unparsable_line() { + // A non-dotdir line the date regex can't parse is silently dropped — + // it must be collected as hidden so the caller emits a recovery hint. + let input = "total 8\n\ + -rw-r--r-- 1 user staff 100 Jan 1 12:00 main.rs\n\ + garbage line without date anchor\n"; + let (entries, _parsed, _truncated, hidden) = compact_ls(input, false, false); + assert!(entries.contains("main.rs")); + assert_eq!(hidden, vec!["garbage line without date anchor"]); + } + + #[test] + fn test_compact_hidden_clean_listing() { + let input = "total 8\n\ + drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ + -rw-r--r-- 1 user staff 100 Jan 1 12:00 main.rs\n"; + let (_entries, _parsed, _truncated, hidden) = compact_ls(input, false, false); + assert!(hidden.is_empty(), "nothing dropped, no hint expected"); + } + + #[test] + fn test_compact_only_noise_dirs_keeps_hidden() { + // Directory containing only noise dirs: output collapses to (empty), + // but RTK-filtered entries must survive so the agent knows they exist. + // .git is standard ls hidden (dot-prefixed), not RTK's filtering. + let input = "total 8\n\ + drwxr-xr-x 2 user staff 64 Jan 1 12:00 node_modules\n\ + drwxr-xr-x 2 user staff 64 Jan 1 12:00 .git\n"; + let (entries, parsed, _truncated, hidden) = compact_ls(input, false, false); + assert_eq!(entries, "(empty)\n"); + assert_eq!(parsed, 2); + assert_eq!(hidden, vec!["node_modules/"]); + } + + #[test] + fn test_hidden_hint_none_when_empty() { + assert!(hidden_hint(&[], &[]).is_none()); + } + + #[test] + fn test_hidden_hint_truncation_note_and_one_shot_command() { + let noise = vec!["node_modules/".to_string(), "target/".to_string()]; + let hint = hidden_hint(&[], &noise).expect("hint for hidden entries"); + assert!(hint.starts_with("... (2 filtered)")); + assert!( + !hint.contains("use -a"), + "standard ls flags are not RTK's job to teach: {hint}" + ); + assert!( + !hint.contains("full output"), + "must not point at already-seen output: {hint}" + ); + // Tee availability depends on environment; when present the hint is + // the standard one-shot retrieval command over the hidden-only file. + if hint.lines().count() > 1 { + assert!( + hint.contains("[see remaining: tail -n +1 "), + "tee hint must be the standard tail form: {hint}" + ); + } + } + + #[test] + fn test_hidden_hint_note_variants() { + let t = vec!["x 1B".to_string()]; + let f = vec!["target/".to_string()]; + assert!(hidden_hint(&t, &[]) + .expect("hint") + .starts_with("... (1 more)")); + assert!(hidden_hint(&[], &f) + .expect("hint") + .starts_with("... (1 filtered)")); + assert!(hidden_hint(&t, &f) + .expect("hint") + .starts_with("... (1 more, 1 filtered)")); + } + + #[test] + fn test_compact_truncates_past_cap() { + let mut input = String::from("total 0\n"); + for i in 0..60 { + input.push_str(&format!( + "-rw-r--r-- 1 user staff 100 Jan 1 12:00 file{:02}.txt\n", + i + )); + } + let (entries, _parsed, truncated, _hidden) = compact_ls(&input, false, false); + assert_eq!(entries.lines().count(), CAP_INVENTORY); + assert_eq!(truncated.len(), 60 - CAP_INVENTORY); + assert!(entries.contains("file00.txt")); + assert!(!entries.contains("file59.txt")); + assert!( + truncated.iter().any(|l| l.contains("file59.txt")), + "overflow entries must be recoverable via the tee file" + ); + } + + #[test] + fn test_compact_no_truncation_under_cap() { + let input = "total 8\n\ + drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ + -rw-r--r-- 1 user staff 100 Jan 1 12:00 main.rs\n"; + let (_entries, _parsed, truncated, _hidden) = compact_ls(input, false, false); + assert!(truncated.is_empty()); + } + #[test] fn test_compact_show_all() { let input = "total 8\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 .git\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n"; - let (entries, _summary, _parsed) = compact_ls(input, true, false); + let (entries, _parsed, _truncated, _hidden) = compact_ls(input, true, false); assert!(entries.contains(".git/")); assert!(entries.contains("src/")); } @@ -403,9 +547,8 @@ mod tests { #[test] fn test_compact_empty() { let input = "total 0\n"; - let (entries, summary, _parsed) = compact_ls(input, false, false); + let (entries, _parsed, _truncated, _hidden) = compact_ls(input, false, false); assert_eq!(entries, "(empty)\n"); - assert!(summary.is_empty()); } #[test] @@ -413,10 +556,9 @@ mod tests { let input = "total 8\n\ drwxr-xr-x 2 user user 4096 1月 1 12:00 .\n\ drwxr-xr-x 16 user user 20480 1月 1 12:00 ..\n"; - let (entries, summary, parsed_count) = compact_ls(input, false, false); + let (entries, parsed_count, _truncated, _hidden) = compact_ls(input, false, false); assert_eq!(parsed_count, 0); assert_eq!(entries, "(empty)\n"); - assert!(summary.is_empty()); } #[test] @@ -424,23 +566,9 @@ mod tests { let input = "total 0\n\ drwxr-xr-x 2 lumin wheel 64 Apr 23 00:37 .\n\ drwxr-xr-x 16 root wheel 164576 Apr 23 00:37 ..\n"; - let (entries, summary, parsed_count) = compact_ls(input, false, false); + let (entries, parsed_count, _truncated, _hidden) = compact_ls(input, false, false); assert_eq!(parsed_count, 0); assert_eq!(entries, "(empty)\n"); - assert!(summary.is_empty()); - } - - #[test] - fn test_compact_summary() { - let input = "total 48\n\ - drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ - -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n\ - -rw-r--r-- 1 user staff 5678 Jan 1 12:00 lib.rs\n\ - -rw-r--r-- 1 user staff 100 Jan 1 12:00 Cargo.toml\n"; - let (_entries, summary, _parsed) = compact_ls(input, false, false); - assert!(summary.contains("Summary: 3 files, 1 dirs")); - assert!(summary.contains(".rs")); - assert!(summary.contains(".toml")); } #[test] @@ -457,7 +585,7 @@ mod tests { fn test_compact_handles_filenames_with_spaces() { let input = "total 8\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 my file.txt\n"; - let (entries, _summary, _parsed) = compact_ls(input, false, false); + let (entries, _parsed, _truncated, _hidden) = compact_ls(input, false, false); assert!(entries.contains("my file.txt")); } @@ -465,25 +593,21 @@ mod tests { fn test_compact_symlinks() { let input = "total 8\n\ lrwxr-xr-x 1 user staff 10 Jan 1 12:00 link -> target\n"; - let (entries, _summary, _parsed) = compact_ls(input, false, false); + let (entries, _parsed, _truncated, _hidden) = compact_ls(input, false, false); assert!(entries.contains("link -> target")); } #[test] fn test_entries_no_summary() { - // Entries should never contain the summary line + // No summary line anywhere — pure entries (agent-first output) let input = "total 48\n\ drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n"; - let (entries, summary, _parsed) = compact_ls(input, false, false); + let (entries, _parsed, _truncated, _hidden) = compact_ls(input, false, false); assert!( !entries.contains("Summary:"), "entries must not contain summary" ); - assert!( - summary.contains("Summary:"), - "summary must contain the icon" - ); } #[test] @@ -494,7 +618,7 @@ mod tests { drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n\ -rw-r--r-- 1 user staff 5678 Jan 1 12:00 lib.rs\n"; - let (entries, _summary, _parsed) = compact_ls(input, false, false); + let (entries, _parsed, _truncated, _hidden) = compact_ls(input, false, false); let line_count = entries.lines().count(); assert_eq!( line_count, 3, @@ -509,7 +633,7 @@ mod tests { let input = "total 8\n\ -rw-r--r-- 1 fjeanne utilisa. du domaine 0 Mar 31 16:18 empty.txt\n\ -rw-r--r-- 1 fjeanne utilisa. du domaine 1234 Mar 31 16:18 data.json\n"; - let (entries, _summary, _parsed) = compact_ls(input, false, false); + let (entries, _parsed, _truncated, _hidden) = compact_ls(input, false, false); assert!( entries.contains("empty.txt"), "should contain 'empty.txt', got: {entries}" @@ -537,7 +661,7 @@ mod tests { // Some systems show year instead of time for old files let input = "total 8\n\ -rw-r--r-- 1 user staff 5678 Dec 25 2024 archive.tar\n"; - let (entries, _summary, _parsed) = compact_ls(input, false, false); + let (entries, _parsed, _truncated, _hidden) = compact_ls(input, false, false); assert!( entries.contains("archive.tar"), "should contain filename, got: {entries}" @@ -592,7 +716,7 @@ mod tests { // Regression test for #844: `rtk ls /dev/ttyACM*` returned "(empty)" // because character devices (type 'c') were not handled by compact_ls. let input = "crw-rw---- 1 root dialout 166, 0 Apr 22 09:46 /dev/ttyACM0\n"; - let (entries, _summary, _parsed) = compact_ls(input, false, false); + let (entries, _parsed, _truncated, _hidden) = compact_ls(input, false, false); assert!( entries.contains("/dev/ttyACM0"), "should contain device file, got: {entries}" @@ -604,7 +728,7 @@ mod tests { fn test_compact_device_files_macos_hex_size() { // macOS shows device major/minor as hex (e.g. 0x2000000) let input = "crw-rw-rw- 1 root wheel 0x2000000 Mar 31 19:25 /dev/tty\n"; - let (entries, _summary, _parsed) = compact_ls(input, false, false); + let (entries, _parsed, _truncated, _hidden) = compact_ls(input, false, false); assert!( entries.contains("/dev/tty"), "should contain device file, got: {entries}" @@ -614,7 +738,7 @@ mod tests { #[test] fn test_compact_block_device() { let input = "brw-rw---- 1 root disk 8, 0 Apr 22 09:46 /dev/sda\n"; - let (entries, _summary, _parsed) = compact_ls(input, false, false); + let (entries, _parsed, _truncated, _hidden) = compact_ls(input, false, false); assert!( entries.contains("/dev/sda"), "should contain block device, got: {entries}" @@ -673,7 +797,7 @@ mod tests { drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 Cargo.toml\n\ -rwxr-xr-x 1 user staff 500 Jan 1 12:00 build.sh\n"; - let (entries, _summary, _parsed) = compact_ls(input, false, true); + let (entries, _parsed, _truncated, _hidden) = compact_ls(input, false, true); assert!( entries.contains("755 src/"), "dir should be prefixed with octal perms, got: {entries}" @@ -694,7 +818,7 @@ mod tests { // under the hood. let input = "total 48\n\ -rw-r--r-- 1 user staff 1234 Jan 1 12:00 Cargo.toml\n"; - let (entries, _summary, _parsed) = compact_ls(input, false, false); + let (entries, _parsed, _truncated, _hidden) = compact_ls(input, false, false); assert!( !entries.contains("644"), "short format must not include octal perms, got: {entries}" @@ -707,9 +831,8 @@ mod tests { let input = "total 8\n\ drwxr-xr-x 2 user staff 64 1月 1 12:00 src\n\ -rw-r--r-- 1 user staff 1234 1月 1 12:00 main.rs\n"; - let (entries, summary, parsed_count) = compact_ls(input, false, false); + let (entries, parsed_count, _truncated, _hidden) = compact_ls(input, false, false); assert_eq!(parsed_count, 0); assert!(entries.is_empty()); - assert!(summary.is_empty()); } }