Skip to main content

lean_ctx/shell/
compress.rs

1use crate::core::patterns;
2use crate::core::tokens::count_tokens;
3
4const BUILTIN_PASSTHROUGH: &[&str] = &[
5    // JS/TS dev servers & watchers
6    "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    // Package manager script runners (wrap dev servers via package.json)
33    "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
68    "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    // Kubernetes
87    "kubectl logs",
88    "kubectl exec -it",
89    "kubectl exec -ti",
90    "kubectl attach",
91    "kubectl port-forward",
92    "kubectl proxy",
93    // System monitors & streaming
94    "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    // Editors & pagers
118    "less",
119    "more",
120    "vim",
121    "nvim",
122    "vi ",
123    "nano",
124    "micro ",
125    "helix ",
126    "hx ",
127    "emacs",
128    // Terminal multiplexers
129    "tmux",
130    "screen",
131    // Interactive shells & REPLs
132    "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    // Python servers, workers, watchers
149    "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    // Ruby / Rails
170    "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 / Laravel
181    "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    // Java / JVM
189    "./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
203    "go run ",
204    "air ",
205    "gin ",
206    "realize start",
207    "reflex ",
208    "gowatch ",
209    // .NET / C#
210    "dotnet run",
211    "dotnet watch",
212    "dotnet ef",
213    // Elixir / Erlang
214    "mix phx.server",
215    "iex -s mix",
216    // Swift
217    "swift run",
218    "swift package ",
219    "vapor serve",
220    // Zig
221    "zig build run",
222    // Rust
223    "cargo watch",
224    "cargo run",
225    "cargo leptos watch",
226    "bacon ",
227    // General watchers & task runners
228    "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    // CI/CD & infrastructure (long-running)
244    "act ",
245    "skaffold dev",
246    "tilt up",
247    "garden dev",
248    "telepresence ",
249    // Load testing & benchmarking
250    "ab ",
251    "wrk ",
252    "hey ",
253    "vegeta ",
254    "k6 run",
255    "artillery run",
256    // Authentication flows (device code, OAuth, SSO)
257    "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
392/// Returns true for commands whose output structure is critical for developer
393/// readability. Pattern compression (light cleanup like removing `index` lines
394/// or limiting context) still applies, but the terse pipeline and generic
395/// compressors are skipped so diff hunks, blame annotations, etc. remain
396/// fully readable.
397pub 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
404/// Non-git diff tools: `diff`, `colordiff`, `icdiff`, `delta`.
405fn 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
414/// Git subcommands that produce structural output the developer must read verbatim.
415fn 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
449/// Returns true if the argument list contains `-p` or `--patch`.
450fn 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
614/// Public wrapper for integration tests to exercise the compression pipeline.
615pub 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}