Skip to main content

lean_ctx/mcp_server/
mod.rs

1mod dispatch;
2mod execute;
3pub mod helpers;
4
5use std::collections::BTreeMap;
6use std::sync::Arc;
7
8use rmcp::handler::server::ServerHandler;
9use rmcp::model::*;
10use rmcp::service::{RequestContext, RoleServer};
11use rmcp::ErrorData;
12
13use crate::tools::{CrpMode, LeanCtxServer};
14
15/// Tools that ONLY exist on the cloud server. No local fallback — return an error when offline.
16const CLOUD_ONLY_TOOLS: &[&str] = &["ctx_brain", "ctx_routes"];
17
18/// Tools that prefer cloud routing but fall back to local file storage when not configured.
19const CLOUD_PREFERRED_TOOLS: &[&str] = &["ctx_knowledge", "ctx_session"];
20
21/// Outcome of attempting to route a tool call to the cloud server.
22enum CloudResult {
23    /// Server responded successfully; return this text to the caller.
24    Success(String),
25    /// No cloud connection is configured; caller should fall back to local handling.
26    NotConfigured,
27    /// Connection is configured but the call failed; return this error text to the caller.
28    Error(String),
29}
30
31impl ServerHandler for LeanCtxServer {
32    fn get_info(&self) -> ServerInfo {
33        let capabilities = ServerCapabilities::builder().enable_tools().build();
34
35        let instructions = crate::instructions::build_instructions(self.crp_mode);
36
37        InitializeResult::new(capabilities)
38            .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
39            .with_instructions(instructions)
40    }
41
42    async fn initialize(
43        &self,
44        request: InitializeRequestParams,
45        _context: RequestContext<RoleServer>,
46    ) -> Result<InitializeResult, ErrorData> {
47        let name = request.client_info.name.clone();
48        tracing::info!("MCP client connected: {:?}", name);
49        *self.client_name.write().await = name.clone();
50
51        let derived_root = derive_project_root_from_cwd();
52        let cwd_str = std::env::current_dir()
53            .ok()
54            .map(|p| p.to_string_lossy().to_string())
55            .unwrap_or_default();
56        {
57            let mut session = self.session.write().await;
58            if !cwd_str.is_empty() {
59                session.shell_cwd = Some(cwd_str.clone());
60            }
61            if let Some(ref root) = derived_root {
62                session.project_root = Some(root.clone());
63                tracing::info!("Project root set to: {root}");
64            } else if let Some(ref root) = session.project_root {
65                let root_path = std::path::Path::new(root);
66                let root_has_marker = has_project_marker(root_path);
67                let root_str = root_path.to_string_lossy();
68                let root_suspicious = root_str.contains("/.claude")
69                    || root_str.contains("/.codex")
70                    || root_str.contains("/var/folders/")
71                    || root_str.contains("/tmp/")
72                    || root_str.contains("\\.claude")
73                    || root_str.contains("\\.codex")
74                    || root_str.contains("\\AppData\\Local\\Temp")
75                    || root_str.contains("\\Temp\\");
76                if root_suspicious && !root_has_marker {
77                    session.project_root = None;
78                }
79            }
80            let _ = session.save();
81        }
82
83        let agent_name = name.clone();
84        let agent_root = derived_root.clone().unwrap_or_default();
85        let agent_id_handle = self.agent_id.clone();
86        tokio::task::spawn_blocking(move || {
87            if std::env::var("NEBU_CTX_HEADLESS").is_ok() {
88                return;
89            }
90            if let Some(home) = dirs::home_dir() {
91                let _ = crate::rules_inject::inject_all_rules(&home);
92            }
93            crate::hooks::refresh_installed_hooks();
94            crate::core::version_check::check_background();
95
96            if !agent_root.is_empty() {
97                let role = match agent_name.to_lowercase().as_str() {
98                    n if n.contains("cursor") => Some("coder"),
99                    n if n.contains("claude") => Some("coder"),
100                    n if n.contains("codex") => Some("coder"),
101                    n if n.contains("antigravity") || n.contains("gemini") => Some("explorer"),
102                    n if n.contains("review") => Some("reviewer"),
103                    n if n.contains("test") => Some("tester"),
104                    _ => None,
105                };
106                let env_role = std::env::var("NEBU_CTX_AGENT_ROLE").ok();
107                let effective_role = env_role.as_deref().or(role);
108                let mut registry = crate::core::agents::AgentRegistry::load_or_create();
109                registry.cleanup_stale(24);
110                let id = registry.register("mcp", effective_role, &agent_root);
111                let _ = registry.save();
112                if let Ok(mut guard) = agent_id_handle.try_write() {
113                    *guard = Some(id);
114                }
115            }
116        });
117
118        let instructions =
119            crate::instructions::build_instructions_with_client(self.crp_mode, &name);
120        let capabilities = ServerCapabilities::builder().enable_tools().build();
121
122        Ok(InitializeResult::new(capabilities)
123            .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
124            .with_instructions(instructions))
125    }
126
127    async fn list_tools(
128        &self,
129        _request: Option<PaginatedRequestParams>,
130        _context: RequestContext<RoleServer>,
131    ) -> Result<ListToolsResult, ErrorData> {
132        let all_tools = if crate::tool_defs::is_lazy_mode() {
133            crate::tool_defs::lazy_tool_defs()
134        } else if std::env::var("NEBU_CTX_UNIFIED").is_ok()
135            && std::env::var("NEBU_CTX_FULL_TOOLS").is_err()
136        {
137            crate::tool_defs::unified_tool_defs()
138        } else {
139            crate::tool_defs::granular_tool_defs()
140        };
141
142        let disabled = crate::core::config::Config::load().disabled_tools_effective();
143        let tools = if disabled.is_empty() {
144            all_tools
145        } else {
146            all_tools
147                .into_iter()
148                .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
149                .collect()
150        };
151
152        let tools = {
153            let active = self.workflow.read().await.clone();
154            if let Some(run) = active {
155                if let Some(state) = run.spec.state(&run.current) {
156                    if let Some(allowed) = &state.allowed_tools {
157                        let mut allow: std::collections::HashSet<&str> =
158                            allowed.iter().map(|s| s.as_str()).collect();
159                        allow.insert("ctx");
160                        allow.insert("ctx_workflow");
161                        return Ok(ListToolsResult {
162                            tools: tools
163                                .into_iter()
164                                .filter(|t| allow.contains(t.name.as_ref()))
165                                .collect(),
166                            ..Default::default()
167                        });
168                    }
169                }
170            }
171            tools
172        };
173
174        let tools = merge_remote_tool_defs(tools).await;
175
176        Ok(ListToolsResult {
177            tools,
178            ..Default::default()
179        })
180    }
181
182    async fn call_tool(
183        &self,
184        request: CallToolRequestParams,
185        _context: RequestContext<RoleServer>,
186    ) -> Result<CallToolResult, ErrorData> {
187        self.check_idle_expiry().await;
188
189        let original_name = request.name.as_ref().to_string();
190        let (resolved_name, resolved_args) = if original_name == "ctx" {
191            let sub = request
192                .arguments
193                .as_ref()
194                .and_then(|a| a.get("tool"))
195                .and_then(|v| v.as_str())
196                .map(|s| s.to_string())
197                .ok_or_else(|| {
198                    ErrorData::invalid_params("'tool' is required for ctx meta-tool", None)
199                })?;
200            let tool_name = if sub.starts_with("ctx_") {
201                sub
202            } else {
203                format!("ctx_{sub}")
204            };
205            let mut args = request.arguments.unwrap_or_default();
206            args.remove("tool");
207            (tool_name, Some(args))
208        } else {
209            (original_name, request.arguments)
210        };
211        let name = resolved_name.as_str();
212        let args = &resolved_args;
213
214        // Route cloud-only and cloud-preferred tools through the cloud server.
215        let cloud_fallback_warning = if CLOUD_ONLY_TOOLS.contains(&name) {
216            match route_to_cloud(name, args).await {
217                CloudResult::Success(s) => {
218                    // Record telemetry so dashboard reflects cloud tool usage.
219                    self.record_call(name, 0, 0, None).await;
220                    return Ok(CallToolResult::success(vec![Content::text(s)]));
221                }
222                CloudResult::NotConfigured => {
223                    let msg = format!(
224                        "{name} requires a cloud connection. Run: nebu-ctx cloud connect"
225                    );
226                    return Ok(CallToolResult::success(vec![Content::text(msg)]));
227                }
228                CloudResult::Error(e) => {
229                    return Ok(CallToolResult::success(vec![Content::text(e)]));
230                }
231            }
232        } else if CLOUD_PREFERRED_TOOLS.contains(&name) {
233            match route_to_cloud(name, args).await {
234                CloudResult::Success(s) => {
235                    // Record telemetry so dashboard reflects cloud tool usage.
236                    self.record_call(name, 0, 0, None).await;
237                    return Ok(CallToolResult::success(vec![Content::text(s)]));
238                }
239                CloudResult::NotConfigured => Some(
240                    "\n\n⚠ Running locally (no cloud connection). Data stored in .nebu-ctx/ only.\n  To enable cloud persistence: nebu-ctx cloud connect"
241                        .to_string(),
242                ),
243                CloudResult::Error(e) => return Ok(CallToolResult::success(vec![Content::text(e)])),
244            }
245        } else {
246            None
247        };
248
249        if name != "ctx_workflow" {
250            let active = self.workflow.read().await.clone();
251            if let Some(run) = active {
252                if let Some(state) = run.spec.state(&run.current) {
253                    if let Some(allowed) = &state.allowed_tools {
254                        let allowed_ok = allowed.iter().any(|t| t == name) || name == "ctx";
255                        if !allowed_ok {
256                            let mut shown = allowed.clone();
257                            shown.sort();
258                            shown.truncate(30);
259                            return Ok(CallToolResult::success(vec![Content::text(format!(
260                                "Tool '{name}' blocked by workflow '{}' (state: {}). Allowed ({} shown): {}",
261                                run.spec.name,
262                                run.current,
263                                shown.len(),
264                                shown.join(", ")
265                            ))]));
266                        }
267                    }
268                }
269            }
270        }
271
272        let auto_context = {
273            let task = {
274                let session = self.session.read().await;
275                session.task.as_ref().map(|t| t.description.clone())
276            };
277            let project_root = {
278                let session = self.session.read().await;
279                session.project_root.clone()
280            };
281            let mut cache = self.cache.write().await;
282            crate::tools::autonomy::session_lifecycle_pre_hook(
283                &self.autonomy,
284                name,
285                &mut cache,
286                task.as_deref(),
287                project_root.as_deref(),
288                self.crp_mode,
289            )
290        };
291
292        let throttle_result = {
293            let fp = args
294                .as_ref()
295                .map(|a| {
296                    crate::core::loop_detection::LoopDetector::fingerprint(
297                        &serde_json::Value::Object(a.clone()),
298                    )
299                })
300                .unwrap_or_default();
301            let mut detector = self.loop_detector.write().await;
302
303            let is_search = crate::core::loop_detection::LoopDetector::is_search_tool(name);
304            let is_search_shell = name == "ctx_shell" && {
305                let cmd = args
306                    .as_ref()
307                    .and_then(|a| a.get("command"))
308                    .and_then(|v| v.as_str())
309                    .unwrap_or("");
310                crate::core::loop_detection::LoopDetector::is_search_shell_command(cmd)
311            };
312
313            if is_search || is_search_shell {
314                let search_pattern = args.as_ref().and_then(|a| {
315                    a.get("pattern")
316                        .or_else(|| a.get("query"))
317                        .and_then(|v| v.as_str())
318                });
319                let shell_pattern = if is_search_shell {
320                    args.as_ref()
321                        .and_then(|a| a.get("command"))
322                        .and_then(|v| v.as_str())
323                        .and_then(helpers::extract_search_pattern_from_command)
324                } else {
325                    None
326                };
327                let pat = search_pattern.or(shell_pattern.as_deref());
328                detector.record_search(name, &fp, pat)
329            } else {
330                detector.record_call(name, &fp)
331            }
332        };
333
334        if throttle_result.level == crate::core::loop_detection::ThrottleLevel::Blocked {
335            let msg = throttle_result.message.unwrap_or_default();
336            return Ok(CallToolResult::success(vec![Content::text(msg)]));
337        }
338
339        let throttle_warning =
340            if throttle_result.level == crate::core::loop_detection::ThrottleLevel::Reduced {
341                throttle_result.message.clone()
342            } else {
343                None
344            };
345
346        let tool_start = std::time::Instant::now();
347        let result_text = self.dispatch_tool(name, args).await?;
348
349        let mut result_text = result_text;
350
351        // Archive large tool outputs before density compression (zero-loss recovery)
352        let archive_hint = {
353            use crate::core::archive;
354            let archivable = matches!(
355                name,
356                "ctx_shell"
357                    | "ctx_read"
358                    | "ctx_multi_read"
359                    | "ctx_smart_read"
360                    | "ctx_execute"
361                    | "ctx_search"
362                    | "ctx_tree"
363            );
364            if archivable && archive::should_archive(&result_text) {
365                let cmd = helpers::get_str(args, "command")
366                    .or_else(|| helpers::get_str(args, "path"))
367                    .unwrap_or_default();
368                let session_id = self.session.read().await.id.clone();
369                let tokens = crate::core::tokens::count_tokens(&result_text);
370                archive::store(name, &cmd, &result_text, Some(&session_id))
371                    .map(|id| archive::format_hint(&id, result_text.len(), tokens))
372            } else {
373                None
374            }
375        };
376
377        {
378            let config = crate::core::config::Config::load();
379            let density = crate::core::config::OutputDensity::effective(&config.output_density);
380            result_text = crate::core::protocol::compress_output(&result_text, &density);
381        }
382
383        if let Some(hint) = archive_hint {
384            result_text = format!("{result_text}\n{hint}");
385        }
386
387        if let Some(ctx) = auto_context {
388            result_text = format!("{ctx}\n\n{result_text}");
389        }
390
391        if let Some(warning) = throttle_warning {
392            result_text = format!("{result_text}\n\n{warning}");
393        }
394
395        if let Some(offline_note) = cloud_fallback_warning {
396            result_text = format!("{result_text}{offline_note}");
397        }
398
399        if name == "ctx_read" {
400            let read_path = self
401                .resolve_path_or_passthrough(&helpers::get_str(args, "path").unwrap_or_default())
402                .await;
403            let project_root = {
404                let session = self.session.read().await;
405                session.project_root.clone()
406            };
407            let mut cache = self.cache.write().await;
408            let enrich = crate::tools::autonomy::enrich_after_read(
409                &self.autonomy,
410                &mut cache,
411                &read_path,
412                project_root.as_deref(),
413            );
414            if let Some(hint) = enrich.related_hint {
415                result_text = format!("{result_text}\n{hint}");
416            }
417
418            crate::tools::autonomy::maybe_auto_dedup(&self.autonomy, &mut cache);
419        }
420
421        if name == "ctx_shell" {
422            let cmd = helpers::get_str(args, "command").unwrap_or_default();
423            let output_tokens = crate::core::tokens::count_tokens(&result_text);
424            let calls = self.tool_calls.read().await;
425            let last_original = calls.last().map(|c| c.original_tokens).unwrap_or(0);
426            drop(calls);
427            if let Some(hint) = crate::tools::autonomy::shell_efficiency_hint(
428                &self.autonomy,
429                &cmd,
430                last_original,
431                output_tokens,
432            ) {
433                result_text = format!("{result_text}\n{hint}");
434            }
435        }
436
437        {
438            let input = helpers::canonical_args_string(args);
439            let input_md5 = helpers::md5_hex(&input);
440            let output_md5 = helpers::md5_hex(&result_text);
441            let action = helpers::get_str(args, "action");
442            let agent_id = self.agent_id.read().await.clone();
443            let client_name = self.client_name.read().await.clone();
444            let mut explicit_intent: Option<(
445                crate::core::intent_protocol::IntentRecord,
446                Option<String>,
447                String,
448            )> = None;
449
450            {
451                let empty_args = serde_json::Map::new();
452                let args_map = args.as_ref().unwrap_or(&empty_args);
453                let mut session = self.session.write().await;
454                session.record_tool_receipt(
455                    name,
456                    action.as_deref(),
457                    &input_md5,
458                    &output_md5,
459                    agent_id.as_deref(),
460                    Some(&client_name),
461                );
462
463                if let Some(intent) = crate::core::intent_protocol::infer_from_tool_call(
464                    name,
465                    action.as_deref(),
466                    args_map,
467                    session.project_root.as_deref(),
468                ) {
469                    let is_explicit =
470                        intent.source == crate::core::intent_protocol::IntentSource::Explicit;
471                    let root = session.project_root.clone();
472                    let sid = session.id.clone();
473                    session.record_intent(intent.clone());
474                    if is_explicit {
475                        explicit_intent = Some((intent, root, sid));
476                    }
477                }
478                if session.should_save() {
479                    let _ = session.save();
480                }
481            }
482
483            if let Some((intent, root, session_id)) = explicit_intent {
484                crate::core::intent_protocol::apply_side_effects(
485                    &intent,
486                    root.as_deref(),
487                    &session_id,
488                );
489            }
490
491            // Autopilot: consolidation loop (silent, deterministic, budgeted).
492            if self.autonomy.is_enabled() {
493                let (calls, project_root) = {
494                    let session = self.session.read().await;
495                    (session.stats.total_tool_calls, session.project_root.clone())
496                };
497
498                if let Some(root) = project_root {
499                    if crate::tools::autonomy::should_auto_consolidate(&self.autonomy, calls) {
500                        let root_clone = root.clone();
501                        tokio::task::spawn_blocking(move || {
502                            let _ = crate::core::consolidation_engine::consolidate_latest(
503                                &root_clone,
504                                crate::core::consolidation_engine::ConsolidationBudgets::default(),
505                            );
506                        });
507                    }
508                }
509            }
510
511            let agent_key = agent_id.unwrap_or_else(|| "unknown".to_string());
512            let input_tokens = crate::core::tokens::count_tokens(&input) as u64;
513            let output_tokens = crate::core::tokens::count_tokens(&result_text) as u64;
514            let mut store = crate::core::a2a::cost_attribution::CostStore::load();
515            store.record_tool_call(&agent_key, &client_name, name, input_tokens, output_tokens);
516            let _ = store.save();
517        }
518
519        let skip_checkpoint = matches!(
520            name,
521            "ctx_compress"
522                | "ctx_metrics"
523                | "ctx_benchmark"
524                | "ctx_analyze"
525                | "ctx_cache"
526                | "ctx_discover"
527                | "ctx_dedup"
528                | "ctx_session"
529                | "ctx_knowledge"
530                | "ctx_agent"
531                | "ctx_share"
532                | "ctx_wrapped"
533                | "ctx_overview"
534                | "ctx_preload"
535                | "ctx_cost"
536                | "ctx_gain"
537                | "ctx_heatmap"
538                | "ctx_task"
539                | "ctx_impact"
540                | "ctx_architecture"
541                | "ctx_workflow"
542        );
543
544        if !skip_checkpoint && self.increment_and_check() {
545            if let Some(checkpoint) = self.auto_checkpoint().await {
546                let combined = format!(
547                    "{result_text}\n\n--- AUTO CHECKPOINT (every {} calls) ---\n{checkpoint}",
548                    self.checkpoint_interval
549                );
550                return Ok(CallToolResult::success(vec![Content::text(combined)]));
551            }
552        }
553
554        let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
555        if tool_duration_ms > 100 {
556            LeanCtxServer::append_tool_call_log(
557                name,
558                tool_duration_ms,
559                0,
560                0,
561                None,
562                &chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
563            );
564        }
565
566        Ok(CallToolResult::success(vec![Content::text(result_text)]))
567    }
568}
569
570/// Routes a tool call to the cloud server, enriching it with the current git context.
571///
572/// Returns [`CloudResult::Success`] when the server responds, [`CloudResult::NotConfigured`] when
573/// no connection is saved, and [`CloudResult::Error`] when the connection exists but the call fails.
574async fn route_to_cloud(name: &str, args: &Option<serde_json::Map<String, serde_json::Value>>) -> CloudResult {
575    let tool_name = name.to_string();
576    let arguments = args.clone().unwrap_or_default();
577
578    let result = tokio::task::spawn_blocking(move || {
579        let client = match crate::cloud_client::ServerClient::load() {
580            Ok(c) => c,
581            Err(_) => return CloudResult::NotConfigured,
582        };
583        let current_directory = match std::env::current_dir() {
584            Ok(d) => d,
585            Err(e) => return CloudResult::Error(format!("Could not determine working directory: {e}")),
586        };
587        let project_context = crate::git_context::discover_project_context(&current_directory);
588        match client.call_tool(&tool_name, arguments, &project_context) {
589            Ok(value) => {
590                let text = match &value {
591                    serde_json::Value::String(s) => s.clone(),
592                    other => serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()),
593                };
594                CloudResult::Success(text)
595            }
596            Err(e) => CloudResult::Error(format!(
597                "Cloud tool {tool_name} failed: {e}\nCheck connection: nebu-ctx cloud status"
598            )),
599        }
600    })
601    .await;
602
603    result.unwrap_or_else(|_| CloudResult::Error("Cloud routing task panicked".to_string()))
604}
605
606/// Merges tool definitions fetched from the cloud server into the local manifest.
607///
608/// Only tool names listed in [`CLOUD_ONLY_TOOLS`] are pulled from the remote manifest, so the
609/// local definitions always win for [`CLOUD_PREFERRED_TOOLS`]. When the server is unreachable the
610/// local manifest is returned unchanged.
611async fn merge_remote_tool_defs(local_tools: Vec<Tool>) -> Vec<Tool> {
612    let Some(remote_tools) = load_cloud_only_tool_defs().await else {
613        return local_tools;
614    };
615
616    let mut merged = BTreeMap::new();
617    for tool in local_tools {
618        merged.insert(tool.name.to_string(), tool);
619    }
620    for tool in remote_tools {
621        merged.insert(tool.name.to_string(), tool);
622    }
623    merged.into_values().collect()
624}
625
626/// Fetches tool definitions for [`CLOUD_ONLY_TOOLS`] from the configured cloud server.
627async fn load_cloud_only_tool_defs() -> Option<Vec<Tool>> {
628    tokio::task::spawn_blocking(|| {
629        let client = crate::cloud_client::ServerClient::load().ok()?;
630        let remote = client.list_tools().ok()?;
631        Some(
632            remote
633                .tools
634                .into_iter()
635                .filter(|tool| CLOUD_ONLY_TOOLS.contains(&tool.name.as_str()))
636                .map(|tool| {
637                    let input_schema = match tool.input_schema {
638                        serde_json::Value::Object(map) => map,
639                        _ => serde_json::Map::new(),
640                    };
641                    Tool::new(tool.name, tool.description, Arc::new(input_schema))
642                })
643                .collect::<Vec<_>>(),
644        )
645    })
646    .await
647    .ok()
648    .flatten()
649}
650
651pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
652    crate::instructions::build_instructions(crp_mode)
653}
654
655pub fn build_claude_code_instructions_for_test() -> String {
656    crate::instructions::claude_code_instructions()
657}
658
659const PROJECT_MARKERS: &[&str] = &[
660    ".git",
661    "Cargo.toml",
662    "package.json",
663    "go.mod",
664    "pyproject.toml",
665    "setup.py",
666    "pom.xml",
667    "build.gradle",
668    "Makefile",
669    ".lean-ctx.toml",
670];
671
672fn has_project_marker(dir: &std::path::Path) -> bool {
673    PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
674}
675
676fn is_home_or_agent_dir(dir: &std::path::Path) -> bool {
677    if let Some(home) = dirs::home_dir() {
678        if dir == home {
679            return true;
680        }
681    }
682    let dir_str = dir.to_string_lossy();
683    dir_str.ends_with("/.claude")
684        || dir_str.ends_with("/.codex")
685        || dir_str.contains("/.claude/")
686        || dir_str.contains("/.codex/")
687}
688
689fn git_toplevel_from(dir: &std::path::Path) -> Option<String> {
690    std::process::Command::new("git")
691        .args(["rev-parse", "--show-toplevel"])
692        .current_dir(dir)
693        .stdout(std::process::Stdio::piped())
694        .stderr(std::process::Stdio::null())
695        .output()
696        .ok()
697        .and_then(|o| {
698            if o.status.success() {
699                String::from_utf8(o.stdout)
700                    .ok()
701                    .map(|s| s.trim().to_string())
702            } else {
703                None
704            }
705        })
706}
707
708pub fn derive_project_root_from_cwd() -> Option<String> {
709    let cwd = std::env::current_dir().ok()?;
710    let canonical = crate::core::pathutil::safe_canonicalize_or_self(&cwd);
711
712    if is_home_or_agent_dir(&canonical) {
713        return git_toplevel_from(&canonical);
714    }
715
716    if has_project_marker(&canonical) {
717        return Some(canonical.to_string_lossy().to_string());
718    }
719
720    if let Some(git_root) = git_toplevel_from(&canonical) {
721        return Some(git_root);
722    }
723
724    None
725}
726
727pub fn tool_descriptions_for_test() -> Vec<(&'static str, &'static str)> {
728    crate::tool_defs::list_all_tool_defs()
729        .into_iter()
730        .map(|(name, desc, _)| (name, desc))
731        .collect()
732}
733
734pub fn tool_schemas_json_for_test() -> String {
735    crate::tool_defs::list_all_tool_defs()
736        .iter()
737        .map(|(name, _, schema)| format!("{}: {}", name, schema))
738        .collect::<Vec<_>>()
739        .join("\n")
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    #[test]
747    fn project_markers_detected() {
748        let tmp = tempfile::tempdir().unwrap();
749        let root = tmp.path().join("myproject");
750        std::fs::create_dir_all(&root).unwrap();
751        assert!(!has_project_marker(&root));
752
753        std::fs::create_dir(root.join(".git")).unwrap();
754        assert!(has_project_marker(&root));
755    }
756
757    #[test]
758    fn home_dir_detected_as_agent_dir() {
759        if let Some(home) = dirs::home_dir() {
760            assert!(is_home_or_agent_dir(&home));
761        }
762    }
763
764    #[test]
765    fn agent_dirs_detected() {
766        let claude = std::path::PathBuf::from("/home/user/.claude");
767        assert!(is_home_or_agent_dir(&claude));
768        let codex = std::path::PathBuf::from("/home/user/.codex");
769        assert!(is_home_or_agent_dir(&codex));
770        let project = std::path::PathBuf::from("/home/user/projects/myapp");
771        assert!(!is_home_or_agent_dir(&project));
772    }
773
774    #[test]
775    fn test_unified_tool_count() {
776        let tools = crate::tool_defs::unified_tool_defs();
777        assert_eq!(tools.len(), 5, "Expected 5 unified tools");
778    }
779
780    #[test]
781    fn test_granular_tool_count() {
782        let tools = crate::tool_defs::granular_tool_defs();
783        assert!(tools.len() >= 25, "Expected at least 25 granular tools");
784    }
785
786    #[test]
787    fn disabled_tools_filters_list() {
788        let all = crate::tool_defs::granular_tool_defs();
789        let total = all.len();
790        let disabled = ["ctx_graph".to_string(), "ctx_agent".to_string()];
791        let filtered: Vec<_> = all
792            .into_iter()
793            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
794            .collect();
795        assert_eq!(filtered.len(), total - 2);
796        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_graph"));
797        assert!(!filtered.iter().any(|t| t.name.as_ref() == "ctx_agent"));
798    }
799
800    #[test]
801    fn empty_disabled_tools_returns_all() {
802        let all = crate::tool_defs::granular_tool_defs();
803        let total = all.len();
804        let disabled: Vec<String> = vec![];
805        let filtered: Vec<_> = all
806            .into_iter()
807            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
808            .collect();
809        assert_eq!(filtered.len(), total);
810    }
811
812    #[test]
813    fn misspelled_disabled_tool_is_silently_ignored() {
814        let all = crate::tool_defs::granular_tool_defs();
815        let total = all.len();
816        let disabled = ["ctx_nonexistent_tool".to_string()];
817        let filtered: Vec<_> = all
818            .into_iter()
819            .filter(|t| !disabled.iter().any(|d| t.name.as_ref() == d.as_str()))
820            .collect();
821        assert_eq!(filtered.len(), total);
822    }
823
824    #[test]
825    fn cloud_tool_constants_are_disjoint() {
826        for name in CLOUD_ONLY_TOOLS {
827            assert!(
828                !CLOUD_PREFERRED_TOOLS.contains(name),
829                "{name} appears in both CLOUD_ONLY_TOOLS and CLOUD_PREFERRED_TOOLS"
830            );
831        }
832    }
833
834    #[test]
835    fn ctx_brain_is_cloud_only_not_preferred() {
836        assert!(CLOUD_ONLY_TOOLS.contains(&"ctx_brain"));
837        assert!(!CLOUD_PREFERRED_TOOLS.contains(&"ctx_brain"));
838    }
839
840    #[test]
841    fn ctx_knowledge_and_ctx_session_are_cloud_preferred() {
842        assert!(CLOUD_PREFERRED_TOOLS.contains(&"ctx_knowledge"));
843        assert!(CLOUD_PREFERRED_TOOLS.contains(&"ctx_session"));
844        assert!(!CLOUD_ONLY_TOOLS.contains(&"ctx_knowledge"));
845        assert!(!CLOUD_ONLY_TOOLS.contains(&"ctx_session"));
846    }
847
848    #[test]
849    fn ctx_brain_in_granular_tool_defs() {
850        let tools = crate::tool_defs::granular_tool_defs();
851        assert!(
852            tools.iter().any(|t| t.name.as_ref() == "ctx_brain"),
853            "ctx_brain must appear in the local manifest so Claude sees it even when offline"
854        );
855    }
856
857    #[test]
858    fn ctx_brain_stub_has_required_actions() {
859        let tools = crate::tool_defs::granular_tool_defs();
860        let brain = tools.iter().find(|t| t.name.as_ref() == "ctx_brain").unwrap();
861        let schema = serde_json::to_string(&*brain.input_schema).unwrap();
862        assert!(schema.contains("store"), "ctx_brain schema must include 'store' action");
863        assert!(schema.contains("recall"), "ctx_brain schema must include 'recall' action");
864        assert!(schema.contains("forget"), "ctx_brain schema must include 'forget' action");
865    }
866
867    #[test]
868    fn route_to_cloud_returns_not_configured_or_error_when_no_connection() {
869        // Without a saved connection, route_to_cloud must not panic. It should return
870        // NotConfigured or Error (never Success unless CI has a live server).
871        let rt = tokio::runtime::Builder::new_current_thread()
872            .enable_all()
873            .build()
874            .unwrap();
875        let result = rt.block_on(route_to_cloud("ctx_brain", &None));
876        match result {
877            CloudResult::Success(_) => {} // acceptable if CI has a live server
878            CloudResult::NotConfigured => {}
879            CloudResult::Error(_) => {}
880        }
881    }
882}