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