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 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
242pub 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")); }
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 #[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 #[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 #[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 #[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 #[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 #[test]
409 fn from_config_internal_category_not_parseable() {
410 assert!(ToolCategory::parse("internal").is_none());
411 }
412
413 #[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 #[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 #[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}