Skip to main content

lean_ctx/server/
dynamic_tools.rs

1use std::collections::HashSet;
2use std::sync::{Mutex, OnceLock};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
5pub enum ToolCategory {
6    Core,
7    Internal,
8    Arch,
9    Debug,
10    Memory,
11    Metrics,
12    Session,
13}
14
15impl ToolCategory {
16    pub fn parse(s: &str) -> Option<Self> {
17        match s {
18            "core" => Some(Self::Core),
19            "arch" | "architecture" => Some(Self::Arch),
20            "debug" | "profiling" => Some(Self::Debug),
21            "memory" | "semantic" => Some(Self::Memory),
22            "metrics" | "stats" => Some(Self::Metrics),
23            "session" => Some(Self::Session),
24            _ => None,
25        }
26    }
27
28    pub fn as_str(&self) -> &'static str {
29        match self {
30            Self::Core => "core",
31            Self::Internal => "internal",
32            Self::Arch => "arch",
33            Self::Debug => "debug",
34            Self::Memory => "memory",
35            Self::Metrics => "metrics",
36            Self::Session => "session",
37        }
38    }
39}
40
41#[allow(clippy::match_same_arms)]
42pub fn categorize_tool(name: &str) -> ToolCategory {
43    match name {
44        // Internal: meta/self-referential + automated mechanisms (never exposed)
45        "ctx_metrics"
46        | "ctx_cost"
47        | "ctx_gain"
48        | "ctx_radar"
49        | "ctx_heatmap"
50        | "ctx_feedback"
51        | "ctx_intent"
52        | "ctx_response"
53        | "ctx_discover"
54        | "ctx_discover_tools"
55        | "ctx_load_tools"
56        | "ctx_dedup"
57        | "ctx_preload"
58        | "ctx_prefetch"
59        | "ctx_compress_memory" => ToolCategory::Internal,
60
61        // Core: always visible
62        "ctx_read" | "ctx_search" | "ctx_shell" | "shell" | "ctx_tree" | "ctx_edit"
63        | "ctx_session" | "ctx_checkpoint" | "ctx_knowledge" | "ctx_overview" | "ctx_graph"
64        | "ctx_call" | "ctx_compress" | "ctx_cache" | "ctx_retrieve" => ToolCategory::Core,
65
66        // Merged tools (redirects in registry, treated as Core for backward compat)
67        "ctx_multi_read" | "ctx_smart_read" | "ctx_delta" | "ctx_outline" | "ctx_context" => {
68            ToolCategory::Core
69        }
70
71        // Arch: on-demand architecture analysis
72        "ctx_architecture" | "ctx_impact" | "ctx_callgraph" | "ctx_refactor" | "ctx_symbol"
73        | "ctx_routes" | "ctx_smells" | "ctx_index" => ToolCategory::Arch,
74
75        // Debug/Verify: on-demand quality analysis
76        "ctx_benchmark" | "ctx_verify" | "ctx_analyze" | "ctx_profile" | "ctx_proof"
77        | "ctx_review" => ToolCategory::Debug,
78
79        // Provider + URL/Git readers + the MCP gateway are Core: gateways to
80        // external context (GitHub issues, Jira, Postgres, web pages, YouTube,
81        // remote git repos, downstream MCP servers) — always available.
82        "ctx_provider" | "ctx_url_read" | "ctx_git_read" | "ctx_tools" => ToolCategory::Core,
83
84        // Memory: on-demand semantic tools
85        "ctx_semantic_search" | "ctx_artifacts" => ToolCategory::Memory,
86
87        // Batch: on-demand batch/PR/sandbox tools
88        "ctx_fill" | "ctx_execute" | "ctx_expand" | "ctx_pack" | "ctx_plan" | "ctx_control"
89        | "ctx_compile" => ToolCategory::Metrics,
90
91        // Multi-agent: on-demand collaboration
92        "ctx_agent" | "ctx_share" | "ctx_task" | "ctx_handoff" | "ctx_workflow" => {
93            ToolCategory::Session
94        }
95
96        _ => ToolCategory::Core,
97    }
98}
99
100pub fn is_readonly_tool(name: &str) -> bool {
101    matches!(
102        name,
103        "ctx_read"
104            | "ctx_search"
105            | "ctx_tree"
106            | "ctx_overview"
107            | "ctx_plan"
108            | "ctx_metrics"
109            | "ctx_compress"
110            | "ctx_session"
111            | "ctx_knowledge"
112            | "ctx_graph"
113            | "ctx_retrieve"
114            | "ctx_provider"
115            | "ctx_multi_read"
116            | "ctx_smart_read"
117            | "ctx_delta"
118            | "ctx_outline"
119            | "ctx_context"
120            | "ctx_call"
121            | "ctx_url_read"
122            | "ctx_git_read"
123            | "ctx_architecture"
124            | "ctx_impact"
125            | "ctx_callgraph"
126            | "ctx_symbol"
127            | "ctx_routes"
128            | "ctx_smells"
129            | "ctx_index"
130            | "ctx_semantic_search"
131            | "ctx_artifacts"
132            | "ctx_cost"
133            | "ctx_gain"
134            | "ctx_heatmap"
135    )
136}
137
138#[derive(Debug)]
139pub struct DynamicToolState {
140    active_categories: HashSet<ToolCategory>,
141    supports_list_changed: bool,
142}
143
144impl Default for DynamicToolState {
145    fn default() -> Self {
146        Self::new()
147    }
148}
149
150impl DynamicToolState {
151    pub fn new() -> Self {
152        let mut active = HashSet::new();
153        active.insert(ToolCategory::Core);
154        active.insert(ToolCategory::Session);
155        Self {
156            active_categories: active,
157            supports_list_changed: false,
158        }
159    }
160
161    /// Creates state with categories from config (env var > config.toml > default).
162    pub fn from_config(categories: &[String]) -> Self {
163        let mut active = HashSet::new();
164        active.insert(ToolCategory::Core);
165        for cat_str in categories {
166            if let Some(cat) = ToolCategory::parse(cat_str) {
167                active.insert(cat);
168            }
169        }
170        Self {
171            active_categories: active,
172            supports_list_changed: false,
173        }
174    }
175
176    pub fn all_enabled() -> Self {
177        let mut active = HashSet::new();
178        active.insert(ToolCategory::Core);
179        active.insert(ToolCategory::Arch);
180        active.insert(ToolCategory::Debug);
181        active.insert(ToolCategory::Memory);
182        active.insert(ToolCategory::Metrics);
183        active.insert(ToolCategory::Session);
184        Self {
185            active_categories: active,
186            supports_list_changed: false,
187        }
188    }
189
190    pub fn set_supports_list_changed(&mut self, val: bool) {
191        self.supports_list_changed = val;
192    }
193
194    pub fn supports_list_changed(&self) -> bool {
195        self.supports_list_changed
196    }
197
198    pub fn load_category(&mut self, cat: ToolCategory) -> bool {
199        self.active_categories.insert(cat)
200    }
201
202    pub fn unload_category(&mut self, cat: ToolCategory) -> bool {
203        if cat == ToolCategory::Core || cat == ToolCategory::Internal {
204            return false;
205        }
206        self.active_categories.remove(&cat)
207    }
208
209    pub fn is_tool_active(&self, name: &str) -> bool {
210        let cat = categorize_tool(name);
211        if cat == ToolCategory::Internal {
212            return false;
213        }
214        if !self.supports_list_changed {
215            return true;
216        }
217        self.active_categories.contains(&cat)
218    }
219
220    pub fn active_categories(&self) -> Vec<&'static str> {
221        let mut cats: Vec<_> = self
222            .active_categories
223            .iter()
224            .map(ToolCategory::as_str)
225            .collect();
226        cats.sort_unstable();
227        cats
228    }
229
230    pub fn all_categories() -> Vec<&'static str> {
231        vec!["core", "arch", "debug", "memory", "metrics", "session"]
232    }
233}
234
235static GLOBAL: OnceLock<Mutex<DynamicToolState>> = OnceLock::new();
236
237pub fn global() -> &'static Mutex<DynamicToolState> {
238    GLOBAL.get_or_init(|| Mutex::new(DynamicToolState::new()))
239}
240
241pub fn init_all_enabled() {
242    let _ = GLOBAL.set(Mutex::new(DynamicToolState::all_enabled()));
243}
244
245/// Initializes the global state from user config (env var > config.toml > default).
246/// Call once during server startup after config is loaded.
247/// If the global was already initialized (e.g. by a concurrent `global()` call),
248/// applies the categories to the existing state instead.
249pub fn init_from_config(categories: &[String]) {
250    if GLOBAL
251        .set(Mutex::new(DynamicToolState::from_config(categories)))
252        .is_err()
253    {
254        if let Ok(mut state) = global().lock() {
255            let desired = DynamicToolState::from_config(categories);
256            *state = desired;
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn core_tools_always_active() {
267        let state = DynamicToolState::new();
268        assert!(state.is_tool_active("ctx_read"));
269        assert!(state.is_tool_active("ctx_search"));
270    }
271
272    #[test]
273    fn dynamic_tools_filtered_when_list_changed() {
274        let mut state = DynamicToolState::new();
275        state.set_supports_list_changed(true);
276        assert!(!state.is_tool_active("ctx_benchmark"));
277        assert!(!state.is_tool_active("ctx_architecture"));
278        assert!(state.is_tool_active("ctx_read"));
279    }
280
281    #[test]
282    fn load_category_enables_tools() {
283        let mut state = DynamicToolState::new();
284        state.set_supports_list_changed(true);
285        assert!(!state.is_tool_active("ctx_architecture"));
286        state.load_category(ToolCategory::Arch);
287        assert!(state.is_tool_active("ctx_architecture"));
288    }
289
290    #[test]
291    fn cannot_unload_core() {
292        let mut state = DynamicToolState::new();
293        assert!(!state.unload_category(ToolCategory::Core));
294    }
295
296    #[test]
297    fn all_tools_visible_without_list_changed() {
298        let state = DynamicToolState::new();
299        assert!(state.is_tool_active("ctx_graph"));
300        assert!(!state.is_tool_active("ctx_metrics")); // Internal tools never active
301    }
302
303    #[test]
304    fn internal_tools_never_active() {
305        let state = DynamicToolState::all_enabled();
306        assert!(!state.is_tool_active("ctx_metrics"));
307        assert!(!state.is_tool_active("ctx_cost"));
308        assert!(!state.is_tool_active("ctx_discover_tools"));
309        assert!(!state.is_tool_active("ctx_dedup"));
310    }
311
312    // --- from_config: basic scenarios ---
313
314    #[test]
315    fn from_config_core_arch_memory() {
316        let cats = vec!["core".to_string(), "arch".to_string(), "memory".to_string()];
317        let mut state = DynamicToolState::from_config(&cats);
318        state.set_supports_list_changed(true);
319        assert!(state.is_tool_active("ctx_read"));
320        assert!(state.is_tool_active("ctx_architecture"));
321        assert!(state.is_tool_active("ctx_semantic_search"));
322        assert!(!state.is_tool_active("ctx_benchmark"));
323        assert!(!state.is_tool_active("ctx_fill"));
324    }
325
326    #[test]
327    fn from_config_empty_still_has_core() {
328        let mut state = DynamicToolState::from_config(&[]);
329        state.set_supports_list_changed(true);
330        assert!(state.is_tool_active("ctx_read"));
331        assert!(!state.is_tool_active("ctx_architecture"));
332        assert!(!state.is_tool_active("ctx_benchmark"));
333        assert!(!state.is_tool_active("ctx_semantic_search"));
334    }
335
336    // --- from_config: all categories ---
337
338    #[test]
339    fn from_config_all_categories_enables_everything_except_internal() {
340        let cats = vec![
341            "core".to_string(),
342            "arch".to_string(),
343            "debug".to_string(),
344            "memory".to_string(),
345            "metrics".to_string(),
346            "session".to_string(),
347        ];
348        let mut state = DynamicToolState::from_config(&cats);
349        state.set_supports_list_changed(true);
350        assert!(state.is_tool_active("ctx_read"));
351        assert!(state.is_tool_active("ctx_architecture"));
352        assert!(state.is_tool_active("ctx_benchmark"));
353        assert!(state.is_tool_active("ctx_semantic_search"));
354        assert!(state.is_tool_active("ctx_fill"));
355        assert!(state.is_tool_active("ctx_workflow"));
356        assert!(!state.is_tool_active("ctx_metrics"));
357    }
358
359    // --- from_config: single category ---
360
361    #[test]
362    fn from_config_only_debug() {
363        let cats = vec!["debug".to_string()];
364        let mut state = DynamicToolState::from_config(&cats);
365        state.set_supports_list_changed(true);
366        assert!(state.is_tool_active("ctx_read"));
367        assert!(state.is_tool_active("ctx_benchmark"));
368        assert!(!state.is_tool_active("ctx_architecture"));
369        assert!(!state.is_tool_active("ctx_workflow"));
370    }
371
372    // --- from_config: invalid categories are silently ignored ---
373
374    #[test]
375    fn from_config_ignores_unknown_categories() {
376        let cats = vec![
377            "core".to_string(),
378            "nonexistent".to_string(),
379            "foobar".to_string(),
380        ];
381        let mut state = DynamicToolState::from_config(&cats);
382        state.set_supports_list_changed(true);
383        assert!(state.is_tool_active("ctx_read"));
384        assert!(!state.is_tool_active("ctx_architecture"));
385    }
386
387    #[test]
388    fn from_config_only_invalid_still_has_core() {
389        let cats = vec!["invalid".to_string(), "bogus".to_string()];
390        let mut state = DynamicToolState::from_config(&cats);
391        state.set_supports_list_changed(true);
392        assert!(state.is_tool_active("ctx_read"));
393        assert!(!state.is_tool_active("ctx_benchmark"));
394    }
395
396    // --- from_config: duplicate categories are idempotent ---
397
398    #[test]
399    fn from_config_duplicates_are_harmless() {
400        let cats = vec!["arch".to_string(), "arch".to_string(), "arch".to_string()];
401        let mut state = DynamicToolState::from_config(&cats);
402        state.set_supports_list_changed(true);
403        assert!(state.is_tool_active("ctx_architecture"));
404        let active = state.active_categories();
405        let arch_count = active.iter().filter(|&&c| c == "arch").count();
406        assert_eq!(arch_count, 1);
407    }
408
409    // --- from_config: internal category is never user-activatable ---
410
411    #[test]
412    fn from_config_internal_category_not_parseable() {
413        assert!(ToolCategory::parse("internal").is_none());
414    }
415
416    // --- from_config: category aliases work ---
417
418    #[test]
419    fn from_config_alias_architecture_maps_to_arch() {
420        let cats = vec!["architecture".to_string()];
421        let mut state = DynamicToolState::from_config(&cats);
422        state.set_supports_list_changed(true);
423        assert!(state.is_tool_active("ctx_architecture"));
424    }
425
426    #[test]
427    fn from_config_alias_profiling_maps_to_debug() {
428        let cats = vec!["profiling".to_string()];
429        let mut state = DynamicToolState::from_config(&cats);
430        state.set_supports_list_changed(true);
431        assert!(state.is_tool_active("ctx_benchmark"));
432    }
433
434    #[test]
435    fn from_config_alias_semantic_maps_to_memory() {
436        let cats = vec!["semantic".to_string()];
437        let mut state = DynamicToolState::from_config(&cats);
438        state.set_supports_list_changed(true);
439        assert!(state.is_tool_active("ctx_semantic_search"));
440    }
441
442    // --- from_config: subsequent load/unload still works ---
443
444    #[test]
445    fn from_config_then_load_additional_category() {
446        let cats = vec!["core".to_string()];
447        let mut state = DynamicToolState::from_config(&cats);
448        state.set_supports_list_changed(true);
449        assert!(!state.is_tool_active("ctx_architecture"));
450        state.load_category(ToolCategory::Arch);
451        assert!(state.is_tool_active("ctx_architecture"));
452    }
453
454    #[test]
455    fn from_config_then_unload_non_core_category() {
456        let cats = vec!["core".to_string(), "arch".to_string()];
457        let mut state = DynamicToolState::from_config(&cats);
458        state.set_supports_list_changed(true);
459        assert!(state.is_tool_active("ctx_architecture"));
460        state.unload_category(ToolCategory::Arch);
461        assert!(!state.is_tool_active("ctx_architecture"));
462    }
463
464    #[test]
465    fn from_config_cannot_unload_core() {
466        let cats = vec!["core".to_string(), "arch".to_string()];
467        let mut state = DynamicToolState::from_config(&cats);
468        assert!(!state.unload_category(ToolCategory::Core));
469    }
470
471    // --- from_config: without list_changed, all tools visible ---
472
473    #[test]
474    fn from_config_without_list_changed_shows_all() {
475        let cats = vec!["core".to_string()];
476        let state = DynamicToolState::from_config(&cats);
477        assert!(state.is_tool_active("ctx_architecture"));
478        assert!(state.is_tool_active("ctx_benchmark"));
479        assert!(!state.is_tool_active("ctx_metrics"));
480    }
481
482    #[test]
483    fn categorize_known_tools() {
484        assert_eq!(categorize_tool("ctx_read"), ToolCategory::Core);
485        assert_eq!(categorize_tool("ctx_graph"), ToolCategory::Core);
486        assert_eq!(categorize_tool("ctx_benchmark"), ToolCategory::Debug);
487        assert_eq!(categorize_tool("ctx_semantic_search"), ToolCategory::Memory);
488        assert_eq!(categorize_tool("ctx_metrics"), ToolCategory::Internal);
489        assert_eq!(categorize_tool("ctx_workflow"), ToolCategory::Session);
490    }
491
492    #[test]
493    fn readonly_classification() {
494        assert!(is_readonly_tool("ctx_read"));
495        assert!(is_readonly_tool("ctx_search"));
496        assert!(is_readonly_tool("ctx_tree"));
497        assert!(is_readonly_tool("ctx_overview"));
498        assert!(is_readonly_tool("ctx_provider"));
499
500        assert!(!is_readonly_tool("ctx_edit"));
501        assert!(!is_readonly_tool("ctx_shell"));
502        assert!(!is_readonly_tool("ctx_compile"));
503        assert!(!is_readonly_tool("ctx_execute"));
504        assert!(!is_readonly_tool("ctx_cache"));
505    }
506
507    #[test]
508    fn plan_mode_tools_are_all_readonly() {
509        for tool in crate::core::editor_registry::plan_mode::plan_mode_tools() {
510            assert!(
511                is_readonly_tool(tool),
512                "{tool} is listed as plan mode tool but not marked readonly"
513            );
514        }
515    }
516}