1use crate::compress::generic::{strip_ansi, GenericCompressor};
2use crate::compress::listing_fold::{
3 finish_folded, fold_consecutive_runs, shape_key_for_basename, FoldEntry,
4};
5use crate::compress::{CompressionResult, Compressor};
6
7pub struct LsCompressor;
8
9impl Compressor for LsCompressor {
10 fn matches(&self, command: &str) -> bool {
11 command_tokens(command).any(|token| token == "ls")
12 }
13
14 fn compress_with_exit_code(
15 &self,
16 command: &str,
17 output: &str,
18 _exit_code: Option<i32>,
19 ) -> CompressionResult {
20 let stripped = strip_ansi(output);
21 if stripped.trim().is_empty() {
22 return CompressionResult::new(stripped);
23 }
24 if is_ls_recursive(&stripped) {
25 return GenericCompressor::compress_output(output).into();
26 }
27 let folded = compress_ls_listing(command, &stripped);
28 CompressionResult::new(folded)
29 }
30}
31
32fn command_tokens(command: &str) -> impl Iterator<Item = String> + '_ {
33 command
34 .split_whitespace()
35 .map(|token| token.trim_matches(|ch| matches!(ch, '\'' | '"')))
36 .filter(|token| {
37 !matches!(
38 *token,
39 "npx" | "pnpm" | "yarn" | "bun" | "bunx" | "exec" | "-m"
40 )
41 })
42 .map(|token| {
43 token
44 .rsplit(['/', '\\'])
45 .next()
46 .unwrap_or(token)
47 .trim_end_matches(".cmd")
48 .to_string()
49 })
50}
51
52fn is_ls_recursive(output: &str) -> bool {
53 output.lines().any(|line| {
54 let t = line.trim_end();
55 t.ends_with(':') && !t.starts_with("total ")
56 })
57}
58
59fn compress_ls_listing(command: &str, output: &str) -> String {
60 let long_format = command.split_whitespace().any(|t| {
61 let t = t.trim_start_matches('-');
62 t.contains('l')
63 });
64
65 let mut prefix = Vec::new();
66 let mut entries = Vec::new();
67
68 for line in output.lines() {
69 if line.starts_with("total ") {
70 prefix.push(line.to_string());
71 continue;
72 }
73 if line.trim().is_empty() {
74 continue;
75 }
76 let Some((dir, basename)) = parse_ls_line(line, long_format) else {
77 return output.to_string();
78 };
79 let shape_key = shape_key_for_basename(&dir, &basename);
80 entries.push(FoldEntry {
81 line: line.to_string(),
82 dir,
83 basename,
84 shape_key,
85 });
86 }
87
88 if entries.is_empty() {
89 return output.trim_end().to_string();
90 }
91
92 let mut folded = fold_consecutive_runs(entries);
93 let mut out = prefix;
94 out.append(&mut folded);
95 finish_folded(out)
96}
97
98fn parse_ls_line(line: &str, long_format: bool) -> Option<(String, String)> {
99 if long_format {
100 let trimmed = line.trim_start();
101 if trimmed.starts_with('-')
102 || trimmed.starts_with('d')
103 || trimmed.starts_with('l')
104 || trimmed.starts_with('b')
105 || trimmed.starts_with('c')
106 || trimmed.starts_with('s')
107 || trimmed.starts_with('p')
108 {
109 let name = line.split_whitespace().last()?.to_string();
110 return Some((String::new(), name));
111 }
112 return None;
113 }
114 let name = line.trim().to_string();
115 if name.is_empty() {
116 return None;
117 }
118 Some((String::new(), name))
119}
120
121pub fn build_lebench_ls_la_fixture() -> String {
122 let mut lines = Vec::with_capacity(224);
123 lines.push("total 1234".to_string());
124 for i in 1..=200u32 {
125 lines.push(format!(
126 "-rw-r--r-- 1 user staff 4096 Jan 01 00:00 module_{:03}.ts",
127 i
128 ));
129 }
130 lines.insert(
132 101,
133 "-rw-r--r-- 1 user staff 4096 Jan 01 00:00 module_100_NEEDLE_FILE_marker.ts".to_string(),
134 );
135 for i in 1..=22u32 {
136 lines.push(format!(
137 "-rw-r--r-- 1 user staff 1024 Jan 01 00:00 filler_{:02}.log",
138 i
139 ));
140 }
141 lines.join("\n")
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 const NEEDLE: &str = "module_100_NEEDLE_FILE_marker.ts";
149
150 #[test]
151 fn matches_ls_invocations() {
152 let c = LsCompressor;
153 assert!(c.matches("ls -la src/generated/client"));
154 assert!(c.matches("cd /tmp && ls"));
155 assert!(!c.matches("gsl"));
156 }
157
158 #[test]
159 fn lebench_224_line_ls_la_folds_and_preserves_needle() {
160 let input = build_lebench_ls_la_fixture();
161 let line_count = input.lines().count();
162 assert_eq!(line_count, 224, "fixture line count");
163
164 let out = compress_ls_listing("ls -la", &input);
165 assert!(
166 out.contains(NEEDLE),
167 "needle must survive compression; got:\n{out}"
168 );
169 assert!(
170 out.contains("module_*.ts"),
171 "homogeneous run should fold to pattern summary"
172 );
173 assert!(
174 out.lines().count() < 50,
175 "should compress dramatically; got {} lines",
176 out.lines().count()
177 );
178 eprintln!(
179 "ls fixture: {} -> {} lines",
180 line_count,
181 out.lines().count()
182 );
183 }
184
185 #[test]
186 fn small_listing_passes_through_unchanged() {
187 let input = (1..5)
188 .map(|i| format!("-rw-r--r-- 1 u g 0 Jan 1 file_{i}.txt"))
189 .collect::<Vec<_>>()
190 .join("\n");
191 let out = compress_ls_listing("ls -l", &input);
192 assert_eq!(out.lines().count(), 4);
193 for i in 1..5 {
194 assert!(out.contains(&format!("file_{i}.txt")));
195 }
196 }
197
198 #[test]
199 fn empty_ls_passes_through() {
200 let c = LsCompressor;
201 let r = c.compress_with_exit_code("ls", "", None);
202 assert_eq!(r.text, "");
203 }
204}