1pub mod bounded_lock;
2pub mod bypass_hint;
3pub mod compaction_sync;
4pub mod context_gate;
5mod dispatch;
6pub mod dynamic_tools;
7pub mod elicitation;
8pub(crate) mod execute;
9pub mod helpers;
10pub mod multi_path;
11pub mod notifications;
12pub mod progress;
13pub mod prompts;
14pub mod reference_store;
15pub mod registry;
16pub mod resources;
17pub mod role_guard;
18pub mod roots;
19use roots::has_project_marker;
20pub mod tool_trait;
21
22use futures::FutureExt;
23use rmcp::handler::server::ServerHandler;
24use rmcp::model::{
25 CallToolRequestParams, CallToolResult, Content, Implementation, InitializeRequestParams,
26 InitializeResult, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo,
27};
28use rmcp::service::{RequestContext, RoleServer};
29use rmcp::ErrorData;
30
31use crate::tools::{CrpMode, LeanCtxServer};
32mod call_tool;
33mod server_handler;
34
35pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
36 crate::instructions::build_instructions_for_test(crp_mode)
37}
38
39pub fn build_claude_code_instructions_for_test() -> String {
40 crate::instructions::claude_code_instructions()
41}
42
43fn is_home_or_agent_dir(dir: &std::path::Path) -> bool {
44 if let Some(home) = dirs::home_dir() {
45 if dir == home {
46 return true;
47 }
48 }
49 let dir_str = dir.to_string_lossy();
50 dir_str.ends_with("/.claude")
51 || dir_str.ends_with("/.codex")
52 || dir_str.contains("/.claude/")
53 || dir_str.contains("/.codex/")
54}
55
56fn git_toplevel_from(dir: &std::path::Path) -> Option<String> {
57 std::process::Command::new("git")
58 .args(["rev-parse", "--show-toplevel"])
59 .current_dir(dir)
60 .stdout(std::process::Stdio::piped())
61 .stderr(std::process::Stdio::null())
62 .output()
63 .ok()
64 .and_then(|o| {
65 if o.status.success() {
66 String::from_utf8(o.stdout)
67 .ok()
68 .map(|s| s.trim().to_string())
69 } else {
70 None
71 }
72 })
73}
74
75pub fn derive_project_root_from_cwd() -> Option<String> {
76 let cwd = std::env::current_dir().ok()?;
77 let canonical = crate::core::pathutil::safe_canonicalize_or_self(&cwd);
78
79 if is_home_or_agent_dir(&canonical) {
80 return git_toplevel_from(&canonical);
81 }
82
83 if has_project_marker(&canonical) {
84 return Some(canonical.to_string_lossy().to_string());
85 }
86
87 if let Some(git_root) = git_toplevel_from(&canonical) {
88 return Some(git_root);
89 }
90
91 if let Some(root) = detect_multi_root_workspace(&canonical) {
92 return Some(root);
93 }
94
95 if !crate::core::pathutil::is_broad_or_unsafe_root(&canonical) {
99 tracing::info!(
100 "No project markers found — using CWD as project root: {}",
101 canonical.display()
102 );
103 return Some(canonical.to_string_lossy().to_string());
104 }
105
106 None
107}
108
109#[cfg(test)]
111use crate::core::pathutil::is_broad_or_unsafe_root;
112
113fn detect_multi_root_workspace(dir: &std::path::Path) -> Option<String> {
117 let entries = std::fs::read_dir(dir).ok()?;
118 let mut child_projects: Vec<String> = Vec::new();
119
120 for entry in entries.flatten() {
121 let path = entry.path();
122 if path.is_dir() && has_project_marker(&path) {
123 let canonical = crate::core::pathutil::safe_canonicalize_or_self(&path);
124 child_projects.push(canonical.to_string_lossy().to_string());
125 }
126 }
127
128 if child_projects.len() >= 2 {
129 let existing = std::env::var("LEAN_CTX_ALLOW_PATH").unwrap_or_default();
130 let sep = if cfg!(windows) { ";" } else { ":" };
131 let merged = if existing.is_empty() {
132 child_projects.join(sep)
133 } else {
134 format!("{existing}{sep}{}", child_projects.join(sep))
135 };
136 std::env::set_var("LEAN_CTX_ALLOW_PATH", &merged);
137 tracing::info!(
138 "Multi-root workspace detected at {}: auto-allowing {} child projects",
139 dir.display(),
140 child_projects.len()
141 );
142 return Some(dir.to_string_lossy().to_string());
143 }
144
145 None
146}
147
148pub fn tool_descriptions_for_test() -> Vec<(&'static str, &'static str)> {
149 crate::tool_defs::list_all_tool_defs()
150 .into_iter()
151 .map(|(name, desc, _)| (name, desc))
152 .collect()
153}
154
155pub fn tool_schemas_json_for_test() -> String {
156 crate::tool_defs::list_all_tool_defs()
157 .iter()
158 .map(|(name, _, schema)| format!("{name}: {schema}"))
159 .collect::<Vec<_>>()
160 .join("\n")
161}
162
163pub const WORKFLOW_PASSTHROUGH_TOOLS: &[&str] = &[
167 "ctx",
168 "ctx_workflow",
169 "ctx_read",
170 "ctx_multi_read",
171 "ctx_smart_read",
172 "ctx_search",
173 "ctx_tree",
174 "ctx_session",
175 "ctx_ledger",
176];
177
178pub fn is_workflow_stale(run: &crate::core::workflow::types::WorkflowRun) -> bool {
181 let elapsed = chrono::Utc::now()
182 .signed_duration_since(run.updated_at)
183 .num_minutes();
184 elapsed > 30
185}
186
187fn is_shell_tool_name(name: &str) -> bool {
188 matches!(name, "ctx_shell" | "ctx_execute")
189}
190
191fn extract_file_read_from_shell(cmd: &str) -> Option<String> {
192 let trimmed = cmd.trim();
193 let parts: Vec<&str> = trimmed.split_whitespace().collect();
194 if parts.len() < 2 {
195 return None;
196 }
197 let bin = parts[0].rsplit('/').next().unwrap_or(parts[0]);
198 match bin {
199 "cat" | "head" | "tail" | "less" | "more" | "bat" | "batcat" => {
200 let file_arg = parts.iter().skip(1).find(|a| !a.starts_with('-'))?;
201 Some(file_arg.to_string())
202 }
203 _ => None,
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn project_markers_detected() {
213 let tmp = tempfile::tempdir().unwrap();
214 let root = tmp.path().join("myproject");
215 std::fs::create_dir_all(&root).unwrap();
216 assert!(!has_project_marker(&root));
217
218 std::fs::create_dir(root.join(".git")).unwrap();
219 assert!(has_project_marker(&root));
220 }
221
222 #[test]
223 fn home_dir_detected_as_agent_dir() {
224 if let Some(home) = dirs::home_dir() {
225 assert!(is_home_or_agent_dir(&home));
226 }
227 }
228
229 #[test]
230 fn agent_dirs_detected() {
231 let claude = std::path::PathBuf::from("/home/user/.claude");
232 assert!(is_home_or_agent_dir(&claude));
233 let codex = std::path::PathBuf::from("/home/user/.codex");
234 assert!(is_home_or_agent_dir(&codex));
235 let project = std::path::PathBuf::from("/home/user/projects/myapp");
236 assert!(!is_home_or_agent_dir(&project));
237 }
238
239 #[test]
240 fn test_unified_tool_count() {
241 let tools = crate::tool_defs::unified_tool_defs();
242 assert_eq!(tools.len(), 5, "Expected 5 unified tools");
243 }
244
245 #[test]
246 fn test_granular_tool_count() {
247 let tools = crate::tool_defs::granular_tool_defs();
248 assert!(tools.len() >= 25, "Expected at least 25 granular tools");
249 }
250
251 #[test]
252 fn test_registry_tool_count_ssot() {
253 let registry = crate::server::registry::build_registry();
254 assert_eq!(
255 registry.len(),
256 68,
257 "Registry tool count drift! Update this test AND all docs when adding/removing tools."
258 );
259 }
260
261 #[test]
262 fn disabled_tools_filters_list() {
263 let all = crate::tool_defs::granular_tool_defs();
264 let total = all.len();
265 let disabled = ["ctx_graph".to_string(), "ctx_agent".to_string()];
266 let filtered: Vec<_> = all
267 .into_iter()
268 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
269 .collect();
270 assert_eq!(filtered.len(), total - 2);
271 assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
272 assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
273 }
274
275 #[test]
276 fn empty_disabled_tools_returns_all() {
277 let all = crate::tool_defs::granular_tool_defs();
278 let total = all.len();
279 let disabled: Vec<String> = vec![];
280 let filtered: Vec<_> = all
281 .into_iter()
282 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
283 .collect();
284 assert_eq!(filtered.len(), total);
285 }
286
287 #[test]
288 fn misspelled_disabled_tool_is_silently_ignored() {
289 let all = crate::tool_defs::granular_tool_defs();
290 let total = all.len();
291 let disabled = ["ctx_nonexistent_tool".to_string()];
292 let filtered: Vec<_> = all
293 .into_iter()
294 .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
295 .collect();
296 assert_eq!(filtered.len(), total);
297 }
298
299 #[test]
300 fn detect_multi_root_workspace_with_child_projects() {
301 let tmp = tempfile::tempdir().unwrap();
302 let workspace = tmp.path().join("workspace");
303 std::fs::create_dir_all(&workspace).unwrap();
304
305 let proj_a = workspace.join("project-a");
306 let proj_b = workspace.join("project-b");
307 std::fs::create_dir_all(proj_a.join(".git")).unwrap();
308 std::fs::create_dir_all(&proj_b).unwrap();
309 std::fs::write(proj_b.join("package.json"), "{}").unwrap();
310
311 let result = detect_multi_root_workspace(&workspace);
312 assert!(
313 result.is_some(),
314 "should detect workspace with 2 child projects"
315 );
316
317 std::env::remove_var("LEAN_CTX_ALLOW_PATH");
318 }
319
320 #[test]
321 fn detect_multi_root_workspace_returns_none_for_single_project() {
322 let tmp = tempfile::tempdir().unwrap();
323 let workspace = tmp.path().join("workspace");
324 std::fs::create_dir_all(&workspace).unwrap();
325
326 let proj_a = workspace.join("project-a");
327 std::fs::create_dir_all(proj_a.join(".git")).unwrap();
328
329 let result = detect_multi_root_workspace(&workspace);
330 assert!(
331 result.is_none(),
332 "should not detect workspace with only 1 child project"
333 );
334 }
335
336 #[test]
337 fn is_broad_or_unsafe_root_rejects_home() {
338 if let Some(home) = dirs::home_dir() {
339 assert!(is_broad_or_unsafe_root(&home));
340 }
341 }
342
343 #[test]
344 fn is_broad_or_unsafe_root_rejects_filesystem_root() {
345 assert!(is_broad_or_unsafe_root(std::path::Path::new("/")));
346 }
347
348 #[test]
349 fn is_broad_or_unsafe_root_rejects_agent_dirs() {
350 assert!(is_broad_or_unsafe_root(std::path::Path::new(
351 "/home/user/.claude"
352 )));
353 assert!(is_broad_or_unsafe_root(std::path::Path::new(
354 "/home/user/.codex"
355 )));
356 }
357
358 #[test]
359 fn is_broad_or_unsafe_root_allows_project_subdir() {
360 let tmp = tempfile::tempdir().unwrap();
361 let subdir = tmp.path().join("my-project");
362 std::fs::create_dir_all(&subdir).unwrap();
363 assert!(!is_broad_or_unsafe_root(&subdir));
364 }
365
366 #[test]
367 fn is_broad_or_unsafe_root_allows_tmp_subdirs() {
368 assert!(!is_broad_or_unsafe_root(std::path::Path::new(
369 "/tmp/leanctx-test"
370 )));
371 assert!(!is_broad_or_unsafe_root(std::path::Path::new(
372 "/tmp/my-project"
373 )));
374 }
375
376 #[test]
377 fn is_broad_or_unsafe_root_allows_home_subdirs() {
378 if let Some(home) = dirs::home_dir() {
379 let subdir = home.join("projects").join("my-app");
380 assert!(!is_broad_or_unsafe_root(&subdir));
381 }
382 }
383
384 #[test]
385 fn derive_project_root_falls_back_to_bare_cwd() {
386 let tmp = tempfile::tempdir().unwrap();
387 let bare = tmp.path().join("bare-dir");
388 std::fs::create_dir_all(&bare).unwrap();
389
390 let original = std::env::current_dir().unwrap();
391 std::env::set_current_dir(&bare).unwrap();
392 let result = derive_project_root_from_cwd();
393 std::env::set_current_dir(original).unwrap();
394
395 assert!(result.is_some(), "bare dir should produce a project root");
396 let root = result.unwrap();
397 assert!(
398 root.contains("bare-dir"),
399 "fallback should use the bare dir path"
400 );
401 }
402}