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