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