Skip to main content

aft/compress/
ls.rs

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    // Needle sorts between module_100.ts and module_101.ts (index 101 after total).
131    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}