Skip to main content

lean_ctx/tools/
mod.rs

1pub mod autonomy;
2pub mod ctx_agent;
3pub mod ctx_analyze;
4pub mod ctx_architecture;
5pub mod ctx_artifacts;
6pub mod ctx_benchmark;
7pub mod ctx_callgraph;
8pub mod ctx_compile;
9pub mod ctx_compose;
10pub mod ctx_compress;
11pub mod ctx_compress_memory;
12pub mod ctx_context;
13pub mod ctx_control;
14pub mod ctx_cost;
15pub mod ctx_dedup;
16pub mod ctx_delta;
17pub mod ctx_discover;
18pub mod ctx_edit;
19pub mod ctx_execute;
20pub mod ctx_expand;
21pub mod ctx_explore;
22pub mod ctx_feedback;
23pub mod ctx_fill;
24pub mod ctx_gain;
25pub mod ctx_glob;
26pub mod ctx_graph;
27pub mod ctx_graph_diagram;
28pub mod ctx_graph_diff;
29pub mod ctx_graph_primitives;
30pub mod ctx_handoff;
31pub mod ctx_heatmap;
32pub mod ctx_impact;
33pub mod ctx_index;
34pub mod ctx_intent;
35pub mod ctx_knowledge;
36pub mod ctx_knowledge_relations;
37pub mod ctx_metrics;
38pub mod ctx_multi_read;
39pub mod ctx_multi_repo;
40pub mod ctx_outline;
41pub mod ctx_overview;
42pub mod ctx_pack;
43pub mod ctx_package;
44pub mod ctx_patch;
45pub mod ctx_plan;
46pub mod ctx_plugins;
47pub mod ctx_prefetch;
48pub mod ctx_preload;
49pub mod ctx_proof;
50pub mod ctx_provider;
51pub mod ctx_quality;
52pub mod ctx_read;
53pub mod ctx_refactor;
54pub mod ctx_repomap;
55pub mod ctx_response;
56pub mod ctx_review;
57pub mod ctx_routes;
58pub mod ctx_rules;
59pub mod ctx_search;
60pub mod ctx_semantic_search;
61pub mod ctx_session;
62pub mod ctx_share;
63pub mod ctx_shell;
64pub mod ctx_skillify;
65pub mod ctx_smart_read;
66pub mod ctx_smells;
67pub mod ctx_summary;
68pub mod ctx_symbol;
69pub mod ctx_task;
70pub mod ctx_tools;
71pub mod ctx_transcript_compact;
72pub mod ctx_tree;
73pub mod ctx_verify;
74pub mod ctx_workflow;
75pub(crate) mod edit_io;
76pub(crate) mod edit_recovery;
77pub(crate) mod graph_meta;
78pub(crate) mod knowledge_shared;
79pub(crate) mod output_format;
80pub mod registered;
81pub mod search_hook;
82pub mod search_kernel;
83pub(crate) mod walk_guard;
84
85mod server;
86mod server_lifecycle;
87mod server_metrics;
88mod server_paths;
89pub(crate) mod startup;
90
91pub use server::*;
92pub use startup::create_server;
93
94#[cfg(test)]
95mod resolve_path_tests {
96    use super::startup::canonicalize_path;
97    use super::*;
98
99    fn create_git_root(path: &std::path::Path) -> String {
100        std::fs::create_dir_all(path.join(".git")).unwrap();
101        canonicalize_path(path)
102    }
103
104    #[cfg(not(feature = "no-jail"))]
105    #[tokio::test]
106    #[allow(clippy::await_holding_lock)]
107    async fn resolve_path_can_reroot_to_trusted_startup_root_when_session_root_is_stale() {
108        // #991: serialize via `isolated_data_dir` (holds `test_env_lock`) so the
109        // `LEAN_CTX_ALLOW_REROOT` set below cannot be removed by a sibling
110        // reroot test running in parallel (e.g. the `remove_var` in
111        // `..._without_opt_in`) between set and `resolve_path` — which would
112        // silently disable rerooting and flake this assertion. Cleaned up at the
113        // end so the opt-in never leaks to other tests.
114        let _iso = crate::core::data_dir::isolated_data_dir();
115        crate::test_env::set_var("LEAN_CTX_ALLOW_REROOT", "1");
116        let tmp = tempfile::tempdir().unwrap();
117        let stale = tmp.path().join("stale");
118        let real = tmp.path().join("real");
119        std::fs::create_dir_all(&stale).unwrap();
120        let real_root = create_git_root(&real);
121        std::fs::write(real.join("a.txt"), "ok").unwrap();
122
123        let server = LeanCtxServer::new_with_startup(
124            None,
125            Some(real.as_path()),
126            SessionMode::Personal,
127            "default",
128            "default",
129        );
130        {
131            let mut session = server.session.write().await;
132            session.project_root = Some(stale.to_string_lossy().to_string());
133            session.shell_cwd = Some(stale.to_string_lossy().to_string());
134        }
135
136        let out = server
137            .resolve_path(&real.join("a.txt").to_string_lossy())
138            .await
139            .unwrap();
140
141        assert!(out.ends_with("/a.txt"));
142
143        let session = server.session.read().await;
144        assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
145        assert_eq!(session.shell_cwd.as_deref(), Some(real_root.as_str()));
146        drop(session);
147        crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
148    }
149
150    #[cfg(not(feature = "no-jail"))]
151    #[tokio::test]
152    #[allow(clippy::await_holding_lock)]
153    async fn resolve_path_rejects_absolute_path_outside_trusted_startup_root() {
154        // Hermetic config + serialized via test_env_lock so a parallel test that
155        // flips `path_jail` cannot disable this jail-enforcement assertion (#406).
156        let _iso = crate::core::data_dir::isolated_data_dir();
157        let tmp = tempfile::tempdir().unwrap();
158        let stale = tmp.path().join("stale");
159        let root = tmp.path().join("root");
160        let other = tmp.path().join("other");
161        std::fs::create_dir_all(&stale).unwrap();
162        create_git_root(&root);
163        let _other_value = create_git_root(&other);
164        std::fs::write(other.join("b.txt"), "no").unwrap();
165
166        let server = LeanCtxServer::new_with_startup(
167            None,
168            Some(root.as_path()),
169            SessionMode::Personal,
170            "default",
171            "default",
172        );
173        {
174            let mut session = server.session.write().await;
175            session.project_root = Some(stale.to_string_lossy().to_string());
176            session.shell_cwd = Some(stale.to_string_lossy().to_string());
177        }
178
179        let err = server
180            .resolve_path(&other.join("b.txt").to_string_lossy())
181            .await
182            .unwrap_err();
183        assert!(err.contains("path escapes project root"));
184
185        let session = server.session.read().await;
186        assert_eq!(
187            session.project_root.as_deref(),
188            Some(stale.to_string_lossy().as_ref())
189        );
190    }
191
192    #[cfg(not(feature = "no-jail"))]
193    #[tokio::test]
194    #[allow(clippy::await_holding_lock)]
195    async fn resolve_path_auto_registers_language_cache_then_retry_succeeds() {
196        // #899: reading dependency source in a language cache (here a Go module
197        // cache outside the project) fails closed once with an auto-detect hint,
198        // then resolves on retry — no config edit, no subprocess.
199        let _iso = crate::core::data_dir::isolated_data_dir();
200        let tmp = tempfile::tempdir().unwrap();
201        let root = tmp.path().join("project");
202        create_git_root(&root);
203        let dep = tmp.path().join("go/pkg/mod/example.com/lib@v1");
204        std::fs::create_dir_all(&dep).unwrap();
205        let file = dep.join("lib.go");
206        std::fs::write(&file, "package lib").unwrap();
207
208        let server = LeanCtxServer::new_with_startup(
209            None,
210            Some(root.as_path()),
211            SessionMode::Personal,
212            "default",
213            "default",
214        );
215        {
216            let mut session = server.session.write().await;
217            session.project_root = Some(root.to_string_lossy().to_string());
218            session.shell_cwd = Some(root.to_string_lossy().to_string());
219        }
220
221        // First read: fail-closed, with the targeted retry hint.
222        let err = server
223            .resolve_path(&file.to_string_lossy())
224            .await
225            .unwrap_err();
226        assert!(
227            err.contains("Auto-detected Go module cache"),
228            "expected auto-detect hint, got: {err}"
229        );
230        assert!(err.contains("Retry"), "hint must ask for a retry: {err}");
231
232        // Retry: the cache is now a session read-only root, so the read resolves.
233        let ok = server
234            .resolve_path(&file.to_string_lossy())
235            .await
236            .unwrap_or_else(|e| panic!("retry must resolve, got: {e}"));
237        assert!(
238            ok.ends_with("/lib.go"),
239            "retry resolves the cache file: {ok}"
240        );
241    }
242
243    #[cfg(not(feature = "no-jail"))]
244    #[tokio::test]
245    #[allow(clippy::await_holding_lock)]
246    async fn resolve_path_auto_reroots_from_agent_config_dir_without_opt_in() {
247        // #580: the MCP server is launched from an agent/IDE config dir
248        // (~/.copilot-style) and wrongly adopts it as the root. With no
249        // `allow_auto_reroot` opt-in and no trusted startup root, the first
250        // absolute path into a real project must still correct the root — an
251        // agent config dir is never a real jail boundary.
252        let _iso = crate::core::data_dir::isolated_data_dir();
253        crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
254        let tmp = tempfile::tempdir().unwrap();
255        let agent = tmp.path().join(".copilot");
256        let real = tmp.path().join("repo");
257        std::fs::create_dir_all(&agent).unwrap();
258        let real_root = create_git_root(&real);
259        std::fs::write(real.join("a.txt"), "ok").unwrap();
260
261        let server = LeanCtxServer::new_with_startup(
262            None,
263            None,
264            SessionMode::Personal,
265            "default",
266            "default",
267        );
268        {
269            let mut session = server.session.write().await;
270            session.project_root = Some(agent.to_string_lossy().to_string());
271            session.shell_cwd = Some(agent.to_string_lossy().to_string());
272        }
273
274        let out = server
275            .resolve_path(&real.join("a.txt").to_string_lossy())
276            .await
277            .unwrap();
278        assert!(
279            out.ends_with("/a.txt"),
280            "agent-dir jail must auto-correct: {out}"
281        );
282
283        let session = server.session.read().await;
284        assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
285    }
286
287    #[cfg(not(feature = "no-jail"))]
288    #[tokio::test]
289    #[allow(clippy::await_holding_lock)]
290    async fn resolve_path_auto_reroots_from_markerless_client_cwd_without_opt_in() {
291        // VS Code/WSL can launch the MCP server from /mnt/c/Users while the
292        // workspace lives under /mnt/d. That markerless cwd must not become a
293        // permanent PathJail root when the request points at a real project.
294        let _iso = crate::core::data_dir::isolated_data_dir();
295        crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
296        let tmp = tempfile::tempdir().unwrap();
297        let client_cwd = tmp.path().join("Users").join("user");
298        let real = tmp.path().join("workspaces").join("lean-ctx");
299        std::fs::create_dir_all(&client_cwd).unwrap();
300        let real_root = create_git_root(&real);
301        std::fs::write(real.join("rust.rs"), "ok").unwrap();
302
303        let server = LeanCtxServer::new_with_startup(
304            None,
305            None,
306            SessionMode::Personal,
307            "default",
308            "default",
309        );
310        {
311            let mut session = server.session.write().await;
312            session.project_root = Some(client_cwd.to_string_lossy().to_string());
313            session.shell_cwd = Some(client_cwd.to_string_lossy().to_string());
314        }
315
316        let out = server
317            .resolve_path(&real.join("rust.rs").to_string_lossy())
318            .await
319            .unwrap();
320        assert!(
321            out.ends_with("/rust.rs"),
322            "markerless client cwd must auto-correct: {out}"
323        );
324
325        let session = server.session.read().await;
326        assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
327    }
328
329    #[cfg(not(feature = "no-jail"))]
330    #[tokio::test]
331    #[allow(clippy::await_holding_lock)]
332    async fn resolve_path_auto_reroots_even_when_extra_root_allows_path() {
333        let _iso = crate::core::data_dir::isolated_data_dir();
334        crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
335        let tmp = tempfile::tempdir().unwrap();
336        let client_cwd = tmp.path().join("Users").join("user");
337        let real = tmp.path().join("workspaces").join("lean-ctx");
338        std::fs::create_dir_all(&client_cwd).unwrap();
339        let real_root = create_git_root(&real);
340        std::fs::write(real.join("rust.rs"), "ok").unwrap();
341
342        let server = LeanCtxServer::new_with_startup(
343            None,
344            None,
345            SessionMode::Personal,
346            "default",
347            "default",
348        );
349        {
350            let mut session = server.session.write().await;
351            session.project_root = Some(client_cwd.to_string_lossy().to_string());
352            session.shell_cwd = Some(client_cwd.to_string_lossy().to_string());
353            session.extra_roots.push(real_root.clone());
354        }
355
356        let out = server
357            .resolve_path(&real.join("rust.rs").to_string_lossy())
358            .await
359            .unwrap();
360        assert!(
361            out.ends_with("/rust.rs"),
362            "extra root must not suppress markerless-cwd reroot: {out}"
363        );
364
365        let session = server.session.read().await;
366        assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
367        assert_eq!(session.shell_cwd.as_deref(), Some(real_root.as_str()));
368    }
369
370    #[cfg(not(feature = "no-jail"))]
371    #[tokio::test]
372    #[allow(clippy::await_holding_lock)]
373    async fn resolve_path_markerless_root_still_blocks_markerless_escape() {
374        // #649 must not weaken PathJail: from a markerless client cwd, an absolute
375        // path that derives NO project marker stays blocked and the root is
376        // unchanged. Only rerooting *to a real project* is permitted.
377        let _iso = crate::core::data_dir::isolated_data_dir();
378        crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
379        let tmp = tempfile::tempdir().unwrap();
380        let client_cwd = tmp.path().join("Users").join("user");
381        let loose = tmp.path().join("workspaces").join("loose");
382        std::fs::create_dir_all(&client_cwd).unwrap();
383        std::fs::create_dir_all(&loose).unwrap();
384        std::fs::write(loose.join("data.txt"), "no").unwrap();
385
386        let server = LeanCtxServer::new_with_startup(
387            None,
388            None,
389            SessionMode::Personal,
390            "default",
391            "default",
392        );
393        {
394            let mut session = server.session.write().await;
395            session.project_root = Some(client_cwd.to_string_lossy().to_string());
396            session.shell_cwd = Some(client_cwd.to_string_lossy().to_string());
397        }
398
399        let err = server
400            .resolve_path(&loose.join("data.txt").to_string_lossy())
401            .await
402            .unwrap_err();
403        assert!(err.contains("path escapes project root"), "got: {err}");
404
405        let session = server.session.read().await;
406        assert_eq!(
407            session.project_root.as_deref(),
408            Some(client_cwd.to_string_lossy().as_ref())
409        );
410    }
411
412    #[cfg(not(feature = "no-jail"))]
413    #[tokio::test]
414    #[allow(clippy::await_holding_lock)]
415    async fn resolve_path_agent_dir_still_blocks_markerless_escape() {
416        // The agent-dir bypass only reroots to a *real* project (one carrying a
417        // marker). A markerless absolute path outside the jail stays blocked —
418        // PathJail enforcement is unchanged, only the root *choice* is corrected.
419        let _iso = crate::core::data_dir::isolated_data_dir();
420        crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
421        let tmp = tempfile::tempdir().unwrap();
422        let agent = tmp.path().join(".copilot");
423        let loose = tmp.path().join("loose");
424        std::fs::create_dir_all(&agent).unwrap();
425        std::fs::create_dir_all(&loose).unwrap();
426        std::fs::write(loose.join("data.txt"), "no").unwrap();
427
428        let server = LeanCtxServer::new_with_startup(
429            None,
430            None,
431            SessionMode::Personal,
432            "default",
433            "default",
434        );
435        {
436            let mut session = server.session.write().await;
437            session.project_root = Some(agent.to_string_lossy().to_string());
438            session.shell_cwd = Some(agent.to_string_lossy().to_string());
439        }
440
441        let err = server
442            .resolve_path(&loose.join("data.txt").to_string_lossy())
443            .await
444            .unwrap_err();
445        assert!(err.contains("path escapes project root"), "got: {err}");
446
447        let session = server.session.read().await;
448        assert_eq!(
449            session.project_root.as_deref(),
450            Some(agent.to_string_lossy().as_ref())
451        );
452    }
453
454    #[tokio::test]
455    #[allow(clippy::await_holding_lock)]
456    async fn startup_prefers_workspace_scoped_session_over_global_latest() {
457        let _lock = crate::core::data_dir::test_env_lock();
458        let _data = tempfile::tempdir().unwrap();
459        let _tmp = tempfile::tempdir().unwrap();
460
461        crate::test_env::set_var("LEAN_CTX_DATA_DIR", _data.path());
462
463        let repo_a = _tmp.path().join("repo-a");
464        let repo_b = _tmp.path().join("repo-b");
465        let root_a = create_git_root(&repo_a);
466        let root_b = create_git_root(&repo_b);
467
468        let mut session_b = crate::core::session::SessionState::new();
469        session_b.project_root = Some(root_b.clone());
470        session_b.shell_cwd = Some(root_b.clone());
471        session_b.set_task("repo-b task", None);
472        session_b.save().unwrap();
473
474        std::thread::sleep(std::time::Duration::from_millis(50));
475
476        let mut session_a = crate::core::session::SessionState::new();
477        session_a.project_root = Some(root_a.clone());
478        session_a.shell_cwd = Some(root_a.clone());
479        session_a.set_task("repo-a latest task", None);
480        session_a.save().unwrap();
481
482        let server = LeanCtxServer::new_with_startup(
483            None,
484            Some(repo_b.as_path()),
485            SessionMode::Personal,
486            "default",
487            "default",
488        );
489        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
490
491        let session = server.session.read().await;
492        assert_eq!(session.project_root.as_deref(), Some(root_b.as_str()));
493        assert_eq!(session.shell_cwd.as_deref(), Some(root_b.as_str()));
494        assert_eq!(
495            session.task.as_ref().map(|t| t.description.as_str()),
496            Some("repo-b task")
497        );
498    }
499
500    #[tokio::test]
501    #[allow(clippy::await_holding_lock)]
502    async fn startup_creates_fresh_session_for_new_workspace_and_preserves_subdir_cwd() {
503        let _lock = crate::core::data_dir::test_env_lock();
504        let _data = tempfile::tempdir().unwrap();
505        let _tmp = tempfile::tempdir().unwrap();
506
507        crate::test_env::set_var("LEAN_CTX_DATA_DIR", _data.path());
508
509        let repo_a = _tmp.path().join("repo-a");
510        let repo_b = _tmp.path().join("repo-b");
511        let repo_b_src = repo_b.join("src");
512        let root_a = create_git_root(&repo_a);
513        let root_b = create_git_root(&repo_b);
514        std::fs::create_dir_all(&repo_b_src).unwrap();
515        let repo_b_src_value = canonicalize_path(&repo_b_src);
516
517        let mut session_a = crate::core::session::SessionState::new();
518        session_a.project_root = Some(root_a.clone());
519        session_a.shell_cwd = Some(root_a.clone());
520        session_a.set_task("repo-a latest task", None);
521        let old_id = session_a.id.clone();
522        session_a.save().unwrap();
523
524        let server = LeanCtxServer::new_with_startup(
525            None,
526            Some(repo_b_src.as_path()),
527            SessionMode::Personal,
528            "default",
529            "default",
530        );
531        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
532
533        let session = server.session.read().await;
534        assert_eq!(session.project_root.as_deref(), Some(root_b.as_str()));
535        assert_eq!(
536            session.shell_cwd.as_deref(),
537            Some(repo_b_src_value.as_str())
538        );
539        assert!(session.task.is_none());
540        assert_ne!(session.id, old_id);
541    }
542
543    #[cfg(not(feature = "no-jail"))]
544    #[tokio::test]
545    #[allow(clippy::await_holding_lock)]
546    async fn resolve_path_does_not_auto_update_when_current_root_is_real_project() {
547        // Hermetic config + serialized via test_env_lock so a parallel test that
548        // flips `path_jail` cannot disable this jail-enforcement assertion (#406).
549        let _iso = crate::core::data_dir::isolated_data_dir();
550        let tmp = tempfile::tempdir().unwrap();
551        let root = tmp.path().join("root");
552        let other = tmp.path().join("other");
553        let root_value = create_git_root(&root);
554        create_git_root(&other);
555        std::fs::write(other.join("b.txt"), "no").unwrap();
556
557        let root_str = root.to_string_lossy().to_string();
558        let server = LeanCtxServer::new_with_project_root(Some(&root_str));
559
560        let err = server
561            .resolve_path(&other.join("b.txt").to_string_lossy())
562            .await
563            .unwrap_err();
564        assert!(err.contains("path escapes project root"));
565
566        let session = server.session.read().await;
567        assert_eq!(session.project_root.as_deref(), Some(root_value.as_str()));
568    }
569
570    // #707: a mid-session worktree switch moves shell_cwd into a nested linked
571    // worktree (own `.git` *file*) while project_root stays at initialize-time.
572    // The path `src/lib.rs` deliberately also exists relative to the test
573    // process CWD (`rust/`): the server's `p.exists()` probe used to
574    // short-circuit on exactly that and serve the stale root copy before the
575    // divergent-checkout precedence could apply.
576    #[tokio::test]
577    #[allow(clippy::await_holding_lock)]
578    async fn resolve_path_prefers_worktree_copy_after_mid_session_switch() {
579        let _iso = crate::core::data_dir::isolated_data_dir();
580        let tmp = tempfile::tempdir().unwrap();
581        let repo = tmp.path().join("repo");
582        let root_value = create_git_root(&repo);
583        let wt = repo.join(".claude/worktrees/wt");
584        std::fs::create_dir_all(wt.join("src")).unwrap();
585        std::fs::write(wt.join(".git"), "gitdir: ../../../.git/worktrees/wt\n").unwrap();
586        std::fs::create_dir_all(repo.join("src")).unwrap();
587        std::fs::write(repo.join("src/lib.rs"), "stale").unwrap();
588        std::fs::write(wt.join("src/lib.rs"), "fresh").unwrap();
589
590        let server = LeanCtxServer::new_with_project_root(Some(&root_value));
591        {
592            // The worktree switch as ctx_shell's cwd tracking records it.
593            let mut session = server.session.write().await;
594            session.shell_cwd = Some(wt.to_string_lossy().to_string());
595        }
596
597        let out = server.resolve_path("src/lib.rs").await.unwrap();
598        assert_eq!(
599            std::fs::read_to_string(&out).unwrap(),
600            "fresh",
601            "worktree copy must win over the stale project_root copy: {out}"
602        );
603
604        // Switching back restores the established precedence: without
605        // divergence the project_root fallback serves the root copy again.
606        // Probed via a path that does NOT exist relative to the test process
607        // CWD — `src/lib.rs` would hit the (intended) `p.exists()` fast path
608        // and resolve to the real checkout's file, making the assertion
609        // environment-dependent (that is exactly how CI diverged from local).
610        {
611            let mut session = server.session.write().await;
612            session.shell_cwd = Some(root_value.clone());
613        }
614        std::fs::write(repo.join("src/back_707.rs"), "stale").unwrap();
615        std::fs::write(wt.join("src/back_707.rs"), "fresh").unwrap();
616        let out = server.resolve_path("src/back_707.rs").await.unwrap();
617        assert_eq!(std::fs::read_to_string(&out).unwrap(), "stale");
618    }
619}