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