1pub 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}