1pub fn format_command(cmd: &str, output: &str) -> Option<String> {
11 let parts: Vec<&str> = cmd.split_whitespace().collect();
12 let base = parts.first().map(|s| s.rsplit('/').next().unwrap_or(s)).unwrap_or("");
13
14 match base {
15 "git" => format_git(parts.get(1).copied(), output),
16 "cargo" => format_cargo(parts.get(1).copied(), output),
17 "npm" | "npx" => format_npm(parts.get(1).copied(), output),
18 "pytest" | "python" if cmd.contains("pytest") => Some(format_test_failures(output)),
19 "go" if parts.get(1).copied() == Some("test") => Some(format_test_failures(output)),
20 "docker" => format_docker(parts.get(1).copied(), output),
21 "kubectl" => format_kubectl(parts.get(1).copied(), output),
22 "ls" => Some(format_ls(output)),
23 "find" | "fd" => Some(format_find(output)),
24 "tsc" => Some(format_tsc(output)),
25 "eslint" | "biome" => Some(format_lint(output)),
26 _ => None,
27 }
28}
29
30fn format_git(subcmd: Option<&str>, output: &str) -> Option<String> {
33 match subcmd? {
34 "status" => Some(format_git_status(output)),
35 "log" => Some(format_git_log(output)),
36 "diff" => Some(format_git_diff(output)),
37 "add" | "commit" | "push" | "pull" | "checkout" | "switch" | "branch" => {
38 Some(format_git_short(subcmd.unwrap(), output))
39 }
40 _ => None,
41 }
42}
43
44fn format_git_status(output: &str) -> String {
45 let mut staged = Vec::new();
46 let mut modified = Vec::new();
47 let mut untracked = Vec::new();
48
49 for line in output.lines() {
50 let trimmed = line.trim();
51 if trimmed.starts_with("new file:") || trimmed.starts_with("modified:") && line.starts_with('\t') {
52 staged.push(trimmed.to_string());
54 } else if trimmed.starts_with("modified:") || trimmed.starts_with("deleted:") {
55 modified.push(trimmed.to_string());
56 } else if line.starts_with("\t") && !trimmed.starts_with("(use") {
57 if output[..output.find(line).unwrap_or(0)].contains("Untracked files:") {
59 untracked.push(trimmed.to_string());
60 }
61 }
62 }
63
64 if staged.is_empty() && modified.is_empty() && untracked.is_empty() {
66 let mut short_staged = Vec::new();
67 let mut short_modified = Vec::new();
68 let mut short_untracked = Vec::new();
69 for line in output.lines() {
70 if line.len() < 3 { continue; }
71 let (idx, rest) = (line.get(..2), line.get(3..));
72 if let (Some(idx), Some(rest)) = (idx, rest) {
73 match idx.trim() {
74 "M" | "A" | "D" | "R" => short_staged.push(format!("{} {}", idx.trim(), rest)),
75 "??" => short_untracked.push(rest.to_string()),
76 _ if idx.contains('M') => short_modified.push(format!("M {}", rest)),
77 _ => {}
78 }
79 }
80 }
81 if !short_staged.is_empty() || !short_modified.is_empty() || !short_untracked.is_empty() {
82 staged = short_staged;
83 modified = short_modified;
84 untracked = short_untracked;
85 }
86 }
87
88 if staged.is_empty() && modified.is_empty() && untracked.is_empty() {
89 if output.contains("nothing to commit") {
90 return "clean".to_string();
91 }
92 return output.to_string();
93 }
94
95 let mut result = Vec::new();
96 if !staged.is_empty() {
97 result.push(format!("staged({}): {}", staged.len(), staged.join(", ")));
98 }
99 if !modified.is_empty() {
100 result.push(format!("modified({}): {}", modified.len(), modified.join(", ")));
101 }
102 if !untracked.is_empty() {
103 if untracked.len() > 5 {
104 result.push(format!("untracked({}): {}, ...+{}", untracked.len(),
105 untracked[..3].join(", "), untracked.len() - 3));
106 } else {
107 result.push(format!("untracked({}): {}", untracked.len(), untracked.join(", ")));
108 }
109 }
110 result.join("\n")
111}
112
113fn format_git_log(output: &str) -> String {
114 let mut commits = Vec::new();
116 let mut current_hash = String::new();
117 let mut current_subject = String::new();
118
119 for line in output.lines() {
120 if line.starts_with("commit ") {
121 if !current_hash.is_empty() {
122 commits.push(format!("{} {}", ¤t_hash[..current_hash.len().min(7)], current_subject.trim()));
123 }
124 current_hash = line.strip_prefix("commit ").unwrap_or("").trim().to_string();
125 current_subject.clear();
126 } else if line.starts_with("Author:") || line.starts_with("Date:") || line.starts_with("Merge:") {
127 } else {
129 let trimmed = line.trim();
130 if !trimmed.is_empty() && current_subject.is_empty() {
131 current_subject = trimmed.to_string();
132 }
133 }
134 }
135 if !current_hash.is_empty() {
136 commits.push(format!("{} {}", ¤t_hash[..current_hash.len().min(7)], current_subject.trim()));
137 }
138
139 if commits.is_empty() {
140 return output.to_string();
142 }
143 commits.join("\n")
144}
145
146fn format_git_diff(output: &str) -> String {
147 let mut result = Vec::new();
149 let mut context_count = 0;
150
151 for line in output.lines() {
152 if line.starts_with("diff --git") || line.starts_with("---") || line.starts_with("+++") {
153 result.push(line.to_string());
154 context_count = 0;
155 } else if line.starts_with("@@") {
156 result.push(line.to_string());
157 context_count = 0;
158 } else if line.starts_with('+') || line.starts_with('-') {
159 result.push(line.to_string());
160 context_count = 0;
161 } else {
162 context_count += 1;
164 if context_count <= 1 {
165 result.push(line.to_string());
166 }
167 }
168 }
169 result.join("\n")
170}
171
172fn format_git_short(subcmd: &str, output: &str) -> String {
173 match subcmd {
174 "add" => {
175 if output.trim().is_empty() { return "ok".to_string(); }
176 output.to_string()
177 }
178 "commit" => {
179 for line in output.lines() {
181 if line.contains(']') && line.contains('[') {
182 return format!("ok {}", line.trim());
183 }
184 }
185 if output.trim().is_empty() { return "ok".to_string(); }
186 output.lines().find(|l| !l.trim().is_empty()).unwrap_or("ok").to_string()
188 }
189 "push" => {
190 for line in output.lines() {
191 if line.contains("->") {
192 return format!("ok {}", line.trim());
193 }
194 }
195 "ok".to_string()
196 }
197 "pull" => {
198 let mut files_changed = 0;
199 let mut insertions = 0;
200 let mut deletions = 0;
201 for line in output.lines() {
202 if line.contains("files changed") || line.contains("file changed") {
203 let parts: Vec<&str> = line.split_whitespace().collect();
204 for (i, p) in parts.iter().enumerate() {
205 if *p == "file" || p.starts_with("file") { files_changed = parts.get(i-1).and_then(|n| n.parse().ok()).unwrap_or(0); }
206 if p.starts_with("insertion") { insertions = parts.get(i-1).and_then(|n| n.parse().ok()).unwrap_or(0); }
207 if p.starts_with("deletion") { deletions = parts.get(i-1).and_then(|n| n.parse().ok()).unwrap_or(0); }
208 }
209 }
210 }
211 if files_changed > 0 {
212 format!("ok {} files +{} -{}", files_changed, insertions, deletions)
213 } else if output.contains("Already up to date") {
214 "ok up-to-date".to_string()
215 } else {
216 "ok".to_string()
217 }
218 }
219 _ => output.lines().take(3).collect::<Vec<_>>().join("\n"),
220 }
221}
222
223fn format_cargo(subcmd: Option<&str>, output: &str) -> Option<String> {
226 match subcmd? {
227 "test" => Some(format_test_failures(output)),
228 "build" | "check" => Some(format_cargo_build(output)),
229 "clippy" => Some(format_lint(output)),
230 _ => None,
231 }
232}
233
234fn format_cargo_build(output: &str) -> String {
235 let errors: Vec<&str> = output.lines()
236 .filter(|l| l.starts_with("error") || l.contains("error[E") || l.starts_with("warning"))
237 .collect();
238
239 if errors.is_empty() {
240 for line in output.lines().rev() {
242 if line.contains("Finished") || line.contains("Compiling") {
243 return line.trim().to_string();
244 }
245 }
246 return "ok".to_string();
247 }
248
249 let mut grouped: Vec<String> = Vec::new();
251 let current_file = String::new();
252 let file_errors: Vec<String> = Vec::new();
253
254 for line in output.lines() {
255 if line.starts_with("error") || line.contains("error[E") {
256 grouped.push(line.to_string());
258 } else if line.trim().starts_with("-->") {
259 grouped.push(format!(" {}", line.trim()));
260 }
261 }
262
263 if grouped.is_empty() {
264 return output.to_string();
265 }
266
267 let _ = (current_file, file_errors); format!("ERRORS: {}\n{}", errors.len(), grouped.join("\n"))
269}
270
271fn format_test_failures(output: &str) -> String {
274 let mut failures = Vec::new();
275 let mut summary_line = String::new();
276 let mut in_failure = false;
277 let mut failure_buf = Vec::new();
278
279 for line in output.lines() {
280 if line.starts_with("test result:") || line.starts_with("Tests:") {
282 summary_line = line.to_string();
283 }
284 if line.starts_with("---- ") && line.ends_with(" ----") {
286 if !failure_buf.is_empty() {
287 failures.push(failure_buf.join("\n"));
288 failure_buf.clear();
289 }
290 in_failure = true;
291 failure_buf.push(line.to_string());
292 continue;
293 }
294 if line == "failures:" {
296 in_failure = true;
297 continue;
298 }
299 if line.contains("FAILED") || line.contains("FAIL:") {
301 failures.push(line.to_string());
302 }
303 if line.starts_with("--- FAIL:") {
305 failures.push(line.to_string());
306 }
307 if in_failure {
309 if line.trim().is_empty() && !failure_buf.is_empty() {
310 failures.push(failure_buf.join("\n"));
311 failure_buf.clear();
312 in_failure = false;
313 } else {
314 failure_buf.push(line.to_string());
315 }
316 }
317 if line.contains("... FAILED") || line.contains("FAILED") && line.starts_with("test ") {
319 if !failures.iter().any(|f| f.contains(line)) {
320 failures.push(line.to_string());
321 }
322 }
323 }
324 if !failure_buf.is_empty() {
325 failures.push(failure_buf.join("\n"));
326 }
327
328 if failures.is_empty() {
330 if !summary_line.is_empty() {
331 return summary_line;
332 }
333 let total = output.lines().filter(|l| l.contains("... ok") || l.contains("PASSED") || l.contains("passed")).count();
335 if total > 0 {
336 return format!("ok: {} tests passed", total);
337 }
338 return output.to_string();
339 }
340
341 let mut result = Vec::new();
342 if !summary_line.is_empty() {
343 result.push(summary_line);
344 }
345 result.push(format!("FAILURES ({}):", failures.len()));
346 for f in &failures {
347 result.push(f.clone());
348 }
349 result.join("\n")
350}
351
352fn format_npm(subcmd: Option<&str>, output: &str) -> Option<String> {
355 match subcmd? {
356 "test" => Some(format_test_failures(output)),
357 "run" if output.contains("error") || output.contains("FAIL") => Some(format_test_failures(output)),
358 "install" | "i" | "add" => Some(format_npm_install(output)),
359 _ => None,
360 }
361}
362
363fn format_npm_install(output: &str) -> String {
364 let mut vulns = String::new();
365 for line in output.lines() {
366 if line.contains("added") && line.contains("packages") {
367 return line.trim().to_string();
368 }
369 if line.contains("vulnerabilities") {
370 vulns = line.trim().to_string();
371 }
372 }
373 if !vulns.is_empty() {
374 return format!("ok ({})", vulns);
375 }
376 "ok".to_string()
377}
378
379fn format_docker(subcmd: Option<&str>, output: &str) -> Option<String> {
382 match subcmd? {
383 "ps" => Some(format_docker_ps(output)),
384 "images" => Some(format_docker_images(output)),
385 "logs" => None, _ => None,
387 }
388}
389
390fn format_docker_ps(output: &str) -> String {
391 let lines: Vec<&str> = output.lines().collect();
392 if lines.is_empty() { return output.to_string(); }
393
394 let mut result = Vec::new();
395 for (i, line) in lines.iter().enumerate() {
396 if i == 0 {
397 result.push("NAME | IMAGE | STATUS".to_string());
399 continue;
400 }
401 let parts: Vec<&str> = line.split_whitespace().collect();
402 if parts.len() >= 5 {
403 let name = parts.last().unwrap_or(&"");
405 let image = parts.get(1).unwrap_or(&"");
406 let status_parts: Vec<&&str> = parts.iter().skip(4).take_while(|p| !p.starts_with("0.0.0.0")).collect();
407 let status = status_parts.iter().map(|s| **s).collect::<Vec<_>>().join(" ");
408 result.push(format!("{} | {} | {}", name, image, status));
409 } else {
410 result.push(line.to_string());
411 }
412 }
413 result.join("\n")
414}
415
416fn format_docker_images(output: &str) -> String {
417 let lines: Vec<&str> = output.lines().collect();
418 if lines.is_empty() { return output.to_string(); }
419
420 let mut result = Vec::new();
421 for (i, line) in lines.iter().enumerate() {
422 if i == 0 {
423 result.push("REPO:TAG | SIZE".to_string());
424 continue;
425 }
426 let parts: Vec<&str> = line.split_whitespace().collect();
427 if parts.len() >= 5 {
428 let repo = parts[0];
429 let tag = parts[1];
430 let size = parts.last().unwrap_or(&"");
431 result.push(format!("{}:{} | {}", repo, tag, size));
432 }
433 }
434 result.join("\n")
435}
436
437fn format_kubectl(subcmd: Option<&str>, output: &str) -> Option<String> {
440 match subcmd? {
441 "get" => Some(format_kubectl_get(output)),
442 _ => None,
443 }
444}
445
446fn format_kubectl_get(output: &str) -> String {
447 let lines: Vec<&str> = output.lines().collect();
449 if lines.is_empty() { return output.to_string(); }
450
451 let mut result = Vec::new();
452 for line in &lines {
453 let collapsed: String = line.split_whitespace().collect::<Vec<_>>().join(" ");
455 result.push(collapsed);
456 }
457 result.join("\n")
458}
459
460fn format_ls(output: &str) -> String {
463 let lines: Vec<&str> = output.lines().collect();
464 if lines.len() <= 20 { return output.to_string(); }
465
466 let mut dirs = Vec::new();
468 let mut files = Vec::new();
469
470 for line in &lines {
471 let trimmed = line.trim();
472 if trimmed.is_empty() || trimmed.starts_with("total") { continue; }
473 if trimmed.starts_with('d') || trimmed.ends_with('/') {
474 dirs.push(trimmed.split_whitespace().last().unwrap_or(trimmed).to_string());
475 } else {
476 files.push(trimmed.split_whitespace().last().unwrap_or(trimmed).to_string());
477 }
478 }
479
480 let mut result = Vec::new();
481 if !dirs.is_empty() {
482 result.push(format!("dirs({}): {}", dirs.len(), dirs.join(", ")));
483 }
484 if files.len() > 10 {
485 result.push(format!("files({}): {}, ...+{}", files.len(),
486 files[..5].join(", "), files.len() - 5));
487 } else if !files.is_empty() {
488 result.push(format!("files({}): {}", files.len(), files.join(", ")));
489 }
490 result.join("\n")
491}
492
493fn format_find(output: &str) -> String {
496 let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
497 if lines.len() <= 20 { return output.to_string(); }
498
499 let mut by_dir: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
501 for line in &lines {
502 let path = std::path::Path::new(line.trim());
503 let parent = path.parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_default();
504 let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
505 by_dir.entry(parent).or_default().push(name);
506 }
507
508 let mut result = Vec::new();
509 result.push(format!("{} files found:", lines.len()));
510 for (dir, files) in &by_dir {
511 if files.len() > 5 {
512 result.push(format!(" {}/ ({} files)", dir, files.len()));
513 } else {
514 result.push(format!(" {}/ {}", dir, files.join(", ")));
515 }
516 }
517 result.join("\n")
518}
519
520fn format_tsc(output: &str) -> String {
523 let mut by_file: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
525 let mut error_count = 0;
526
527 for line in output.lines() {
528 if line.contains("): error TS") || line.contains("): warning TS") {
530 error_count += 1;
531 if let Some(paren_pos) = line.find('(') {
532 let file = &line[..paren_pos];
533 by_file.entry(file.to_string()).or_default().push(line.to_string());
534 } else {
535 by_file.entry("unknown".to_string()).or_default().push(line.to_string());
536 }
537 }
538 }
539
540 if error_count == 0 {
541 if output.contains("Found 0 errors") || output.trim().is_empty() {
542 return "ok: 0 errors".to_string();
543 }
544 return output.to_string();
545 }
546
547 let mut result = Vec::new();
548 result.push(format!("ERRORS: {} in {} files", error_count, by_file.len()));
549 for (file, errors) in &by_file {
550 result.push(format!(" {} ({}):", file, errors.len()));
551 for e in errors.iter().take(5) {
552 result.push(format!(" {}", e));
553 }
554 if errors.len() > 5 {
555 result.push(format!(" ...+{} more", errors.len() - 5));
556 }
557 }
558 result.join("\n")
559}
560
561fn format_lint(output: &str) -> String {
564 let mut by_rule: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
565 let mut total = 0;
566
567 for line in output.lines() {
568 let trimmed = line.trim();
571 if trimmed.contains("error") || trimmed.contains("warning") {
572 total += 1;
573 let parts: Vec<&str> = trimmed.split_whitespace().collect();
575 if let Some(rule) = parts.last() {
576 *by_rule.entry(rule.to_string()).or_insert(0) += 1;
577 }
578 }
579 }
580
581 if total == 0 {
582 return "ok: 0 issues".to_string();
583 }
584
585 let mut result = Vec::new();
586 result.push(format!("{} issues:", total));
587 let mut sorted: Vec<_> = by_rule.iter().collect();
588 sorted.sort_by(|a, b| b.1.cmp(a.1));
589 for (rule, count) in sorted.iter().take(10) {
590 result.push(format!(" {} ({}x)", rule, count));
591 }
592 if sorted.len() > 10 {
593 result.push(format!(" ...+{} more rules", sorted.len() - 10));
594 }
595 result.join("\n")
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601
602 #[test]
603 fn test_git_status_clean() {
604 let output = "On branch main\nnothing to commit, working tree clean\n";
605 assert_eq!(format_git_status(output), "clean");
606 }
607
608 #[test]
609 fn test_git_log_compact() {
610 let output = "commit abc1234567890\nAuthor: Test <test@test.com>\nDate: Mon Apr 13\n\n feat: Add feature\n\ncommit def5678901234\nAuthor: Test <test@test.com>\nDate: Sun Apr 12\n\n fix: Bug fix\n";
611 let result = format_git_log(output);
612 assert!(result.contains("abc1234"));
613 assert!(result.contains("feat: Add feature"));
614 assert!(!result.contains("Author:"));
615 }
616
617 #[test]
618 fn test_git_push_compact() {
619 let output = "Enumerating objects: 5, done.\nCounting objects: 100% (5/5), done.\nDelta compression using up to 8 threads\n abc1234..def5678 main -> main\n";
620 let result = format_git_short("push", output);
621 assert!(result.starts_with("ok"));
622 }
623
624 #[test]
625 fn test_cargo_test_all_pass() {
626 let output = "running 15 tests\ntest a ... ok\ntest b ... ok\ntest result: ok. 15 passed; 0 failed; 0 ignored\n";
627 let result = format_test_failures(output);
628 assert!(result.contains("ok") || result.contains("passed"));
629 assert!(!result.contains("FAILURES"));
630 }
631
632 #[test]
633 fn test_cargo_test_with_failure() {
634 let output = "running 3 tests\ntest a ... ok\ntest b ... FAILED\ntest c ... ok\n\nfailures:\n\n---- b stdout ----\nassertion failed\n\ntest result: FAILED. 2 passed; 1 failed\n";
635 let result = format_test_failures(output);
636 assert!(result.contains("FAIL"));
637 }
638
639 #[test]
640 fn test_docker_ps_compact() {
641 let output = "CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\nabc123def456 nginx \"nginx\" 2h ago Up 2h 80/tcp web\n";
642 let result = format_docker_ps(output);
643 assert!(result.contains("NAME | IMAGE | STATUS"));
644 assert!(result.contains("web"));
645 }
646
647 #[test]
648 fn test_tsc_no_errors() {
649 let result = format_tsc("Found 0 errors.\n");
650 assert_eq!(result, "ok: 0 errors");
651 }
652
653 #[test]
654 fn test_format_command_routing() {
655 assert!(format_command("git status", "nothing to commit").is_some());
656 assert!(format_command("cargo test", "test result: ok").is_some());
657 assert!(format_command("unknown_tool", "output").is_none());
658 }
659
660 #[test]
661 fn test_ls_short_passthrough() {
662 let output = "file1.rs\nfile2.rs\n";
663 assert_eq!(format_ls(output), output);
664 }
665
666 #[test]
667 fn test_npm_install_compact() {
668 let output = "added 42 packages in 3s\n2 vulnerabilities\n";
669 let result = format_npm_install(output);
670 assert!(result.contains("added 42 packages"));
671 }
672}