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