Skip to main content

lean_ctx_patterns/
ls.rs

1// SPDX-License-Identifier: Apache-2.0
2
3pub fn compress(output: &str) -> Option<String> {
4    let lines: Vec<&str> = output.lines().collect();
5    if lines.len() < 5 {
6        return None;
7    }
8
9    let is_long = lines.iter().any(|l| {
10        l.starts_with('-') || l.starts_with('d') || l.starts_with('l') || l.starts_with("total ")
11    });
12
13    if is_long {
14        compress_long(output)
15    } else {
16        compress_short(output)
17    }
18}
19
20fn compress_long(output: &str) -> Option<String> {
21    let mut dirs = Vec::new();
22    let mut files = Vec::new();
23
24    for line in output.lines() {
25        if line.starts_with("total ") || line.trim().is_empty() {
26            continue;
27        }
28
29        let parts: Vec<&str> = line.split_whitespace().collect();
30        if parts.len() < 9 {
31            continue;
32        }
33
34        let name = parts[8..].join(" ");
35
36        if name == "." || name == ".." {
37            continue;
38        }
39
40        if line.starts_with('d') {
41            dirs.push(format!("{name}/"));
42        } else {
43            let size = format_size(parts[4]);
44            files.push(format!("{name}  {size}"));
45        }
46    }
47
48    if dirs.is_empty() && files.is_empty() {
49        return None;
50    }
51
52    let mut result = String::new();
53    for d in &dirs {
54        result.push_str(d);
55        result.push('\n');
56    }
57    for f in &files {
58        result.push_str(f);
59        result.push('\n');
60    }
61
62    result.push_str(&format!("\n{} files, {} dirs", files.len(), dirs.len()));
63
64    Some(result)
65}
66
67fn compress_short(output: &str) -> Option<String> {
68    let items: Vec<&str> = output
69        .split_whitespace()
70        .filter(|s| !s.is_empty())
71        .collect();
72
73    if items.len() < 10 {
74        return None;
75    }
76
77    let mut dirs = Vec::new();
78    let mut files = Vec::new();
79
80    for item in &items {
81        if item.ends_with('/') {
82            dirs.push(*item);
83        } else {
84            files.push(*item);
85        }
86    }
87
88    let mut result = String::new();
89    for d in &dirs {
90        result.push_str(d);
91        result.push('\n');
92    }
93
94    let mut line_buf = String::new();
95    for f in &files {
96        if line_buf.len() + f.len() + 2 > 70 {
97            result.push_str(&line_buf);
98            result.push('\n');
99            line_buf.clear();
100        }
101        if !line_buf.is_empty() {
102            line_buf.push_str("  ");
103        }
104        line_buf.push_str(f);
105    }
106    if !line_buf.is_empty() {
107        result.push_str(&line_buf);
108        result.push('\n');
109    }
110
111    result.push_str(&format!("\n{} items", dirs.len() + files.len()));
112
113    Some(result)
114}
115
116fn format_size(size_str: &str) -> String {
117    let last = size_str.as_bytes().last().copied().unwrap_or(b'0');
118    if matches!(last, b'K' | b'M' | b'G' | b'T') {
119        return size_str.to_string();
120    }
121    let bytes: u64 = size_str.parse().unwrap_or(0);
122    if bytes >= 1_048_576 {
123        format!("{:.1}M", bytes as f64 / 1_048_576.0)
124    } else if bytes >= 1024 {
125        format!("{:.1}K", bytes as f64 / 1024.0)
126    } else {
127        format!("{bytes}B")
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn format_size_raw_small() {
137        assert_eq!(format_size("512"), "512B");
138    }
139
140    #[test]
141    fn format_size_raw_kb() {
142        assert_eq!(format_size("4096"), "4.0K");
143    }
144
145    #[test]
146    fn format_size_raw_mb() {
147        assert_eq!(format_size("1048576"), "1.0M");
148    }
149
150    #[test]
151    fn format_size_human_k_passthrough() {
152        assert_eq!(format_size("4.0K"), "4.0K");
153    }
154
155    #[test]
156    fn format_size_human_m_passthrough() {
157        assert_eq!(format_size("1.2M"), "1.2M");
158    }
159
160    #[test]
161    fn format_size_human_g_passthrough() {
162        assert_eq!(format_size("2.5G"), "2.5G");
163    }
164
165    #[test]
166    fn format_size_zero_and_empty() {
167        assert_eq!(format_size("0"), "0B");
168        assert_eq!(format_size(""), "0B");
169    }
170
171    #[test]
172    fn format_size_integer_k_passthrough() {
173        assert_eq!(format_size("15K"), "15K");
174    }
175
176    #[test]
177    fn compress_long_ls_l_raw_bytes() {
178        let output = "total 32\n\
179            drwxr-xr-x  5 user staff   160 May 20 10:00 src\n\
180            drwxr-xr-x  3 user staff    96 May 20 10:00 tests\n\
181            -rw-r--r--  1 user staff  4096 May 20 10:00 Cargo.toml\n\
182            -rw-r--r--  1 user staff 12288 May 20 10:00 Cargo.lock\n\
183            -rw-r--r--  1 user staff   512 May 20 10:00 README.md\n\
184            -rw-r--r--  1 user staff   100 May 20 10:00 .gitignore\n\
185            -rw-r--r--  1 user staff    42 May 20 10:00 .env\n";
186        let result = compress(output).expect("should compress");
187        assert!(result.contains("4.0K"), "4096 should become 4.0K: {result}");
188        assert!(
189            result.contains("12.0K"),
190            "12288 should become 12.0K: {result}"
191        );
192        assert!(result.contains("512B"), "512 should become 512B: {result}");
193        assert!(
194            result.contains("src/"),
195            "dirs should have trailing /: {result}"
196        );
197    }
198
199    #[test]
200    fn compress_long_ls_lah_human_readable() {
201        let output = "total 32K\n\
202            drwxr-xr-x  5 user staff  160 May 20 10:00 src\n\
203            drwxr-xr-x  3 user staff   96 May 20 10:00 tests\n\
204            -rw-r--r--  1 user staff 4.0K May 20 10:00 Cargo.toml\n\
205            -rw-r--r--  1 user staff  12K May 20 10:00 Cargo.lock\n\
206            -rw-r--r--  1 user staff 1.2M May 20 10:00 big-file.bin\n\
207            -rw-r--r--  1 user staff  512 May 20 10:00 README.md\n\
208            -rw-r--r--  1 user staff  100 May 20 10:00 .gitignore\n";
209        let result = compress(output).expect("should compress");
210        assert!(
211            result.contains("4.0K"),
212            "human 4.0K should pass through: {result}"
213        );
214        assert!(
215            result.contains("12K"),
216            "human 12K should pass through: {result}"
217        );
218        assert!(
219            result.contains("1.2M"),
220            "human 1.2M should pass through: {result}"
221        );
222        assert!(
223            !result.contains("  0B"),
224            "should NOT show 0B for human-readable sizes: {result}"
225        );
226    }
227
228    #[test]
229    fn compress_long_ls_lh_same_as_lah() {
230        let output = "total 16K\n\
231            drwxr-xr-x  2 user staff   64 May 20 10:00 docs\n\
232            -rw-r--r--  1 user staff 2.5G May 20 10:00 database.db\n\
233            -rw-r--r--  1 user staff 330K May 20 10:00 image.png\n\
234            -rw-r--r--  1 user staff  15T May 20 10:00 huge.tar\n\
235            -rw-r--r--  1 user staff   42 May 20 10:00 tiny.txt\n\
236            -rw-r--r--  1 user staff    0 May 20 10:00 empty.log\n";
237        let result = compress(output).expect("should compress");
238        assert!(result.contains("2.5G"), "G suffix: {result}");
239        assert!(result.contains("15T"), "T suffix: {result}");
240    }
241
242    #[test]
243    fn compress_long_mixed_dirs_and_files() {
244        let output = "total 8\n\
245            drwxr-xr-x  2 user staff  64 May 20 10:00 .git\n\
246            drwxr-xr-x  2 user staff  64 May 20 10:00 node_modules\n\
247            drwxr-xr-x  2 user staff  64 May 20 10:00 src\n\
248            -rw-r--r--  1 user staff 256 May 20 10:00 package.json\n\
249            -rw-r--r--  1 user staff 100 May 20 10:00 .env\n";
250        let result = compress(output).expect("should compress");
251        assert!(result.contains(".git/"));
252        assert!(result.contains(".env"));
253        assert!(result.contains("3 dirs"));
254        assert!(result.contains("2 files"));
255    }
256
257    #[test]
258    fn compress_long_dotfiles_preserved() {
259        let output = "total 4\n\
260            -rw-r--r--  1 user staff 100 May 20 10:00 .env\n\
261            -rw-r--r--  1 user staff 200 May 20 10:00 .gitignore\n\
262            -rw-r--r--  1 user staff 300 May 20 10:00 .dockerignore\n\
263            -rw-r--r--  1 user staff 400 May 20 10:00 .eslintrc\n\
264            -rw-r--r--  1 user staff 500 May 20 10:00 .prettierrc\n";
265        let result = compress(output).expect("should compress");
266        assert!(result.contains(".env"), "dotfiles must appear: {result}");
267        assert!(result.contains(".gitignore"));
268    }
269}