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