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