Skip to main content

lean_ctx/cli/dispatch/
mod.rs

1use crate::{
2    core, doctor, heatmap, hook_handlers, report, setup, shell, status, token_report, uninstall,
3};
4
5mod analytics;
6mod help;
7mod lifecycle;
8mod network;
9mod server;
10
11#[allow(clippy::wildcard_imports)]
12use analytics::*;
13#[allow(clippy::wildcard_imports)]
14use help::*;
15#[allow(clippy::wildcard_imports)]
16use lifecycle::*;
17#[allow(clippy::wildcard_imports)]
18use network::*;
19#[allow(clippy::wildcard_imports)]
20use server::*;
21
22pub fn run() {
23    let mut args: Vec<String> = std::env::args().collect();
24
25    // On Linux, if the binary was replaced while running, systemd may write
26    // the path with " (deleted)" suffix into ExecStart, causing "(deleted)"
27    // to appear as an argument. Strip it defensively.
28    if args.get(1).is_some_and(|a| a == "(deleted)") {
29        args.remove(1);
30    }
31
32    if !is_server_mode(&args) {
33        restore_sigpipe_default();
34    }
35
36    let enters_mcp = args.len() == 1 || args.get(1).is_some_and(|a| a == "mcp");
37    if !enters_mcp {
38        crate::core::logging::init_logging();
39    }
40
41    if args.len() > 1 {
42        let rest = args[2..].to_vec();
43
44        match args[1].as_str() {
45            "-c" | "exec" => {
46                let raw = rest.first().is_some_and(|a| a == "--raw");
47                let cmd_args = if raw { &args[3..] } else { &args[2..] };
48                let command = if cmd_args.len() == 1 {
49                    cmd_args[0].clone()
50                } else {
51                    shell::join_command(cmd_args)
52                };
53                // The `lean-ctx -c` wrapper runs inside the agent shell, which
54                // carries runtime/session vars the MCP server never sees. Bridge
55                // them so ctx_shell can forward them too (#370).
56                core::agent_runtime_env::capture();
57                if std::env::var("LEAN_CTX_ACTIVE").is_ok()
58                    || std::env::var("LEAN_CTX_DISABLED").is_ok()
59                {
60                    passthrough(&command);
61                }
62                if raw {
63                    std::env::set_var("LEAN_CTX_RAW", "1");
64                } else {
65                    std::env::set_var("LEAN_CTX_COMPRESS", "1");
66                }
67                let code = shell::exec(&command);
68                core::stats::flush();
69                core::heatmap::flush();
70                core::path_mode_memory::flush();
71                core::auto_mode_resolver::flush_sources();
72                std::process::exit(code);
73            }
74            "-t" | "--track" => {
75                let cmd_args = &args[2..];
76                let code = if cmd_args.len() > 1 {
77                    shell::exec_argv(cmd_args)
78                } else {
79                    let command = cmd_args[0].clone();
80                    if std::env::var("LEAN_CTX_ACTIVE").is_ok()
81                        || std::env::var("LEAN_CTX_DISABLED").is_ok()
82                    {
83                        passthrough(&command);
84                    }
85                    shell::exec(&command)
86                };
87                core::stats::flush();
88                core::heatmap::flush();
89                core::path_mode_memory::flush();
90                core::auto_mode_resolver::flush_sources();
91                std::process::exit(code);
92            }
93            "shell" | "--shell" => {
94                shell::interactive();
95                return;
96            }
97            "gain" => {
98                cmd_gain(&rest);
99                return;
100            }
101            "savings" => {
102                cmd_savings(&rest);
103                return;
104            }
105            "learning" => {
106                cmd_learning(&rest);
107                return;
108            }
109            "conformance" | "selftest" => {
110                cmd_conformance(&rest);
111                return;
112            }
113            "billing" => {
114                cmd_billing(&rest);
115                return;
116            }
117            "finops" => {
118                cmd_finops(&rest);
119                return;
120            }
121            "roi" => {
122                // Local ROI is individual + free. The team roll-up lives on its own
123                // surface (`savings team` / the web account), not under `roi`.
124                super::cmd_roi(&rest);
125                return;
126            }
127            "token-report" | "report-tokens" => {
128                let code = token_report::run_cli(&rest);
129                if code != 0 {
130                    std::process::exit(code);
131                }
132                return;
133            }
134            "pack" => {
135                crate::cli::cmd_pack(&rest);
136                return;
137            }
138            "policy" => {
139                crate::cli::cmd_policy(&rest);
140                return;
141            }
142            "plugin" | "plugins" => {
143                crate::cli::plugin_cmd::cmd_plugin(&rest);
144                return;
145            }
146            "rules" => {
147                crate::cli::rules_cmd::cmd_rules(&rest);
148                return;
149            }
150            "proof" => {
151                crate::cli::cmd_proof(&rest);
152                return;
153            }
154            "verify" => {
155                crate::cli::cmd_verify(&rest);
156                return;
157            }
158            "eval" => {
159                crate::cli::eval_cmd::cmd_eval(&rest);
160                return;
161            }
162            "verify-cache" | "cache-selftest" => {
163                let code = crate::cli::verify_cache_cmd::cmd_verify_cache(&rest);
164                if code != 0 {
165                    std::process::exit(code);
166                }
167                return;
168            }
169            "visualize" => {
170                super::cmd_visualize(&rest);
171                return;
172            }
173            "audit" => {
174                if rest.first().map(String::as_str) == Some("evidence") {
175                    crate::cli::audit_report::cmd_evidence(&rest[1..]);
176                } else {
177                    println!("{}", crate::cli::audit_report::generate_report());
178                }
179                return;
180            }
181            "agent" => {
182                crate::cli::cmd_agent(&rest);
183                return;
184            }
185            "instructions" => {
186                crate::cli::cmd_instructions(&rest);
187                return;
188            }
189            "index" => {
190                crate::cli::cmd_index(&rest);
191                return;
192            }
193            "semantic-search" | "search-code" => {
194                crate::cli::cmd_semantic_search(&rest);
195                core::stats::flush();
196                return;
197            }
198            "repomap" | "repo-map" => {
199                crate::cli::cmd_repomap(&rest);
200                core::stats::flush();
201                return;
202            }
203            "cep" => {
204                println!("{}", core::stats::format_cep_report());
205                return;
206            }
207            "dashboard" => {
208                cmd_dashboard(&rest);
209                return;
210            }
211            "team" => {
212                cmd_team(&rest);
213                return;
214            }
215            "provider" => {
216                cmd_provider(&rest);
217                return;
218            }
219            "serve" => {
220                cmd_serve(&rest);
221                return;
222            }
223            "watch" => {
224                cmd_watch(&rest);
225                return;
226            }
227            "proxy" => {
228                cmd_proxy(&rest);
229                return;
230            }
231            "daemon" => {
232                cmd_daemon(&rest);
233                return;
234            }
235            "init" => {
236                super::cmd_init(&rest);
237                return;
238            }
239            "setup" => {
240                let non_interactive = rest.iter().any(|a| a == "--non-interactive");
241                let yes = rest.iter().any(|a| a == "--yes" || a == "-y");
242                let fix = rest.iter().any(|a| a == "--fix");
243                let json = rest.iter().any(|a| a == "--json");
244                let no_auto_approve = rest.iter().any(|a| a == "--no-auto-approve");
245                let skip_rules = rest.iter().any(|a| a == "--skip-rules");
246
247                if non_interactive || fix || json || yes {
248                    let opts = setup::SetupOptions {
249                        non_interactive,
250                        yes,
251                        fix,
252                        json,
253                        no_auto_approve,
254                        skip_rules,
255                        ..Default::default()
256                    };
257                    match setup::run_setup_with_options(opts) {
258                        Ok(report) => {
259                            if json {
260                                println!(
261                                    "{}",
262                                    serde_json::to_string_pretty(&report)
263                                        .unwrap_or_else(|_| "{}".to_string())
264                                );
265                            }
266                            if !report.success {
267                                std::process::exit(1);
268                            }
269                        }
270                        Err(e) => {
271                            eprintln!("{e}");
272                            std::process::exit(1);
273                        }
274                    }
275                } else {
276                    setup::run_setup();
277                }
278                return;
279            }
280            "onboard" => {
281                setup::run_onboard();
282                return;
283            }
284            "install" => {
285                // Plain `lean-ctx install` is a natural thing to type after
286                // installing the binary — treat it as the guided setup rather
287                // than failing with a usage error. `--repair`/`--fix` keeps the
288                // non-interactive, merge-based repair path.
289                let repair = rest.iter().any(|a| a == "--repair" || a == "--fix");
290                let json = rest.iter().any(|a| a == "--json");
291                if !repair {
292                    setup::run_setup();
293                    return;
294                }
295                let opts = setup::SetupOptions {
296                    non_interactive: true,
297                    yes: true,
298                    fix: true,
299                    json,
300                    ..Default::default()
301                };
302                match setup::run_setup_with_options(opts) {
303                    Ok(report) => {
304                        if json {
305                            println!(
306                                "{}",
307                                serde_json::to_string_pretty(&report)
308                                    .unwrap_or_else(|_| "{}".to_string())
309                            );
310                        }
311                        if !report.success {
312                            std::process::exit(1);
313                        }
314                    }
315                    Err(e) => {
316                        eprintln!("{e}");
317                        std::process::exit(1);
318                    }
319                }
320                return;
321            }
322            "bootstrap" => {
323                let json = rest.iter().any(|a| a == "--json");
324                let opts = setup::SetupOptions {
325                    non_interactive: true,
326                    yes: true,
327                    fix: true,
328                    json,
329                    ..Default::default()
330                };
331                match setup::run_setup_with_options(opts) {
332                    Ok(report) => {
333                        if json {
334                            println!(
335                                "{}",
336                                serde_json::to_string_pretty(&report)
337                                    .unwrap_or_else(|_| "{}".to_string())
338                            );
339                        }
340                        if !report.success {
341                            std::process::exit(1);
342                        }
343                    }
344                    Err(e) => {
345                        eprintln!("{e}");
346                        std::process::exit(1);
347                    }
348                }
349                return;
350            }
351            "status" => {
352                let code = status::run_cli(&rest);
353                if code != 0 {
354                    std::process::exit(code);
355                }
356                return;
357            }
358            "read" => {
359                super::cmd_read(&rest);
360                core::stats::flush();
361                return;
362            }
363            "diff" => {
364                super::cmd_diff(&rest);
365                core::stats::flush();
366                return;
367            }
368            "grep" => {
369                super::cmd_grep(&rest);
370                core::stats::flush();
371                return;
372            }
373            "find" => {
374                super::cmd_find(&rest);
375                core::stats::flush();
376                return;
377            }
378            "ls" => {
379                super::cmd_ls(&rest);
380                core::stats::flush();
381                return;
382            }
383            "deps" => {
384                super::cmd_deps(&rest);
385                core::stats::flush();
386                return;
387            }
388            "discover" => {
389                super::cmd_discover(&rest);
390                return;
391            }
392            "ghost" => {
393                super::cmd_ghost(&rest);
394                return;
395            }
396            "filter" => {
397                super::cmd_filter(&rest);
398                return;
399            }
400            "heatmap" => {
401                heatmap::cmd_heatmap(&rest);
402                return;
403            }
404            "graph" => {
405                cmd_graph(&rest);
406                return;
407            }
408            "smells" => {
409                cmd_smells(&rest);
410                return;
411            }
412            "session" => {
413                super::cmd_session_action(&rest);
414                return;
415            }
416            "ledger" => {
417                super::cmd_ledger(&rest);
418                return;
419            }
420            "control" | "context-control" => {
421                super::cmd_control(&rest);
422                return;
423            }
424            "plan" | "context-plan" => {
425                super::cmd_plan(&rest);
426                return;
427            }
428            "compile" | "context-compile" => {
429                super::cmd_compile(&rest);
430                return;
431            }
432            "knowledge" => {
433                super::cmd_knowledge(&rest);
434                return;
435            }
436            "skillify" => {
437                super::cmd_skillify(&rest);
438                return;
439            }
440            "summary" => {
441                super::cmd_summary(&rest);
442                return;
443            }
444            "overview" => {
445                super::cmd_overview(&rest);
446                return;
447            }
448            "compress" => {
449                super::cmd_compress(&rest);
450                return;
451            }
452            "wrapped" => {
453                eprintln!("'lean-ctx wrapped' has been removed. Use: lean-ctx gain --wrapped");
454                std::process::exit(1);
455            }
456            "sessions" | "session-store" => {
457                super::cmd_sessions(&rest);
458                return;
459            }
460            "benchmark" => {
461                super::cmd_benchmark(&rest);
462                return;
463            }
464            "compact" => {
465                cmd_compact(&rest);
466                return;
467            }
468            "profile" => {
469                super::cmd_profile(&rest);
470                return;
471            }
472            "tools" => {
473                // Canonical, unambiguous entry point for MCP *tool* profiles
474                // (how many tools the agent sees). Disambiguates from
475                // `lean-ctx profile`, which manages *context* profiles.
476                let mut forwarded = vec!["tools".to_string()];
477                forwarded.extend(rest.iter().cloned());
478                super::cmd_profile(&forwarded);
479                return;
480            }
481            "config" => {
482                super::cmd_config(&rest);
483                return;
484            }
485            "allow" => {
486                super::cmd_allow(&rest);
487                return;
488            }
489            "stats" => {
490                super::cmd_stats(&rest);
491                return;
492            }
493            "cache" => {
494                super::cmd_cache(&rest);
495                return;
496            }
497            "theme" => {
498                super::cmd_theme(&rest);
499                return;
500            }
501            "tee" => {
502                super::cmd_tee(&rest);
503                return;
504            }
505            "terse" | "compression" => {
506                super::cmd_compression(&rest);
507                return;
508            }
509            "slow-log" => {
510                super::cmd_slow_log(&rest);
511                return;
512            }
513            // Editor focus ingress (#500): called by the VS Code extension on
514            // tab change; <10ms, no daemon required.
515            "editor-signal" => {
516                let file = rest
517                    .iter()
518                    .position(|a| a == "--file")
519                    .and_then(|i| rest.get(i + 1));
520                if let Some(path) = file {
521                    if let Err(e) = core::editor_signal::record_focus(path) {
522                        eprintln!("editor-signal: {e}");
523                        std::process::exit(1);
524                    }
525                } else {
526                    eprintln!("usage: lean-ctx editor-signal --file <path>");
527                    std::process::exit(2);
528                }
529                return;
530            }
531            "update" | "--self-update" => {
532                core::updater::run(&rest);
533                return;
534            }
535            "restart" => {
536                cmd_restart();
537                return;
538            }
539            "stop" => {
540                cmd_stop();
541                return;
542            }
543            "dev-install" => {
544                cmd_dev_install();
545                return;
546            }
547            "doctor" => {
548                let code = doctor::run_cli(&rest);
549                if code != 0 {
550                    std::process::exit(code);
551                }
552                return;
553            }
554            "harden" => {
555                super::harden::run(&rest);
556                return;
557            }
558            "export-rules" => {
559                super::export_rules::run(&rest);
560                return;
561            }
562            "gotchas" | "bugs" => {
563                super::cloud::cmd_gotchas(&rest);
564                return;
565            }
566            "learn" => {
567                super::cmd_learn(&rest);
568                return;
569            }
570            "buddy" | "pet" => {
571                super::cloud::cmd_buddy(&rest);
572                return;
573            }
574            "hook" => {
575                hook_handlers::mark_hook_environment();
576                // Hooks run inside the agent shell environment, so they can see
577                // runtime/session vars (e.g. CODEX_THREAD_ID) that the long-lived
578                // MCP server process never receives. Bridge them for ctx_shell (#370).
579                core::agent_runtime_env::capture();
580                hook_handlers::arm_watchdog(std::time::Duration::from_secs(5));
581                let action = rest.first().map_or("help", std::string::String::as_str);
582                match action {
583                    "rewrite" => hook_handlers::handle_rewrite(),
584                    "redirect" => hook_handlers::handle_redirect(),
585                    "observe" => hook_handlers::handle_observe(),
586                    "copilot" => hook_handlers::handle_copilot(),
587                    "codex-pretooluse" => hook_handlers::handle_codex_pretooluse(),
588                    "codex-session-start" => hook_handlers::handle_codex_session_start(),
589                    "rewrite-inline" => hook_handlers::handle_rewrite_inline(),
590                    _ => {
591                        eprintln!("Usage: lean-ctx hook <rewrite|redirect|observe|copilot|codex-pretooluse|codex-session-start|rewrite-inline>");
592                        eprintln!("  Internal commands used by agent hooks (Claude, Cursor, Copilot, etc.)");
593                        std::process::exit(1);
594                    }
595                }
596                return;
597            }
598            "report-issue" | "report" => {
599                report::run(&rest);
600                return;
601            }
602            "uninstall" => {
603                let dry_run = rest.iter().any(|a| a == "--dry-run");
604                let keep_config = rest.iter().any(|a| a == "--keep-config");
605                let keep_binary = rest.iter().any(|a| a == "--keep-binary");
606                uninstall::run(dry_run, keep_config, keep_binary);
607                return;
608            }
609            "bypass" => {
610                if rest.is_empty() {
611                    eprintln!("Usage: lean-ctx bypass \"command\"");
612                    eprintln!("Runs the command with zero compression (raw passthrough).");
613                    std::process::exit(1);
614                }
615                let command = if rest.len() == 1 {
616                    rest[0].clone()
617                } else {
618                    shell::join_command(&args[2..])
619                };
620                std::env::set_var("LEAN_CTX_RAW", "1");
621                let code = shell::exec(&command);
622                std::process::exit(code);
623            }
624            "safety-levels" | "safety" => {
625                println!("{}", core::compression_safety::format_safety_table());
626                return;
627            }
628            "cheat" | "cheatsheet" | "cheat-sheet" => {
629                super::cmd_cheatsheet();
630                return;
631            }
632            "login" => {
633                super::cloud::cmd_login(&rest);
634                return;
635            }
636            "register" => {
637                super::cloud::cmd_register(&rest);
638                return;
639            }
640            "forgot-password" => {
641                super::cloud::cmd_forgot_password(&rest);
642                return;
643            }
644            "sync" => {
645                super::cloud::cmd_sync(&rest);
646                return;
647            }
648            "contribute" => {
649                super::cloud::cmd_contribute();
650                return;
651            }
652            "cloud" => {
653                super::cloud::cmd_cloud(&rest);
654                return;
655            }
656            "upgrade" => {
657                super::cloud::cmd_upgrade();
658                return;
659            }
660            "--version" | "-V" => {
661                println!("{}", core::integrity::origin_line());
662                return;
663            }
664            "help" => {
665                let want_all = rest
666                    .iter()
667                    .any(|a| matches!(a.as_str(), "all" | "full" | "--all" | "-a"));
668                if want_all {
669                    print_help();
670                } else {
671                    print_help_concise();
672                }
673                return;
674            }
675            "--help" | "-h" => {
676                if rest
677                    .iter()
678                    .any(|a| matches!(a.as_str(), "all" | "full" | "--all" | "-a"))
679                {
680                    print_help();
681                } else {
682                    print_help_concise();
683                }
684                return;
685            }
686            "mcp" => {}
687            _ => {
688                tracing::error!("lean-ctx: unknown command '{}'", args[1]);
689                print_help_concise();
690                std::process::exit(1);
691            }
692        }
693    }
694
695    // Bare `lean-ctx` in an interactive terminal: a human almost certainly did
696    // not mean to start a silent stdio MCP server (which just hangs waiting for
697    // JSON-RPC). Show a short quickstart instead. MCP clients pipe stdin (not a
698    // TTY) so they still get the server, and explicit `lean-ctx mcp` always
699    // serves regardless of TTY.
700    if args.len() == 1 && std::io::IsTerminal::is_terminal(&std::io::stdin()) {
701        print_quickstart();
702        return;
703    }
704
705    if let Err(e) = run_mcp_server() {
706        tracing::error!("lean-ctx: {e}");
707        std::process::exit(1);
708    }
709}
710
711/// Long-lived server entry points keep Rust's default ignored SIGPIPE: they
712/// must survive peers closing sockets/pipes early. Bare `lean-ctx` counts as
713/// a server because MCP clients spawn the binary without a subcommand.
714fn is_server_mode(args: &[String]) -> bool {
715    args.len() == 1
716        || args.get(1).is_some_and(|a| {
717            matches!(
718                a.as_str(),
719                "mcp" | "daemon" | "proxy" | "serve" | "watch" | "dashboard"
720            )
721        })
722}
723
724/// Restore the default SIGPIPE disposition for short-lived CLI invocations.
725///
726/// Rust's runtime ignores SIGPIPE process-wide, so `lean-ctx doctor | head`
727/// made `println!` panic with BrokenPipe; the LineWriter flush in stdout's
728/// Drop then panicked again *during unwinding*, which aborts — the SIGABRT
729/// (exit 134) of upstream #378 / GL#436. Real CLIs (cat, grep, rg) terminate
730/// silently with exit 141 instead; SIG_DFL gives us exactly that. Children
731/// spawned via std::process::Command are unaffected either way (std resets
732/// their SIGPIPE disposition since Rust 1.65).
733#[cfg(unix)]
734fn restore_sigpipe_default() {
735    // SAFETY: signal(2) with SIG_DFL has no preconditions and is called once
736    // during single-threaded startup, before any I/O.
737    unsafe {
738        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
739    }
740}
741
742#[cfg(not(unix))]
743fn restore_sigpipe_default() {}
744
745fn passthrough(command: &str) -> ! {
746    let (shell, flag) = shell::shell_and_flag();
747    let mut cmd = std::process::Command::new(&shell);
748    cmd.arg(&flag).arg(command).env("LEAN_CTX_ACTIVE", "1");
749    shell::platform::apply_utf8_locale(&mut cmd);
750    let status = cmd.status().map_or(127, |s| s.code().unwrap_or(1));
751    std::process::exit(status);
752}
753
754pub(super) fn run_async<F: std::future::Future>(future: F) -> F::Output {
755    tokio::runtime::Runtime::new()
756        .expect("failed to create async runtime")
757        .block_on(future)
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763    use serial_test::serial;
764
765    fn args_of(parts: &[&str]) -> Vec<String> {
766        parts.iter().map(|s| (*s).to_string()).collect()
767    }
768
769    #[test]
770    fn server_modes_keep_ignored_sigpipe() {
771        for mode in ["mcp", "daemon", "proxy", "serve", "watch", "dashboard"] {
772            assert!(
773                is_server_mode(&args_of(&["lean-ctx", mode])),
774                "{mode} must count as server mode"
775            );
776        }
777        // Bare invocation = MCP server spawned by a client.
778        assert!(is_server_mode(&args_of(&["lean-ctx"])));
779    }
780
781    #[test]
782    fn cli_modes_restore_default_sigpipe() {
783        for mode in ["doctor", "-c", "status", "ls", "grep", "gain", "help"] {
784            assert!(
785                !is_server_mode(&args_of(&["lean-ctx", mode])),
786                "{mode} must count as CLI mode (SIGPIPE default)"
787            );
788        }
789    }
790
791    #[test]
792    fn quickstart_is_short_and_points_to_setup() {
793        let q = quickstart_text();
794        assert!(
795            q.contains("lean-ctx onboard"),
796            "quickstart must point to onboard"
797        );
798        assert!(q.contains("lean-ctx help"), "quickstart must point to help");
799        // Must stay a *quickstart*, not the full reference — keep it tight.
800        assert!(
801            q.lines().count() <= 16,
802            "quickstart should be short; got {} lines",
803            q.lines().count()
804        );
805        assert!(
806            !q.contains("COMMANDS:"),
807            "quickstart must not inline the full command reference"
808        );
809    }
810
811    #[test]
812    fn concise_help_is_short_and_points_to_full() {
813        let h = concise_help_text();
814        assert!(h.contains("lean-ctx onboard"), "must lead with onboard");
815        assert!(
816            h.contains("lean-ctx help all"),
817            "must point to full reference"
818        );
819        assert!(
820            h.contains("lean-ctx tools"),
821            "must surface the tools profile command"
822        );
823        // Concise means concise — keep it well under the full reference.
824        assert!(
825            h.lines().count() <= 40,
826            "concise help should stay short; got {} lines",
827            h.lines().count()
828        );
829        assert!(
830            !h.contains("SHELL HOOK PATTERNS"),
831            "concise help must not inline the full pattern catalog"
832        );
833    }
834
835    #[test]
836    fn capability_banner_tool_count_matches_registry() {
837        let n = crate::server::registry::tool_count();
838        let banner = capability_banner();
839        assert!(
840            banner.contains(&format!("{n} MCP tools")),
841            "banner must show the live registry count ({n}); got: {banner}"
842        );
843    }
844
845    #[test]
846    #[serial]
847    fn worker_threads_default_clamps_low() {
848        std::env::remove_var("LEAN_CTX_WORKER_THREADS");
849        assert_eq!(resolve_worker_threads(1), 1);
850    }
851
852    #[test]
853    #[serial]
854    fn worker_threads_default_clamps_high() {
855        std::env::remove_var("LEAN_CTX_WORKER_THREADS");
856        assert_eq!(resolve_worker_threads(32), 4);
857    }
858
859    #[test]
860    #[serial]
861    fn worker_threads_default_passthrough() {
862        std::env::remove_var("LEAN_CTX_WORKER_THREADS");
863        assert_eq!(resolve_worker_threads(3), 3);
864    }
865
866    #[test]
867    #[serial]
868    fn worker_threads_env_override() {
869        std::env::set_var("LEAN_CTX_WORKER_THREADS", "12");
870        assert_eq!(resolve_worker_threads(2), 12);
871        std::env::remove_var("LEAN_CTX_WORKER_THREADS");
872    }
873
874    #[test]
875    #[serial]
876    fn worker_threads_env_invalid_falls_back() {
877        std::env::set_var("LEAN_CTX_WORKER_THREADS", "not_a_number");
878        assert_eq!(resolve_worker_threads(3), 3);
879        std::env::remove_var("LEAN_CTX_WORKER_THREADS");
880    }
881}