lean_ctx/server/
dynamic_tools.rs1use 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 "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 "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 "ctx_multi_read" | "ctx_smart_read" | "ctx_delta" | "ctx_outline" | "ctx_context" => {
68 ToolCategory::Core
69 }
70
71 "ctx_architecture" | "ctx_impact" | "ctx_callgraph" | "ctx_refactor" | "ctx_symbol"
73 | "ctx_routes" | "ctx_smells" | "ctx_index" => ToolCategory::Arch,
74
75 "ctx_benchmark" | "ctx_verify" | "ctx_analyze" | "ctx_profile" | "ctx_proof"
77 | "ctx_review" => ToolCategory::Debug,
78
79 "ctx_provider" => ToolCategory::Core,
82
83 "ctx_semantic_search" | "ctx_artifacts" => ToolCategory::Memory,
85
86 "ctx_fill" | "ctx_execute" | "ctx_expand" | "ctx_pack" | "ctx_plan" | "ctx_control"
88 | "ctx_compile" => ToolCategory::Metrics,
89
90 "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")); }
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}