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    pub fn all_enabled() -> Self {
159        let mut active = HashSet::new();
160        active.insert(ToolCategory::Core);
161        active.insert(ToolCategory::Arch);
162        active.insert(ToolCategory::Debug);
163        active.insert(ToolCategory::Memory);
164        active.insert(ToolCategory::Metrics);
165        active.insert(ToolCategory::Session);
166        Self {
167            active_categories: active,
168            supports_list_changed: false,
169        }
170    }
171
172    pub fn set_supports_list_changed(&mut self, val: bool) {
173        self.supports_list_changed = val;
174    }
175
176    pub fn supports_list_changed(&self) -> bool {
177        self.supports_list_changed
178    }
179
180    pub fn load_category(&mut self, cat: ToolCategory) -> bool {
181        self.active_categories.insert(cat)
182    }
183
184    pub fn unload_category(&mut self, cat: ToolCategory) -> bool {
185        if cat == ToolCategory::Core || cat == ToolCategory::Internal {
186            return false;
187        }
188        self.active_categories.remove(&cat)
189    }
190
191    pub fn is_tool_active(&self, name: &str) -> bool {
192        let cat = categorize_tool(name);
193        if cat == ToolCategory::Internal {
194            return false;
195        }
196        if !self.supports_list_changed {
197            return true;
198        }
199        self.active_categories.contains(&cat)
200    }
201
202    pub fn active_categories(&self) -> Vec<&'static str> {
203        let mut cats: Vec<_> = self
204            .active_categories
205            .iter()
206            .map(ToolCategory::as_str)
207            .collect();
208        cats.sort_unstable();
209        cats
210    }
211
212    pub fn all_categories() -> Vec<&'static str> {
213        vec!["core", "arch", "debug", "memory", "metrics", "session"]
214    }
215}
216
217static GLOBAL: OnceLock<Mutex<DynamicToolState>> = OnceLock::new();
218
219pub fn global() -> &'static Mutex<DynamicToolState> {
220    GLOBAL.get_or_init(|| Mutex::new(DynamicToolState::new()))
221}
222
223pub fn init_all_enabled() {
224    let _ = GLOBAL.set(Mutex::new(DynamicToolState::all_enabled()));
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn core_tools_always_active() {
233        let state = DynamicToolState::new();
234        assert!(state.is_tool_active("ctx_read"));
235        assert!(state.is_tool_active("ctx_search"));
236    }
237
238    #[test]
239    fn dynamic_tools_filtered_when_list_changed() {
240        let mut state = DynamicToolState::new();
241        state.set_supports_list_changed(true);
242        assert!(!state.is_tool_active("ctx_benchmark"));
243        assert!(!state.is_tool_active("ctx_architecture"));
244        assert!(state.is_tool_active("ctx_read"));
245    }
246
247    #[test]
248    fn load_category_enables_tools() {
249        let mut state = DynamicToolState::new();
250        state.set_supports_list_changed(true);
251        assert!(!state.is_tool_active("ctx_architecture"));
252        state.load_category(ToolCategory::Arch);
253        assert!(state.is_tool_active("ctx_architecture"));
254    }
255
256    #[test]
257    fn cannot_unload_core() {
258        let mut state = DynamicToolState::new();
259        assert!(!state.unload_category(ToolCategory::Core));
260    }
261
262    #[test]
263    fn all_tools_visible_without_list_changed() {
264        let state = DynamicToolState::new();
265        assert!(state.is_tool_active("ctx_graph"));
266        assert!(!state.is_tool_active("ctx_metrics")); // Internal tools never active
267    }
268
269    #[test]
270    fn internal_tools_never_active() {
271        let state = DynamicToolState::all_enabled();
272        assert!(!state.is_tool_active("ctx_metrics"));
273        assert!(!state.is_tool_active("ctx_cost"));
274        assert!(!state.is_tool_active("ctx_discover_tools"));
275        assert!(!state.is_tool_active("ctx_dedup"));
276    }
277
278    #[test]
279    fn categorize_known_tools() {
280        assert_eq!(categorize_tool("ctx_read"), ToolCategory::Core);
281        assert_eq!(categorize_tool("ctx_graph"), ToolCategory::Core);
282        assert_eq!(categorize_tool("ctx_benchmark"), ToolCategory::Debug);
283        assert_eq!(categorize_tool("ctx_semantic_search"), ToolCategory::Memory);
284        assert_eq!(categorize_tool("ctx_metrics"), ToolCategory::Internal);
285        assert_eq!(categorize_tool("ctx_workflow"), ToolCategory::Session);
286    }
287
288    #[test]
289    fn readonly_classification() {
290        assert!(is_readonly_tool("ctx_read"));
291        assert!(is_readonly_tool("ctx_search"));
292        assert!(is_readonly_tool("ctx_tree"));
293        assert!(is_readonly_tool("ctx_overview"));
294        assert!(is_readonly_tool("ctx_provider"));
295
296        assert!(!is_readonly_tool("ctx_edit"));
297        assert!(!is_readonly_tool("ctx_shell"));
298        assert!(!is_readonly_tool("ctx_compile"));
299        assert!(!is_readonly_tool("ctx_execute"));
300        assert!(!is_readonly_tool("ctx_cache"));
301    }
302
303    #[test]
304    fn plan_mode_tools_are_all_readonly() {
305        for tool in crate::core::editor_registry::plan_mode::plan_mode_tools() {
306            assert!(
307                is_readonly_tool(tool),
308                "{tool} is listed as plan mode tool but not marked readonly"
309            );
310        }
311    }
312}