1use std::collections::HashSet;
9use std::sync::{Mutex, OnceLock};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum ToolCategory {
13 Core,
14 Internal,
15 Arch,
16 Debug,
17 Memory,
18 Metrics,
19 Session,
20}
21
22impl ToolCategory {
23 pub fn parse(s: &str) -> Option<Self> {
24 match s {
25 "core" => Some(Self::Core),
26 "arch" | "architecture" => Some(Self::Arch),
27 "debug" | "profiling" => Some(Self::Debug),
28 "memory" | "semantic" => Some(Self::Memory),
29 "metrics" | "stats" => Some(Self::Metrics),
30 "session" => Some(Self::Session),
31 _ => None,
32 }
33 }
34
35 pub fn as_str(&self) -> &'static str {
36 match self {
37 Self::Core => "core",
38 Self::Internal => "internal",
39 Self::Arch => "arch",
40 Self::Debug => "debug",
41 Self::Memory => "memory",
42 Self::Metrics => "metrics",
43 Self::Session => "session",
44 }
45 }
46}
47
48#[allow(clippy::match_same_arms)]
49pub fn categorize_tool(name: &str) -> ToolCategory {
50 match name {
51 "ctx_metrics"
53 | "ctx_cost"
54 | "ctx_gain"
55 | "ctx_radar"
56 | "ctx_heatmap"
57 | "ctx_feedback"
58 | "ctx_discover"
59 | "ctx_discover_tools"
60 | "ctx_load_tools"
61 | "ctx_dedup"
62 | "ctx_preload"
63 | "ctx_prefetch"
64 | "ctx_compress_memory"
65 | "ctx_shape" => ToolCategory::Internal,
66
67 "ctx_read" | "ctx_search" | "ctx_shell" | "shell" | "ctx_tree" | "ctx_edit"
74 | "ctx_patch" | "ctx_session" | "ctx_checkpoint" | "ctx_knowledge" | "ctx_doc"
75 | "ctx_graph" | "ctx_callgraph" | "ctx_call" | "ctx_compress" | "ctx_expand"
76 | "ctx_delta" | "ctx_outline" | "ctx_context" => ToolCategory::Core,
77
78 "ctx_index" => ToolCategory::Arch,
80
81 "ctx_verify" | "ctx_profile" | "ctx_proof" => ToolCategory::Debug,
83
84 "ctx_provider" | "ctx_url_read" | "ctx_git_read" | "ctx_tools" => ToolCategory::Core,
88
89 "ctx_artifacts" => ToolCategory::Memory,
91
92 "ctx_execute" | "ctx_pack" | "ctx_control" => ToolCategory::Metrics,
94
95 "ctx_agent" | "ctx_share" | "ctx_handoff" | "ctx_work_graph" | "ctx_tasks" => {
97 ToolCategory::Session
98 }
99
100 _ => ToolCategory::Core,
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct RemovedTool {
110 pub replacement: Option<&'static str>,
113 pub hint: &'static str,
115}
116
117const fn superseded(replacement: &'static str, hint: &'static str) -> Option<RemovedTool> {
118 Some(RemovedTool {
119 replacement: Some(replacement),
120 hint,
121 })
122}
123
124const fn retired(hint: &'static str) -> Option<RemovedTool> {
125 Some(RemovedTool {
126 replacement: None,
127 hint,
128 })
129}
130
131#[must_use]
133pub fn removed_tool(name: &str) -> Option<RemovedTool> {
134 match name {
135 "ctx_smart_read" => superseded(
136 "ctx_read",
137 "ctx_read auto-selects the mode (omit `mode`, or pass mode=\"auto\")",
138 ),
139 "ctx_multi_read" => superseded(
140 "ctx_read",
141 "ctx_read now batch-reads via paths=[\"a.rs\",\"b.rs\"]",
142 ),
143 "ctx_semantic_search" => superseded(
145 "ctx_search",
146 "ctx_search with action=\"semantic\" (query=…); reindex/find_related are actions too",
147 ),
148 "ctx_symbol" => superseded(
149 "ctx_search",
150 "ctx_search with action=\"symbol\" (name=…, optional file/kind)",
151 ),
152 "ctx_overview" | "ctx_summary" | "ctx_explore" => superseded(
154 "ctx_compose",
155 "ctx_compose(task=…) returns the ranked files, symbols and structure for a task",
156 ),
157 "ctx_plan" | "ctx_compile" | "ctx_fill" => superseded(
158 "ctx_compose",
159 "ctx_compose selects under the turn budget; read chosen files with ctx_read",
160 ),
161 "ctx_impact" => superseded(
163 "ctx_graph",
164 "ctx_graph action=\"impact\" (path=…), action=\"path\" for chains, action=\"diff\" for changes",
165 ),
166 "ctx_architecture" | "ctx_repomap" => superseded(
167 "ctx_graph",
168 "ctx_graph action=\"diagram\"|\"explain\"|\"related\"; ctx_tree for the directory map",
169 ),
170 "ctx_routes" => superseded(
171 "ctx_search",
172 "ctx_search for route definitions (pattern=…), ctx_graph action=\"related\" for handlers",
173 ),
174 "ctx_retrieve" => superseded(
175 "ctx_expand",
176 "ctx_expand id=… returns withheld or archived content",
177 ),
178 "ctx_cache" => superseded(
181 "ctx_read",
182 "ctx_read fresh=true re-reads; maintenance runs via `lean-ctx cache prune|reset`",
183 ),
184 "ctx_smells" | "ctx_quality" | "ctx_optimize" | "ctx_review" => {
186 retired("code review and linting belong to the host's own tooling")
187 }
188 "ctx_quality_lab" | "ctx_benchmark" | "ctx_perf" | "ctx_cognitive" | "ctx_ledger"
189 | "ctx_live_zone" => retired(
190 "measurement and diagnostics live in `lean-ctx gain`, `lean-ctx doctor` and the dashboard",
191 ),
192 "ctx_refactor" => retired(
193 "write refactorings are host execution; ctx_callgraph keeps the LSP read side (references)",
194 ),
195 "ctx_analyze" | "ctx_compare" => retired(
196 "ctx_read mode=\"auto\" picks the mode; `lean-ctx compress diff <file>` previews compression",
197 ),
198 "ctx_crush" => {
199 retired("ctx_read and ctx_shell apply the JSON/CSV/YAML crushers automatically")
200 }
201 "ctx_response" => {
202 retired("LeanCTX shapes the context the model reads, not the model's own responses")
203 }
204 "ctx_skillify" | "ctx_rules" => retired(
205 "agent rule files are host configuration; use `lean-ctx rules status|diff` or `lean-ctx doctor`",
206 ),
207 "ctx_workflow" => retired(
208 "workflow execution belongs to the host; ctx_work_graph keeps the shared coordination state",
209 ),
210 "ctx_task" => retired(
212 "task assignment is host execution; ctx_work_graph keeps the shared coordination state",
213 ),
214 "ctx_intent" => retired(
215 "LeanCTX infers intent from the tool calls; ctx_compose(task=…) selects context for a task",
216 ),
217 _ => None,
218 }
219}
220
221pub const LOCAL_COLLABORATION_COMPATIBILITY_TOOLS: &[&str] =
228 &["ctx_agent", "ctx_share", "ctx_handoff"];
229
230#[must_use]
233pub fn is_local_collaboration_compatibility_tool(name: &str) -> bool {
234 LOCAL_COLLABORATION_COMPATIBILITY_TOOLS.contains(&name)
235}
236
237pub const INTERNAL_HOST_TOOLS: &[&str] = &["ctx_shape"];
242
243pub use crate::core::hive_gate::HIVE_TOOLS;
245
246#[must_use]
248pub fn is_withheld_hive_tool(name: &str) -> bool {
249 HIVE_TOOLS.contains(&name) && !crate::core::hive_gate::available_locally()
250}
251
252#[must_use]
258pub fn is_publicly_advertised_tool(name: &str) -> bool {
259 !is_local_collaboration_compatibility_tool(name)
260 && !INTERNAL_HOST_TOOLS.contains(&name)
261 && !is_withheld_hive_tool(name)
262}
263
264#[must_use]
267pub fn removed_tool_message(name: &str) -> Option<String> {
268 removed_tool(name).map(|r| match r.replacement {
269 Some(replacement) => format!(
270 "{name} was removed; use {replacement} instead — {}.",
271 r.hint
272 ),
273 None => format!("{name} was removed — {}.", r.hint),
274 })
275}
276
277pub fn is_readonly_tool(name: &str) -> bool {
278 matches!(
279 name,
280 "ctx_read"
281 | "ctx_search"
282 | "ctx_tree"
283 | "ctx_metrics"
284 | "ctx_compress"
285 | "ctx_session"
286 | "ctx_knowledge"
287 | "ctx_graph"
288 | "ctx_provider"
289 | "ctx_delta"
290 | "ctx_outline"
291 | "ctx_context"
292 | "ctx_call"
293 | "ctx_url_read"
294 | "ctx_git_read"
295 | "ctx_callgraph"
296 | "ctx_index"
297 | "ctx_artifacts"
298 | "ctx_cost"
299 | "ctx_gain"
300 | "ctx_heatmap"
301 )
302}
303
304#[derive(Debug)]
305pub struct DynamicToolState {
306 active_categories: HashSet<ToolCategory>,
307 supports_list_changed: bool,
308}
309
310impl Default for DynamicToolState {
311 fn default() -> Self {
312 Self::new()
313 }
314}
315
316impl DynamicToolState {
317 pub fn new() -> Self {
318 let mut active = HashSet::new();
319 active.insert(ToolCategory::Core);
320 Self {
321 active_categories: active,
322 supports_list_changed: false,
323 }
324 }
325
326 pub fn from_config(categories: &[String]) -> Self {
328 let mut active = HashSet::new();
329 active.insert(ToolCategory::Core);
330 for cat_str in categories {
331 if let Some(cat) = ToolCategory::parse(cat_str) {
332 active.insert(cat);
333 }
334 }
335 Self {
336 active_categories: active,
337 supports_list_changed: false,
338 }
339 }
340
341 pub fn all_enabled() -> Self {
342 let mut active = HashSet::new();
343 active.insert(ToolCategory::Core);
344 active.insert(ToolCategory::Arch);
345 active.insert(ToolCategory::Debug);
346 active.insert(ToolCategory::Memory);
347 active.insert(ToolCategory::Metrics);
348 active.insert(ToolCategory::Session);
349 Self {
350 active_categories: active,
351 supports_list_changed: false,
352 }
353 }
354
355 pub fn set_supports_list_changed(&mut self, val: bool) {
356 self.supports_list_changed = val;
357 }
358
359 pub fn supports_list_changed(&self) -> bool {
360 self.supports_list_changed
361 }
362
363 pub fn load_category(&mut self, cat: ToolCategory) -> bool {
364 self.active_categories.insert(cat)
365 }
366
367 pub fn unload_category(&mut self, cat: ToolCategory) -> bool {
368 if cat == ToolCategory::Core || cat == ToolCategory::Internal {
369 return false;
370 }
371 self.active_categories.remove(&cat)
372 }
373
374 pub fn is_tool_active(&self, name: &str) -> bool {
375 let cat = categorize_tool(name);
376 if cat == ToolCategory::Internal {
377 return false;
378 }
379 if !self.supports_list_changed {
380 return cat != ToolCategory::Session || self.active_categories.contains(&cat);
384 }
385 self.active_categories.contains(&cat)
386 }
387
388 pub fn active_categories(&self) -> Vec<&'static str> {
389 let mut cats: Vec<_> = self
390 .active_categories
391 .iter()
392 .map(ToolCategory::as_str)
393 .collect();
394 cats.sort_unstable();
395 cats
396 }
397
398 pub fn all_categories() -> Vec<&'static str> {
399 vec!["core", "arch", "debug", "memory", "metrics", "session"]
400 }
401}
402
403static GLOBAL: OnceLock<Mutex<DynamicToolState>> = OnceLock::new();
404
405pub fn global() -> &'static Mutex<DynamicToolState> {
406 GLOBAL.get_or_init(|| Mutex::new(DynamicToolState::new()))
407}
408
409pub fn init_all_enabled() {
410 let _ = GLOBAL.set(Mutex::new(DynamicToolState::all_enabled()));
411}
412
413pub fn init_from_config(categories: &[String]) {
418 if GLOBAL
419 .set(Mutex::new(DynamicToolState::from_config(categories)))
420 .is_err()
421 && let Ok(mut state) = global().lock()
422 {
423 let desired = DynamicToolState::from_config(categories);
424 *state = desired;
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 #[test]
433 fn core_tools_always_active() {
434 let state = DynamicToolState::new();
435 assert!(state.is_tool_active("ctx_read"));
436 assert!(state.is_tool_active("ctx_search"));
437 assert!(!state.is_tool_active("ctx_agent"));
438 }
439
440 #[test]
441 fn local_collaboration_substrate_is_compatibility_only() {
442 for name in LOCAL_COLLABORATION_COMPATIBILITY_TOOLS {
443 assert!(is_local_collaboration_compatibility_tool(name));
444 assert!(!is_publicly_advertised_tool(name));
445 }
446 assert!(!is_local_collaboration_compatibility_tool("ctx_session"));
447 assert!(is_publicly_advertised_tool("ctx_session"));
448 assert!(!is_publicly_advertised_tool("ctx_shape"));
450 }
451
452 #[test]
453 fn removed_tool_message_names_the_way_forward() {
454 let message = removed_tool_message("ctx_multi_read").unwrap();
455 assert!(message.starts_with("ctx_multi_read was removed; use ctx_read instead — "));
456 assert!(message.contains("paths="));
457 assert!(removed_tool_message("ctx_read").is_none());
458 let retired = removed_tool_message("ctx_smells").unwrap();
459 assert!(retired.starts_with("ctx_smells was removed — "));
460 }
461
462 #[test]
463 fn dynamic_tools_filtered_when_list_changed() {
464 let mut state = DynamicToolState::new();
465 state.set_supports_list_changed(true);
466 assert!(!state.is_tool_active("ctx_verify"));
467 assert!(!state.is_tool_active("ctx_index"));
468 assert!(state.is_tool_active("ctx_read"));
469 }
470
471 #[test]
472 fn load_category_enables_tools() {
473 let mut state = DynamicToolState::new();
474 state.set_supports_list_changed(true);
475 assert!(!state.is_tool_active("ctx_index"));
476 state.load_category(ToolCategory::Arch);
477 assert!(state.is_tool_active("ctx_index"));
478 }
479
480 #[test]
481 fn cannot_unload_core() {
482 let mut state = DynamicToolState::new();
483 assert!(!state.unload_category(ToolCategory::Core));
484 }
485
486 #[test]
487 fn all_tools_visible_without_list_changed() {
488 let state = DynamicToolState::new();
489 assert!(state.is_tool_active("ctx_graph"));
490 assert!(!state.is_tool_active("ctx_agent"));
491 assert!(!state.is_tool_active("ctx_metrics")); }
493
494 #[test]
495 fn internal_tools_never_active() {
496 let state = DynamicToolState::all_enabled();
497 assert!(!state.is_tool_active("ctx_metrics"));
498 assert!(!state.is_tool_active("ctx_cost"));
499 assert!(!state.is_tool_active("ctx_discover_tools"));
500 assert!(!state.is_tool_active("ctx_dedup"));
501 }
502
503 #[test]
506 fn from_config_core_arch_memory() {
507 let cats = vec!["core".to_string(), "arch".to_string(), "memory".to_string()];
508 let mut state = DynamicToolState::from_config(&cats);
509 state.set_supports_list_changed(true);
510 assert!(state.is_tool_active("ctx_read"));
511 assert!(state.is_tool_active("ctx_index"));
512 assert!(state.is_tool_active("ctx_artifacts"));
513 assert!(!state.is_tool_active("ctx_verify"));
514 assert!(!state.is_tool_active("ctx_pack"));
515 }
516
517 #[test]
518 fn from_config_empty_still_has_core() {
519 let mut state = DynamicToolState::from_config(&[]);
520 state.set_supports_list_changed(true);
521 assert!(state.is_tool_active("ctx_read"));
522 assert!(!state.is_tool_active("ctx_index"));
523 assert!(!state.is_tool_active("ctx_verify"));
524 assert!(!state.is_tool_active("ctx_artifacts"));
525 }
526
527 #[test]
530 fn from_config_all_categories_enables_everything_except_internal() {
531 let cats = vec![
532 "core".to_string(),
533 "arch".to_string(),
534 "debug".to_string(),
535 "memory".to_string(),
536 "metrics".to_string(),
537 "session".to_string(),
538 ];
539 let mut state = DynamicToolState::from_config(&cats);
540 state.set_supports_list_changed(true);
541 assert!(state.is_tool_active("ctx_read"));
542 assert!(state.is_tool_active("ctx_index"));
543 assert!(state.is_tool_active("ctx_verify"));
544 assert!(state.is_tool_active("ctx_provider"));
545 assert!(state.is_tool_active("ctx_pack"));
546 assert!(state.is_tool_active("ctx_work_graph"));
547 assert!(!state.is_tool_active("ctx_metrics"));
548 }
549
550 #[test]
553 fn from_config_only_debug() {
554 let cats = vec!["debug".to_string()];
555 let mut state = DynamicToolState::from_config(&cats);
556 state.set_supports_list_changed(true);
557 assert!(state.is_tool_active("ctx_read"));
558 assert!(state.is_tool_active("ctx_verify"));
559 assert!(!state.is_tool_active("ctx_index"));
560 assert!(!state.is_tool_active("ctx_work_graph"));
561 }
562
563 #[test]
566 fn from_config_ignores_unknown_categories() {
567 let cats = vec![
568 "core".to_string(),
569 "nonexistent".to_string(),
570 "foobar".to_string(),
571 ];
572 let mut state = DynamicToolState::from_config(&cats);
573 state.set_supports_list_changed(true);
574 assert!(state.is_tool_active("ctx_read"));
575 assert!(!state.is_tool_active("ctx_index"));
576 }
577
578 #[test]
579 fn from_config_only_invalid_still_has_core() {
580 let cats = vec!["invalid".to_string(), "bogus".to_string()];
581 let mut state = DynamicToolState::from_config(&cats);
582 state.set_supports_list_changed(true);
583 assert!(state.is_tool_active("ctx_read"));
584 assert!(!state.is_tool_active("ctx_verify"));
585 }
586
587 #[test]
590 fn from_config_duplicates_are_harmless() {
591 let cats = vec!["arch".to_string(), "arch".to_string(), "arch".to_string()];
592 let mut state = DynamicToolState::from_config(&cats);
593 state.set_supports_list_changed(true);
594 assert!(state.is_tool_active("ctx_index"));
595 let active = state.active_categories();
596 let arch_count = active.iter().filter(|&&c| c == "arch").count();
597 assert_eq!(arch_count, 1);
598 }
599
600 #[test]
603 fn from_config_internal_category_not_parseable() {
604 assert!(ToolCategory::parse("internal").is_none());
605 }
606
607 #[test]
610 fn from_config_alias_architecture_maps_to_arch() {
611 let cats = vec!["architecture".to_string()];
612 let mut state = DynamicToolState::from_config(&cats);
613 state.set_supports_list_changed(true);
614 assert!(state.is_tool_active("ctx_index"));
615 }
616
617 #[test]
618 fn from_config_alias_profiling_maps_to_debug() {
619 let cats = vec!["profiling".to_string()];
620 let mut state = DynamicToolState::from_config(&cats);
621 state.set_supports_list_changed(true);
622 assert!(state.is_tool_active("ctx_verify"));
623 }
624
625 #[test]
626 fn from_config_alias_semantic_maps_to_memory() {
627 let cats = vec!["semantic".to_string()];
628 let mut state = DynamicToolState::from_config(&cats);
629 state.set_supports_list_changed(true);
630 assert!(state.is_tool_active("ctx_artifacts"));
631 }
632
633 #[test]
636 fn from_config_then_load_additional_category() {
637 let cats = vec!["core".to_string()];
638 let mut state = DynamicToolState::from_config(&cats);
639 state.set_supports_list_changed(true);
640 assert!(!state.is_tool_active("ctx_index"));
641 state.load_category(ToolCategory::Arch);
642 assert!(state.is_tool_active("ctx_index"));
643 }
644
645 #[test]
646 fn from_config_then_unload_non_core_category() {
647 let cats = vec!["core".to_string(), "arch".to_string()];
648 let mut state = DynamicToolState::from_config(&cats);
649 state.set_supports_list_changed(true);
650 assert!(state.is_tool_active("ctx_index"));
651 state.unload_category(ToolCategory::Arch);
652 assert!(!state.is_tool_active("ctx_index"));
653 }
654
655 #[test]
656 fn from_config_cannot_unload_core() {
657 let cats = vec!["core".to_string(), "arch".to_string()];
658 let mut state = DynamicToolState::from_config(&cats);
659 assert!(!state.unload_category(ToolCategory::Core));
660 }
661
662 #[test]
665 fn from_config_without_list_changed_shows_all() {
666 let cats = vec!["core".to_string()];
667 let state = DynamicToolState::from_config(&cats);
668 assert!(state.is_tool_active("ctx_index"));
669 assert!(state.is_tool_active("ctx_verify"));
670 assert!(!state.is_tool_active("ctx_metrics"));
671 }
672
673 #[test]
674 fn lazy_core_tools_survive_default_category_gate() {
675 let mut state = DynamicToolState::new();
680 state.set_supports_list_changed(true);
681 for name in crate::tool_defs::core_tool_names() {
682 assert!(
683 state.is_tool_active(name),
684 "{name} is in CORE_TOOL_NAMES but dropped by the default category gate"
685 );
686 }
687 }
688
689 #[test]
690 fn categorize_known_tools() {
691 assert_eq!(categorize_tool("ctx_read"), ToolCategory::Core);
692 assert_eq!(categorize_tool("ctx_graph"), ToolCategory::Core);
693 assert_eq!(categorize_tool("ctx_patch"), ToolCategory::Core);
696 assert_eq!(categorize_tool("ctx_verify"), ToolCategory::Debug);
697 assert_eq!(categorize_tool("ctx_provider"), ToolCategory::Core);
698 assert_eq!(categorize_tool("ctx_artifacts"), ToolCategory::Memory);
699 assert_eq!(categorize_tool("ctx_metrics"), ToolCategory::Internal);
700 assert_eq!(categorize_tool("ctx_work_graph"), ToolCategory::Session);
701 }
702
703 #[test]
704 fn readonly_classification() {
705 assert!(is_readonly_tool("ctx_read"));
706 assert!(is_readonly_tool("ctx_search"));
707 assert!(is_readonly_tool("ctx_tree"));
708 assert!(is_readonly_tool("ctx_graph"));
709 assert!(is_readonly_tool("ctx_provider"));
710
711 assert!(!is_readonly_tool("ctx_edit"));
712 assert!(!is_readonly_tool("ctx_shell"));
713 assert!(!is_readonly_tool("ctx_patch"));
714 assert!(!is_readonly_tool("ctx_execute"));
715 assert!(!is_readonly_tool("ctx_work_graph"));
716 }
717
718 #[test]
719 fn plan_mode_tools_are_all_readonly() {
720 for tool in crate::core::editor_registry::plan_mode::plan_mode_tools() {
721 assert!(
722 is_readonly_tool(tool),
723 "{tool} is listed as plan mode tool but not marked readonly"
724 );
725 }
726 }
727}