1use crate::core::patterns;
2use crate::core::tokens::count_tokens;
3
4const BUILTIN_PASSTHROUGH: &[&str] = &[
5 "turbo",
7 "nx serve",
8 "nx dev",
9 "next dev",
10 "vite dev",
11 "vite preview",
12 "vitest",
13 "nuxt dev",
14 "astro dev",
15 "webpack serve",
16 "webpack-dev-server",
17 "nodemon",
18 "concurrently",
19 "pm2",
20 "pm2 logs",
21 "gatsby develop",
22 "expo start",
23 "react-scripts start",
24 "ng serve",
25 "remix dev",
26 "wrangler dev",
27 "hugo server",
28 "hugo serve",
29 "jekyll serve",
30 "bun dev",
31 "ember serve",
32 "npm run dev",
34 "npm run start",
35 "npm run serve",
36 "npm run watch",
37 "npm run preview",
38 "npm run storybook",
39 "npm run test:watch",
40 "npm start",
41 "npx ",
42 "pnpm run dev",
43 "pnpm run start",
44 "pnpm run serve",
45 "pnpm run watch",
46 "pnpm run preview",
47 "pnpm run storybook",
48 "pnpm dev",
49 "pnpm start",
50 "pnpm preview",
51 "yarn dev",
52 "yarn start",
53 "yarn serve",
54 "yarn watch",
55 "yarn preview",
56 "yarn storybook",
57 "bun run dev",
58 "bun run start",
59 "bun run serve",
60 "bun run watch",
61 "bun run preview",
62 "bun start",
63 "deno task dev",
64 "deno task start",
65 "deno task serve",
66 "deno run --watch",
67 "docker compose up",
69 "docker-compose up",
70 "docker compose logs",
71 "docker-compose logs",
72 "docker compose exec",
73 "docker-compose exec",
74 "docker compose run",
75 "docker-compose run",
76 "docker compose watch",
77 "docker-compose watch",
78 "docker logs",
79 "docker attach",
80 "docker exec -it",
81 "docker exec -ti",
82 "docker run -it",
83 "docker run -ti",
84 "docker stats",
85 "docker events",
86 "kubectl logs",
88 "kubectl exec -it",
89 "kubectl exec -ti",
90 "kubectl attach",
91 "kubectl port-forward",
92 "kubectl proxy",
93 "top",
95 "htop",
96 "btop",
97 "watch ",
98 "tail -f",
99 "tail -f ",
100 "journalctl -f",
101 "journalctl --follow",
102 "dmesg -w",
103 "dmesg --follow",
104 "strace",
105 "tcpdump",
106 "ping ",
107 "ping6 ",
108 "traceroute",
109 "mtr ",
110 "nmap ",
111 "iperf ",
112 "iperf3 ",
113 "ss -l",
114 "netstat -l",
115 "lsof -i",
116 "socat ",
117 "less",
119 "more",
120 "vim",
121 "nvim",
122 "vi ",
123 "nano",
124 "micro ",
125 "helix ",
126 "hx ",
127 "emacs",
128 "tmux",
130 "screen",
131 "ssh ",
133 "telnet ",
134 "nc ",
135 "ncat ",
136 "psql",
137 "mysql",
138 "sqlite3",
139 "redis-cli",
140 "mongosh",
141 "mongo ",
142 "python3 -i",
143 "python -i",
144 "irb",
145 "rails console",
146 "rails c ",
147 "iex",
148 "flask run",
150 "uvicorn ",
151 "gunicorn ",
152 "hypercorn ",
153 "daphne ",
154 "django-admin runserver",
155 "manage.py runserver",
156 "python manage.py runserver",
157 "python -m http.server",
158 "python3 -m http.server",
159 "streamlit run",
160 "gradio ",
161 "celery worker",
162 "celery -a",
163 "celery -b",
164 "dramatiq ",
165 "rq worker",
166 "watchmedo ",
167 "ptw ",
168 "pytest-watch",
169 "rails server",
171 "rails s",
172 "puma ",
173 "unicorn ",
174 "thin start",
175 "foreman start",
176 "overmind start",
177 "guard ",
178 "sidekiq",
179 "resque ",
180 "php artisan serve",
182 "php -s ",
183 "php artisan queue:work",
184 "php artisan queue:listen",
185 "php artisan horizon",
186 "php artisan tinker",
187 "sail up",
188 "./gradlew bootrun",
190 "gradlew bootrun",
191 "gradle bootrun",
192 "./gradlew run",
193 "mvn spring-boot:run",
194 "./mvnw spring-boot:run",
195 "mvnw spring-boot:run",
196 "mvn quarkus:dev",
197 "./mvnw quarkus:dev",
198 "sbt run",
199 "sbt ~compile",
200 "lein run",
201 "lein repl",
202 "go run ",
204 "air ",
205 "gin ",
206 "realize start",
207 "reflex ",
208 "gowatch ",
209 "dotnet run",
211 "dotnet watch",
212 "dotnet ef",
213 "mix phx.server",
215 "iex -s mix",
216 "swift run",
218 "swift package ",
219 "vapor serve",
220 "zig build run",
222 "cargo watch",
224 "cargo run",
225 "cargo leptos watch",
226 "bacon ",
227 "make dev",
229 "make serve",
230 "make watch",
231 "make run",
232 "make start",
233 "just dev",
234 "just serve",
235 "just watch",
236 "just start",
237 "just run",
238 "task dev",
239 "task serve",
240 "task watch",
241 "nix develop",
242 "devenv up",
243 "act ",
245 "skaffold dev",
246 "tilt up",
247 "garden dev",
248 "telepresence ",
249 "ab ",
251 "wrk ",
252 "hey ",
253 "vegeta ",
254 "k6 run",
255 "artillery run",
256 "az login",
258 "az account",
259 "gh",
260 "gcloud auth",
261 "gcloud init",
262 "aws sso",
263 "aws configure sso",
264 "firebase login",
265 "netlify login",
266 "vercel login",
267 "heroku login",
268 "flyctl auth",
269 "fly auth",
270 "railway login",
271 "supabase login",
272 "wrangler login",
273 "doppler login",
274 "vault login",
275 "oc login",
276 "kubelogin",
277 "--use-device-code",
278];
279
280const SCRIPT_RUNNER_PREFIXES: &[&str] = &[
281 "npm run ",
282 "npm start",
283 "npx ",
284 "pnpm run ",
285 "pnpm dev",
286 "pnpm start",
287 "pnpm preview",
288 "yarn ",
289 "bun run ",
290 "bun start",
291 "deno task ",
292];
293
294const DEV_SCRIPT_KEYWORDS: &[&str] = &[
295 "dev",
296 "start",
297 "serve",
298 "watch",
299 "preview",
300 "storybook",
301 "hot",
302 "live",
303 "hmr",
304];
305
306fn is_dev_script_runner(cmd: &str) -> bool {
307 for prefix in SCRIPT_RUNNER_PREFIXES {
308 if let Some(rest) = cmd.strip_prefix(prefix) {
309 let script_name = rest.split_whitespace().next().unwrap_or("");
310 for kw in DEV_SCRIPT_KEYWORDS {
311 if script_name.contains(kw) {
312 return true;
313 }
314 }
315 }
316 }
317 false
318}
319
320pub(super) fn is_excluded_command(command: &str, excluded: &[String]) -> bool {
321 let cmd = command.trim().to_lowercase();
322 for pattern in BUILTIN_PASSTHROUGH {
323 if pattern.starts_with("--") {
324 if cmd.contains(pattern) {
325 return true;
326 }
327 } else if pattern.ends_with(' ') || pattern.ends_with('\t') {
328 if cmd == pattern.trim() || cmd.starts_with(pattern) {
329 return true;
330 }
331 } else if cmd == *pattern
332 || cmd.starts_with(&format!("{pattern} "))
333 || cmd.starts_with(&format!("{pattern}\t"))
334 || cmd.contains(&format!(" {pattern} "))
335 || cmd.contains(&format!(" {pattern}\t"))
336 || cmd.contains(&format!("|{pattern} "))
337 || cmd.contains(&format!("|{pattern}\t"))
338 || cmd.ends_with(&format!(" {pattern}"))
339 || cmd.ends_with(&format!("|{pattern}"))
340 {
341 return true;
342 }
343 }
344
345 if is_dev_script_runner(&cmd) {
346 return true;
347 }
348
349 if excluded.is_empty() {
350 return false;
351 }
352 excluded.iter().any(|excl| {
353 let excl_lower = excl.trim().to_lowercase();
354 cmd == excl_lower || cmd.starts_with(&format!("{excl_lower} "))
355 })
356}
357
358pub(super) fn compress_and_measure(command: &str, stdout: &str, stderr: &str) -> (String, usize) {
359 let compressed_stdout = compress_if_beneficial(command, stdout);
360 let compressed_stderr = compress_if_beneficial(command, stderr);
361
362 let mut result = String::new();
363 if !compressed_stdout.is_empty() {
364 result.push_str(&compressed_stdout);
365 }
366 if !compressed_stderr.is_empty() {
367 if !result.is_empty() {
368 result.push('\n');
369 }
370 result.push_str(&compressed_stderr);
371 }
372
373 let content_for_counting = if let Some(pos) = result.rfind("\n[lean-ctx: ") {
374 &result[..pos]
375 } else {
376 &result
377 };
378 let output_tokens = count_tokens(content_for_counting);
379 (result, output_tokens)
380}
381
382fn is_search_output(command: &str) -> bool {
383 let c = command.trim_start();
384 c.starts_with("grep ")
385 || c.starts_with("rg ")
386 || c.starts_with("find ")
387 || c.starts_with("fd ")
388 || c.starts_with("ag ")
389 || c.starts_with("ack ")
390}
391
392pub fn has_structural_output(command: &str) -> bool {
398 if is_standalone_diff_command(command) {
399 return true;
400 }
401 is_structural_git_command(command)
402}
403
404fn is_standalone_diff_command(command: &str) -> bool {
406 let first = command.split_whitespace().next().unwrap_or("");
407 let base = first.rsplit('/').next().unwrap_or(first);
408 base.eq_ignore_ascii_case("diff")
409 || base.eq_ignore_ascii_case("colordiff")
410 || base.eq_ignore_ascii_case("icdiff")
411 || base.eq_ignore_ascii_case("delta")
412}
413
414fn is_structural_git_command(command: &str) -> bool {
416 let mut tokens = command.split_whitespace();
417 while let Some(tok) = tokens.next() {
418 let base = tok.rsplit('/').next().unwrap_or(tok);
419 if !base.eq_ignore_ascii_case("git") {
420 continue;
421 }
422 let mut skip_next = false;
423 let remaining: Vec<&str> = tokens.collect();
424 for arg in &remaining {
425 if skip_next {
426 skip_next = false;
427 continue;
428 }
429 if *arg == "-C" || *arg == "-c" || *arg == "--git-dir" || *arg == "--work-tree" {
430 skip_next = true;
431 continue;
432 }
433 if arg.starts_with('-') {
434 continue;
435 }
436 let sub = arg.to_ascii_lowercase();
437 return match sub.as_str() {
438 "diff" | "show" | "blame" => true,
439 "log" => has_patch_flag(&remaining),
440 "stash" => remaining.iter().any(|a| a.eq_ignore_ascii_case("show")),
441 _ => false,
442 };
443 }
444 return false;
445 }
446 false
447}
448
449fn has_patch_flag(args: &[&str]) -> bool {
451 args.iter()
452 .any(|a| *a == "-p" || *a == "--patch" || a.starts_with("-p"))
453}
454
455fn compress_if_beneficial(command: &str, output: &str) -> String {
456 if output.trim().is_empty() {
457 return String::new();
458 }
459
460 if !is_search_output(command) && crate::tools::ctx_shell::contains_auth_flow(output) {
461 return output.to_string();
462 }
463
464 let original_tokens = count_tokens(output);
465
466 if original_tokens < 50 {
467 return output.to_string();
468 }
469
470 let min_output_tokens = 5;
471
472 if has_structural_output(command) {
473 let cl = command.to_ascii_lowercase();
474 if let Some(compressed) = patterns::try_specific_pattern(&cl, output) {
475 if !compressed.trim().is_empty() {
476 let compressed_tokens = count_tokens(&compressed);
477 if compressed_tokens >= min_output_tokens && compressed_tokens < original_tokens {
478 let saved = original_tokens - compressed_tokens;
479 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
480 if pct >= 5 {
481 return format!(
482 "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
483 );
484 }
485 return compressed;
486 }
487 }
488 }
489 return output.to_string();
490 }
491
492 if let Some(mut compressed) = patterns::compress_output(command, output) {
493 if !compressed.trim().is_empty() {
494 let config = crate::core::config::Config::load();
495 let level = crate::core::config::CompressionLevel::effective(&config);
496 if level.is_active() {
497 let terse_result =
498 crate::core::terse::pipeline::compress(output, &level, Some(&compressed));
499 if terse_result.quality_passed {
500 compressed = terse_result.output;
501 }
502 }
503
504 let compressed_tokens = count_tokens(&compressed);
505 if compressed_tokens >= min_output_tokens && compressed_tokens < original_tokens {
506 let ratio = compressed_tokens as f64 / original_tokens as f64;
507 if ratio < 0.05 && original_tokens > 100 && original_tokens < 2000 {
508 tracing::warn!("compression removed >95% of small output, returning original");
509 return output.to_string();
510 }
511 let saved = original_tokens - compressed_tokens;
512 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
513 if pct >= 5 {
514 return format!(
515 "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
516 );
517 }
518 return compressed;
519 }
520 if compressed_tokens < min_output_tokens {
521 return output.to_string();
522 }
523 }
524 }
525
526 {
527 let config = crate::core::config::Config::load();
528 let level = crate::core::config::CompressionLevel::effective(&config);
529 if level.is_active() {
530 let terse_result = crate::core::terse::pipeline::compress(output, &level, None);
531 if terse_result.quality_passed && terse_result.savings_pct >= 3.0 {
532 let tok_before = terse_result.tokens_before;
533 let tok_after = terse_result.tokens_after;
534 let pct = terse_result.savings_pct.round() as usize;
535 return format!(
536 "{}\n[lean-ctx: {tok_before}→{tok_after} tok, -{pct}%]",
537 terse_result.output
538 );
539 }
540 }
541 }
542
543 let cleaned = crate::core::compressor::lightweight_cleanup(output);
544 let cleaned_tokens = count_tokens(&cleaned);
545 if cleaned_tokens < original_tokens {
546 let lines: Vec<&str> = cleaned.lines().collect();
547 if lines.len() > 30 {
548 let compressed = truncate_with_safety_scan(&lines, original_tokens);
549 if let Some(c) = compressed {
550 return c;
551 }
552 }
553 if cleaned_tokens < original_tokens {
554 let saved = original_tokens - cleaned_tokens;
555 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
556 if pct >= 5 {
557 return format!(
558 "{cleaned}\n[lean-ctx: {original_tokens}→{cleaned_tokens} tok, -{pct}%]"
559 );
560 }
561 return cleaned;
562 }
563 }
564
565 let lines: Vec<&str> = output.lines().collect();
566 if lines.len() > 30 {
567 if let Some(c) = truncate_with_safety_scan(&lines, original_tokens) {
568 return c;
569 }
570 }
571
572 output.to_string()
573}
574
575fn truncate_with_safety_scan(lines: &[&str], original_tokens: usize) -> Option<String> {
576 use crate::core::safety_needles;
577
578 let first = &lines[..5];
579 let last = &lines[lines.len() - 5..];
580 let middle = &lines[5..lines.len() - 5];
581
582 let safety_lines = safety_needles::extract_safety_lines(middle, 20);
583 let safety_count = safety_lines.len();
584 let omitted = middle.len() - safety_count;
585
586 let mut parts = Vec::new();
587 parts.push(first.join("\n"));
588 if safety_count > 0 {
589 parts.push(format!(
590 "[{omitted} lines omitted, {safety_count} safety-relevant lines preserved]"
591 ));
592 parts.push(safety_lines.join("\n"));
593 } else {
594 parts.push(format!("[{omitted} lines omitted]"));
595 }
596 parts.push(last.join("\n"));
597
598 let compressed = parts.join("\n");
599 let ct = count_tokens(&compressed);
600 if ct >= original_tokens {
601 return None;
602 }
603 let saved = original_tokens - ct;
604 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
605 if pct >= 5 {
606 Some(format!(
607 "{compressed}\n[lean-ctx: {original_tokens}→{ct} tok, -{pct}%]"
608 ))
609 } else {
610 Some(compressed)
611 }
612}
613
614pub fn compress_if_beneficial_pub(command: &str, output: &str) -> String {
616 compress_if_beneficial(command, output)
617}
618
619#[cfg(test)]
620mod passthrough_tests {
621 use super::is_excluded_command;
622
623 #[test]
624 fn turbo_is_passthrough() {
625 assert!(is_excluded_command("turbo run dev", &[]));
626 assert!(is_excluded_command("turbo run build", &[]));
627 assert!(is_excluded_command("pnpm turbo run dev", &[]));
628 assert!(is_excluded_command("npx turbo run dev", &[]));
629 }
630
631 #[test]
632 fn dev_servers_are_passthrough() {
633 assert!(is_excluded_command("next dev", &[]));
634 assert!(is_excluded_command("vite dev", &[]));
635 assert!(is_excluded_command("nuxt dev", &[]));
636 assert!(is_excluded_command("astro dev", &[]));
637 assert!(is_excluded_command("nodemon server.js", &[]));
638 }
639
640 #[test]
641 fn interactive_tools_are_passthrough() {
642 assert!(is_excluded_command("vim file.rs", &[]));
643 assert!(is_excluded_command("nvim", &[]));
644 assert!(is_excluded_command("htop", &[]));
645 assert!(is_excluded_command("ssh user@host", &[]));
646 assert!(is_excluded_command("tail -f /var/log/syslog", &[]));
647 }
648
649 #[test]
650 fn docker_streaming_is_passthrough() {
651 assert!(is_excluded_command("docker logs my-container", &[]));
652 assert!(is_excluded_command("docker logs -f webapp", &[]));
653 assert!(is_excluded_command("docker attach my-container", &[]));
654 assert!(is_excluded_command("docker exec -it web bash", &[]));
655 assert!(is_excluded_command("docker exec -ti web bash", &[]));
656 assert!(is_excluded_command("docker run -it ubuntu bash", &[]));
657 assert!(is_excluded_command("docker compose exec web bash", &[]));
658 assert!(is_excluded_command("docker stats", &[]));
659 assert!(is_excluded_command("docker events", &[]));
660 }
661
662 #[test]
663 fn kubectl_is_passthrough() {
664 assert!(is_excluded_command("kubectl logs my-pod", &[]));
665 assert!(is_excluded_command("kubectl logs -f deploy/web", &[]));
666 assert!(is_excluded_command("kubectl exec -it pod -- bash", &[]));
667 assert!(is_excluded_command(
668 "kubectl port-forward svc/web 8080:80",
669 &[]
670 ));
671 assert!(is_excluded_command("kubectl attach my-pod", &[]));
672 assert!(is_excluded_command("kubectl proxy", &[]));
673 }
674
675 #[test]
676 fn database_repls_are_passthrough() {
677 assert!(is_excluded_command("psql -U user mydb", &[]));
678 assert!(is_excluded_command("mysql -u root -p", &[]));
679 assert!(is_excluded_command("sqlite3 data.db", &[]));
680 assert!(is_excluded_command("redis-cli", &[]));
681 assert!(is_excluded_command("mongosh", &[]));
682 }
683
684 #[test]
685 fn streaming_tools_are_passthrough() {
686 assert!(is_excluded_command("journalctl -f", &[]));
687 assert!(is_excluded_command("ping 8.8.8.8", &[]));
688 assert!(is_excluded_command("strace -p 1234", &[]));
689 assert!(is_excluded_command("tcpdump -i eth0", &[]));
690 assert!(is_excluded_command("tail -F /var/log/app.log", &[]));
691 assert!(is_excluded_command("tmux new -s work", &[]));
692 assert!(is_excluded_command("screen -S dev", &[]));
693 }
694
695 #[test]
696 fn additional_dev_servers_are_passthrough() {
697 assert!(is_excluded_command("gatsby develop", &[]));
698 assert!(is_excluded_command("ng serve --port 4200", &[]));
699 assert!(is_excluded_command("remix dev", &[]));
700 assert!(is_excluded_command("wrangler dev", &[]));
701 assert!(is_excluded_command("hugo server", &[]));
702 assert!(is_excluded_command("bun dev", &[]));
703 assert!(is_excluded_command("cargo watch -x test", &[]));
704 }
705
706 #[test]
707 fn normal_commands_not_excluded() {
708 assert!(!is_excluded_command("git status", &[]));
709 assert!(!is_excluded_command("cargo test", &[]));
710 assert!(!is_excluded_command("npm run build", &[]));
711 assert!(!is_excluded_command("ls -la", &[]));
712 }
713
714 #[test]
715 fn user_exclusions_work() {
716 let excl = vec!["myapp".to_string()];
717 assert!(is_excluded_command("myapp serve", &excl));
718 assert!(!is_excluded_command("git status", &excl));
719 }
720
721 #[test]
722 fn auth_commands_excluded() {
723 assert!(is_excluded_command("az login --use-device-code", &[]));
724 assert!(is_excluded_command("gh auth login", &[]));
725 assert!(is_excluded_command("gh pr close --comment 'done'", &[]));
726 assert!(is_excluded_command("gh issue list", &[]));
727 assert!(is_excluded_command("gcloud auth login", &[]));
728 assert!(is_excluded_command("aws sso login", &[]));
729 assert!(is_excluded_command("firebase login", &[]));
730 assert!(is_excluded_command("vercel login", &[]));
731 assert!(is_excluded_command("heroku login", &[]));
732 assert!(is_excluded_command("az login", &[]));
733 assert!(is_excluded_command("kubelogin convert-kubeconfig", &[]));
734 assert!(is_excluded_command("vault login -method=oidc", &[]));
735 assert!(is_excluded_command("flyctl auth login", &[]));
736 }
737
738 #[test]
739 fn auth_exclusion_does_not_affect_normal_commands() {
740 assert!(!is_excluded_command("git log", &[]));
741 assert!(!is_excluded_command("npm run build", &[]));
742 assert!(!is_excluded_command("cargo test", &[]));
743 assert!(!is_excluded_command("aws s3 ls", &[]));
744 assert!(!is_excluded_command("gcloud compute instances list", &[]));
745 assert!(!is_excluded_command("az vm list", &[]));
746 }
747
748 #[test]
749 fn npm_script_runners_are_passthrough() {
750 assert!(is_excluded_command("npm run dev", &[]));
751 assert!(is_excluded_command("npm run start", &[]));
752 assert!(is_excluded_command("npm run serve", &[]));
753 assert!(is_excluded_command("npm run watch", &[]));
754 assert!(is_excluded_command("npm run preview", &[]));
755 assert!(is_excluded_command("npm run storybook", &[]));
756 assert!(is_excluded_command("npm run test:watch", &[]));
757 assert!(is_excluded_command("npm start", &[]));
758 assert!(is_excluded_command("npx vite", &[]));
759 assert!(is_excluded_command("npx next dev", &[]));
760 }
761
762 #[test]
763 fn pnpm_script_runners_are_passthrough() {
764 assert!(is_excluded_command("pnpm run dev", &[]));
765 assert!(is_excluded_command("pnpm run start", &[]));
766 assert!(is_excluded_command("pnpm run serve", &[]));
767 assert!(is_excluded_command("pnpm run watch", &[]));
768 assert!(is_excluded_command("pnpm run preview", &[]));
769 assert!(is_excluded_command("pnpm dev", &[]));
770 assert!(is_excluded_command("pnpm start", &[]));
771 assert!(is_excluded_command("pnpm preview", &[]));
772 }
773
774 #[test]
775 fn yarn_script_runners_are_passthrough() {
776 assert!(is_excluded_command("yarn dev", &[]));
777 assert!(is_excluded_command("yarn start", &[]));
778 assert!(is_excluded_command("yarn serve", &[]));
779 assert!(is_excluded_command("yarn watch", &[]));
780 assert!(is_excluded_command("yarn preview", &[]));
781 assert!(is_excluded_command("yarn storybook", &[]));
782 }
783
784 #[test]
785 fn bun_deno_script_runners_are_passthrough() {
786 assert!(is_excluded_command("bun run dev", &[]));
787 assert!(is_excluded_command("bun run start", &[]));
788 assert!(is_excluded_command("bun run serve", &[]));
789 assert!(is_excluded_command("bun run watch", &[]));
790 assert!(is_excluded_command("bun run preview", &[]));
791 assert!(is_excluded_command("bun start", &[]));
792 assert!(is_excluded_command("deno task dev", &[]));
793 assert!(is_excluded_command("deno task start", &[]));
794 assert!(is_excluded_command("deno task serve", &[]));
795 assert!(is_excluded_command("deno run --watch main.ts", &[]));
796 }
797
798 #[test]
799 fn python_servers_are_passthrough() {
800 assert!(is_excluded_command("flask run --port 5000", &[]));
801 assert!(is_excluded_command("uvicorn app:app --reload", &[]));
802 assert!(is_excluded_command("gunicorn app:app -w 4", &[]));
803 assert!(is_excluded_command("hypercorn app:app", &[]));
804 assert!(is_excluded_command("daphne app.asgi:application", &[]));
805 assert!(is_excluded_command(
806 "django-admin runserver 0.0.0.0:8000",
807 &[]
808 ));
809 assert!(is_excluded_command("python manage.py runserver", &[]));
810 assert!(is_excluded_command("python -m http.server 8080", &[]));
811 assert!(is_excluded_command("python3 -m http.server", &[]));
812 assert!(is_excluded_command("streamlit run app.py", &[]));
813 assert!(is_excluded_command("gradio app.py", &[]));
814 assert!(is_excluded_command("celery worker -A app", &[]));
815 assert!(is_excluded_command("celery -A app worker", &[]));
816 assert!(is_excluded_command("celery -B", &[]));
817 assert!(is_excluded_command("dramatiq tasks", &[]));
818 assert!(is_excluded_command("rq worker", &[]));
819 assert!(is_excluded_command("ptw tests/", &[]));
820 assert!(is_excluded_command("pytest-watch", &[]));
821 }
822
823 #[test]
824 fn ruby_servers_are_passthrough() {
825 assert!(is_excluded_command("rails server -p 3000", &[]));
826 assert!(is_excluded_command("rails s", &[]));
827 assert!(is_excluded_command("puma -C config.rb", &[]));
828 assert!(is_excluded_command("unicorn -c config.rb", &[]));
829 assert!(is_excluded_command("thin start", &[]));
830 assert!(is_excluded_command("foreman start", &[]));
831 assert!(is_excluded_command("overmind start", &[]));
832 assert!(is_excluded_command("guard -G Guardfile", &[]));
833 assert!(is_excluded_command("sidekiq", &[]));
834 assert!(is_excluded_command("resque work", &[]));
835 }
836
837 #[test]
838 fn php_servers_are_passthrough() {
839 assert!(is_excluded_command("php artisan serve", &[]));
840 assert!(is_excluded_command("php -S localhost:8000", &[]));
841 assert!(is_excluded_command("php artisan queue:work", &[]));
842 assert!(is_excluded_command("php artisan queue:listen", &[]));
843 assert!(is_excluded_command("php artisan horizon", &[]));
844 assert!(is_excluded_command("php artisan tinker", &[]));
845 assert!(is_excluded_command("sail up", &[]));
846 }
847
848 #[test]
849 fn java_servers_are_passthrough() {
850 assert!(is_excluded_command("./gradlew bootRun", &[]));
851 assert!(is_excluded_command("gradlew bootRun", &[]));
852 assert!(is_excluded_command("gradle bootRun", &[]));
853 assert!(is_excluded_command("mvn spring-boot:run", &[]));
854 assert!(is_excluded_command("./mvnw spring-boot:run", &[]));
855 assert!(is_excluded_command("mvn quarkus:dev", &[]));
856 assert!(is_excluded_command("./mvnw quarkus:dev", &[]));
857 assert!(is_excluded_command("sbt run", &[]));
858 assert!(is_excluded_command("sbt ~compile", &[]));
859 assert!(is_excluded_command("lein run", &[]));
860 assert!(is_excluded_command("lein repl", &[]));
861 assert!(is_excluded_command("./gradlew run", &[]));
862 }
863
864 #[test]
865 fn go_servers_are_passthrough() {
866 assert!(is_excluded_command("go run main.go", &[]));
867 assert!(is_excluded_command("go run ./cmd/server", &[]));
868 assert!(is_excluded_command("air -c .air.toml", &[]));
869 assert!(is_excluded_command("gin --port 3000", &[]));
870 assert!(is_excluded_command("realize start", &[]));
871 assert!(is_excluded_command("reflex -r '.go$' go run .", &[]));
872 assert!(is_excluded_command("gowatch run", &[]));
873 }
874
875 #[test]
876 fn dotnet_servers_are_passthrough() {
877 assert!(is_excluded_command("dotnet run", &[]));
878 assert!(is_excluded_command("dotnet run --project src/Api", &[]));
879 assert!(is_excluded_command("dotnet watch run", &[]));
880 assert!(is_excluded_command("dotnet ef database update", &[]));
881 }
882
883 #[test]
884 fn elixir_servers_are_passthrough() {
885 assert!(is_excluded_command("mix phx.server", &[]));
886 assert!(is_excluded_command("iex -s mix phx.server", &[]));
887 assert!(is_excluded_command("iex -S mix phx.server", &[]));
888 }
889
890 #[test]
891 fn swift_zig_servers_are_passthrough() {
892 assert!(is_excluded_command("swift run MyApp", &[]));
893 assert!(is_excluded_command("swift package resolve", &[]));
894 assert!(is_excluded_command("vapor serve --port 8080", &[]));
895 assert!(is_excluded_command("zig build run", &[]));
896 }
897
898 #[test]
899 fn rust_watchers_are_passthrough() {
900 assert!(is_excluded_command("cargo watch -x test", &[]));
901 assert!(is_excluded_command("cargo run --bin server", &[]));
902 assert!(is_excluded_command("cargo leptos watch", &[]));
903 assert!(is_excluded_command("bacon test", &[]));
904 }
905
906 #[test]
907 fn general_task_runners_are_passthrough() {
908 assert!(is_excluded_command("make dev", &[]));
909 assert!(is_excluded_command("make serve", &[]));
910 assert!(is_excluded_command("make watch", &[]));
911 assert!(is_excluded_command("make run", &[]));
912 assert!(is_excluded_command("make start", &[]));
913 assert!(is_excluded_command("just dev", &[]));
914 assert!(is_excluded_command("just serve", &[]));
915 assert!(is_excluded_command("just watch", &[]));
916 assert!(is_excluded_command("just start", &[]));
917 assert!(is_excluded_command("just run", &[]));
918 assert!(is_excluded_command("task dev", &[]));
919 assert!(is_excluded_command("task serve", &[]));
920 assert!(is_excluded_command("task watch", &[]));
921 assert!(is_excluded_command("nix develop", &[]));
922 assert!(is_excluded_command("devenv up", &[]));
923 }
924
925 #[test]
926 fn cicd_infra_are_passthrough() {
927 assert!(is_excluded_command("act push", &[]));
928 assert!(is_excluded_command("docker compose watch", &[]));
929 assert!(is_excluded_command("docker-compose watch", &[]));
930 assert!(is_excluded_command("skaffold dev", &[]));
931 assert!(is_excluded_command("tilt up", &[]));
932 assert!(is_excluded_command("garden dev", &[]));
933 assert!(is_excluded_command("telepresence connect", &[]));
934 }
935
936 #[test]
937 fn networking_monitoring_are_passthrough() {
938 assert!(is_excluded_command("mtr 8.8.8.8", &[]));
939 assert!(is_excluded_command("nmap -sV host", &[]));
940 assert!(is_excluded_command("iperf -s", &[]));
941 assert!(is_excluded_command("iperf3 -c host", &[]));
942 assert!(is_excluded_command("socat TCP-LISTEN:8080,fork -", &[]));
943 }
944
945 #[test]
946 fn load_testing_is_passthrough() {
947 assert!(is_excluded_command("ab -n 1000 http://localhost/", &[]));
948 assert!(is_excluded_command("wrk -t12 -c400 http://localhost/", &[]));
949 assert!(is_excluded_command("hey -n 10000 http://localhost/", &[]));
950 assert!(is_excluded_command("vegeta attack", &[]));
951 assert!(is_excluded_command("k6 run script.js", &[]));
952 assert!(is_excluded_command("artillery run test.yml", &[]));
953 }
954
955 #[test]
956 fn smart_script_detection_works() {
957 assert!(is_excluded_command("npm run dev:ssr", &[]));
958 assert!(is_excluded_command("npm run dev:local", &[]));
959 assert!(is_excluded_command("yarn start:production", &[]));
960 assert!(is_excluded_command("pnpm run serve:local", &[]));
961 assert!(is_excluded_command("bun run watch:css", &[]));
962 assert!(is_excluded_command("deno task dev:api", &[]));
963 assert!(is_excluded_command("npm run storybook:ci", &[]));
964 assert!(is_excluded_command("yarn preview:staging", &[]));
965 assert!(is_excluded_command("pnpm run hot-reload", &[]));
966 assert!(is_excluded_command("npm run hmr-server", &[]));
967 assert!(is_excluded_command("bun run live-server", &[]));
968 }
969
970 #[test]
971 fn smart_detection_does_not_false_positive() {
972 assert!(!is_excluded_command("npm run build", &[]));
973 assert!(!is_excluded_command("npm run lint", &[]));
974 assert!(!is_excluded_command("npm run test", &[]));
975 assert!(!is_excluded_command("npm run format", &[]));
976 assert!(!is_excluded_command("yarn build", &[]));
977 assert!(!is_excluded_command("yarn test", &[]));
978 assert!(!is_excluded_command("pnpm run lint", &[]));
979 assert!(!is_excluded_command("bun run build", &[]));
980 }
981
982 #[test]
983 fn gh_fully_excluded() {
984 assert!(is_excluded_command("gh", &[]));
985 assert!(is_excluded_command(
986 "gh pr close --comment 'closing — see #407'",
987 &[]
988 ));
989 assert!(is_excluded_command(
990 "gh issue create --title \"bug\" --body \"desc\"",
991 &[]
992 ));
993 assert!(is_excluded_command("gh api repos/owner/repo/pulls", &[]));
994 assert!(is_excluded_command("gh run list --limit 5", &[]));
995 }
996}
997
998#[cfg(test)]
999mod structural_output_tests {
1000 use super::has_structural_output;
1001
1002 #[test]
1003 fn git_diff_is_structural() {
1004 assert!(has_structural_output("git diff"));
1005 assert!(has_structural_output("git diff --cached"));
1006 assert!(has_structural_output("git diff --staged"));
1007 assert!(has_structural_output("git diff HEAD~1"));
1008 assert!(has_structural_output("git diff main..feature"));
1009 assert!(has_structural_output("git diff -- src/main.rs"));
1010 }
1011
1012 #[test]
1013 fn git_show_is_structural() {
1014 assert!(has_structural_output("git show"));
1015 assert!(has_structural_output("git show HEAD"));
1016 assert!(has_structural_output("git show abc1234"));
1017 assert!(has_structural_output("git show stash@{0}"));
1018 }
1019
1020 #[test]
1021 fn git_blame_is_structural() {
1022 assert!(has_structural_output("git blame src/main.rs"));
1023 assert!(has_structural_output("git blame -L 10,20 file.rs"));
1024 }
1025
1026 #[test]
1027 fn git_with_flags_is_structural() {
1028 assert!(has_structural_output("git -C /tmp diff"));
1029 assert!(has_structural_output("git --git-dir /path diff HEAD"));
1030 assert!(has_structural_output("git -c core.pager=cat show abc"));
1031 }
1032
1033 #[test]
1034 fn case_insensitive() {
1035 assert!(has_structural_output("Git Diff"));
1036 assert!(has_structural_output("GIT DIFF --cached"));
1037 assert!(has_structural_output("git SHOW HEAD"));
1038 }
1039
1040 #[test]
1041 fn full_path_git_binary() {
1042 assert!(has_structural_output("/usr/bin/git diff"));
1043 assert!(has_structural_output("/usr/local/bin/git show HEAD"));
1044 }
1045
1046 #[test]
1047 fn standalone_diff_is_structural() {
1048 assert!(has_structural_output("diff file1.txt file2.txt"));
1049 assert!(has_structural_output("diff -u old.py new.py"));
1050 assert!(has_structural_output("diff -r dir1 dir2"));
1051 assert!(has_structural_output("/usr/bin/diff a b"));
1052 assert!(has_structural_output("colordiff file1 file2"));
1053 assert!(has_structural_output("icdiff old.rs new.rs"));
1054 assert!(has_structural_output("delta"));
1055 }
1056
1057 #[test]
1058 fn git_log_with_patch_is_structural() {
1059 assert!(has_structural_output("git log -p"));
1060 assert!(has_structural_output("git log --patch"));
1061 assert!(has_structural_output("git log -p HEAD~5"));
1062 assert!(has_structural_output("git log -p --stat"));
1063 assert!(has_structural_output("git log --patch --follow file.rs"));
1064 }
1065
1066 #[test]
1067 fn git_log_without_patch_not_structural() {
1068 assert!(!has_structural_output("git log"));
1069 assert!(!has_structural_output("git log --oneline"));
1070 assert!(!has_structural_output("git log --stat"));
1071 assert!(!has_structural_output("git log -n 5"));
1072 }
1073
1074 #[test]
1075 fn git_stash_show_is_structural() {
1076 assert!(has_structural_output("git stash show"));
1077 assert!(has_structural_output("git stash show -p"));
1078 assert!(has_structural_output("git stash show --patch"));
1079 assert!(has_structural_output("git stash show stash@{0}"));
1080 }
1081
1082 #[test]
1083 fn git_stash_without_show_not_structural() {
1084 assert!(!has_structural_output("git stash"));
1085 assert!(!has_structural_output("git stash list"));
1086 assert!(!has_structural_output("git stash pop"));
1087 assert!(!has_structural_output("git stash drop"));
1088 }
1089
1090 #[test]
1091 fn non_structural_git_commands() {
1092 assert!(!has_structural_output("git status"));
1093 assert!(!has_structural_output("git commit -m 'fix'"));
1094 assert!(!has_structural_output("git push"));
1095 assert!(!has_structural_output("git pull"));
1096 assert!(!has_structural_output("git branch"));
1097 assert!(!has_structural_output("git fetch"));
1098 assert!(!has_structural_output("git add ."));
1099 }
1100
1101 #[test]
1102 fn non_git_commands() {
1103 assert!(!has_structural_output("cargo build"));
1104 assert!(!has_structural_output("npm run build"));
1105 assert!(!has_structural_output("ls -la"));
1106 assert!(!has_structural_output("docker ps"));
1107 }
1108
1109 #[test]
1110 fn git_diff_output_preserves_hunks() {
1111 let diff = "diff --git a/src/main.rs b/src/main.rs\n\
1112 index abc1234..def5678 100644\n\
1113 --- a/src/main.rs\n\
1114 +++ b/src/main.rs\n\
1115 @@ -1,5 +1,6 @@\n\
1116 fn main() {\n\
1117 + println!(\"hello\");\n\
1118 let x = 1;\n\
1119 let y = 2;\n\
1120 - let z = 3;\n\
1121 + let z = x + y;\n\
1122 }";
1123 let result = super::compress_if_beneficial("git diff", diff);
1124 assert!(
1125 result.contains("+ println!"),
1126 "must preserve added lines, got: {result}"
1127 );
1128 assert!(
1129 result.contains("- let z = 3;"),
1130 "must preserve removed lines, got: {result}"
1131 );
1132 assert!(
1133 result.contains("@@ -1,5 +1,6 @@"),
1134 "must preserve hunk headers, got: {result}"
1135 );
1136 }
1137
1138 #[test]
1139 fn git_diff_large_preserves_content() {
1140 let mut diff = String::new();
1141 diff.push_str("diff --git a/file.rs b/file.rs\n");
1142 diff.push_str("--- a/file.rs\n+++ b/file.rs\n");
1143 diff.push_str("@@ -1,100 +1,100 @@\n");
1144 for i in 0..80 {
1145 diff.push_str(&format!("+added line {i}: some actual code content\n"));
1146 diff.push_str(&format!("-removed line {i}: old code content\n"));
1147 }
1148 let result = super::compress_if_beneficial("git diff", &diff);
1149 assert!(
1150 result.contains("+added line 0"),
1151 "must preserve first added line, got len: {}",
1152 result.len()
1153 );
1154 assert!(
1155 result.contains("-removed line 0"),
1156 "must preserve first removed line, got len: {}",
1157 result.len()
1158 );
1159 }
1160}