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_checkpoint" | "ctx_knowledge" | "ctx_overview" | "ctx_graph"
64 | "ctx_call" | "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" | "ctx_git_read" | "ctx_tools" => ToolCategory::Core,
83
84 "ctx_semantic_search" | "ctx_artifacts" => ToolCategory::Memory,
86
87 "ctx_fill" | "ctx_execute" | "ctx_expand" | "ctx_pack" | "ctx_plan" | "ctx_control"
89 | "ctx_compile" => ToolCategory::Metrics,
90
91 "ctx_agent" | "ctx_share" | "ctx_task" | "ctx_handoff" | "ctx_workflow" => {
93 ToolCategory::Session
94 }
95
96 _ => ToolCategory::Core,
97 }
98}
99
100pub fn is_readonly_tool(name: &str) -> bool {
101 matches!(
102 name,
103 "ctx_read"
104 | "ctx_search"
105 | "ctx_tree"
106 | "ctx_overview"
107 | "ctx_plan"
108 | "ctx_metrics"
109 | "ctx_compress"
110 | "ctx_session"
111 | "ctx_knowledge"
112 | "ctx_graph"
113 | "ctx_retrieve"
114 | "ctx_provider"
115 | "ctx_multi_read"
116 | "ctx_smart_read"
117 | "ctx_delta"
118 | "ctx_outline"
119 | "ctx_context"
120 | "ctx_call"
121 | "ctx_url_read"
122 | "ctx_git_read"
123 | "ctx_architecture"
124 | "ctx_impact"
125 | "ctx_callgraph"
126 | "ctx_symbol"
127 | "ctx_routes"
128 | "ctx_smells"
129 | "ctx_index"
130 | "ctx_semantic_search"
131 | "ctx_artifacts"
132 | "ctx_cost"
133 | "ctx_gain"
134 | "ctx_heatmap"
135 )
136}
137
138#[derive(Debug)]
139pub struct DynamicToolState {
140 active_categories: HashSet<ToolCategory>,
141 supports_list_changed: bool,
142}
143
144impl Default for DynamicToolState {
145 fn default() -> Self {
146 Self::new()
147 }
148}
149
150impl DynamicToolState {
151 pub fn new() -> Self {
152 let mut active = HashSet::new();
153 active.insert(ToolCategory::Core);
154 active.insert(ToolCategory::Session);
155 Self {
156 active_categories: active,
157 supports_list_changed: false,
158 }
159 }
160
161 pub fn from_config(categories: &[String]) -> Self {
163 let mut active = HashSet::new();
164 active.insert(ToolCategory::Core);
165 for cat_str in categories {
166 if let Some(cat) = ToolCategory::parse(cat_str) {
167 active.insert(cat);
168 }
169 }
170 Self {
171 active_categories: active,
172 supports_list_changed: false,
173 }
174 }
175
176 pub fn all_enabled() -> Self {
177 let mut active = HashSet::new();
178 active.insert(ToolCategory::Core);
179 active.insert(ToolCategory::Arch);
180 active.insert(ToolCategory::Debug);
181 active.insert(ToolCategory::Memory);
182 active.insert(ToolCategory::Metrics);
183 active.insert(ToolCategory::Session);
184 Self {
185 active_categories: active,
186 supports_list_changed: false,
187 }
188 }
189
190 pub fn set_supports_list_changed(&mut self, val: bool) {
191 self.supports_list_changed = val;
192 }
193
194 pub fn supports_list_changed(&self) -> bool {
195 self.supports_list_changed
196 }
197
198 pub fn load_category(&mut self, cat: ToolCategory) -> bool {
199 self.active_categories.insert(cat)
200 }
201
202 pub fn unload_category(&mut self, cat: ToolCategory) -> bool {
203 if cat == ToolCategory::Core || cat == ToolCategory::Internal {
204 return false;
205 }
206 self.active_categories.remove(&cat)
207 }
208
209 pub fn is_tool_active(&self, name: &str) -> bool {
210 let cat = categorize_tool(name);
211 if cat == ToolCategory::Internal {
212 return false;
213 }
214 if !self.supports_list_changed {
215 return true;
216 }
217 self.active_categories.contains(&cat)
218 }
219
220 pub fn active_categories(&self) -> Vec<&'static str> {
221 let mut cats: Vec<_> = self
222 .active_categories
223 .iter()
224 .map(ToolCategory::as_str)
225 .collect();
226 cats.sort_unstable();
227 cats
228 }
229
230 pub fn all_categories() -> Vec<&'static str> {
231 vec!["core", "arch", "debug", "memory", "metrics", "session"]
232 }
233}
234
235static GLOBAL: OnceLock<Mutex<DynamicToolState>> = OnceLock::new();
236
237pub fn global() -> &'static Mutex<DynamicToolState> {
238 GLOBAL.get_or_init(|| Mutex::new(DynamicToolState::new()))
239}
240
241pub fn init_all_enabled() {
242 let _ = GLOBAL.set(Mutex::new(DynamicToolState::all_enabled()));
243}
244
245pub fn init_from_config(categories: &[String]) {
250 if GLOBAL
251 .set(Mutex::new(DynamicToolState::from_config(categories)))
252 .is_err()
253 {
254 if let Ok(mut state) = global().lock() {
255 let desired = DynamicToolState::from_config(categories);
256 *state = desired;
257 }
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn core_tools_always_active() {
267 let state = DynamicToolState::new();
268 assert!(state.is_tool_active("ctx_read"));
269 assert!(state.is_tool_active("ctx_search"));
270 }
271
272 #[test]
273 fn dynamic_tools_filtered_when_list_changed() {
274 let mut state = DynamicToolState::new();
275 state.set_supports_list_changed(true);
276 assert!(!state.is_tool_active("ctx_benchmark"));
277 assert!(!state.is_tool_active("ctx_architecture"));
278 assert!(state.is_tool_active("ctx_read"));
279 }
280
281 #[test]
282 fn load_category_enables_tools() {
283 let mut state = DynamicToolState::new();
284 state.set_supports_list_changed(true);
285 assert!(!state.is_tool_active("ctx_architecture"));
286 state.load_category(ToolCategory::Arch);
287 assert!(state.is_tool_active("ctx_architecture"));
288 }
289
290 #[test]
291 fn cannot_unload_core() {
292 let mut state = DynamicToolState::new();
293 assert!(!state.unload_category(ToolCategory::Core));
294 }
295
296 #[test]
297 fn all_tools_visible_without_list_changed() {
298 let state = DynamicToolState::new();
299 assert!(state.is_tool_active("ctx_graph"));
300 assert!(!state.is_tool_active("ctx_metrics")); }
302
303 #[test]
304 fn internal_tools_never_active() {
305 let state = DynamicToolState::all_enabled();
306 assert!(!state.is_tool_active("ctx_metrics"));
307 assert!(!state.is_tool_active("ctx_cost"));
308 assert!(!state.is_tool_active("ctx_discover_tools"));
309 assert!(!state.is_tool_active("ctx_dedup"));
310 }
311
312 #[test]
315 fn from_config_core_arch_memory() {
316 let cats = vec!["core".to_string(), "arch".to_string(), "memory".to_string()];
317 let mut state = DynamicToolState::from_config(&cats);
318 state.set_supports_list_changed(true);
319 assert!(state.is_tool_active("ctx_read"));
320 assert!(state.is_tool_active("ctx_architecture"));
321 assert!(state.is_tool_active("ctx_semantic_search"));
322 assert!(!state.is_tool_active("ctx_benchmark"));
323 assert!(!state.is_tool_active("ctx_fill"));
324 }
325
326 #[test]
327 fn from_config_empty_still_has_core() {
328 let mut state = DynamicToolState::from_config(&[]);
329 state.set_supports_list_changed(true);
330 assert!(state.is_tool_active("ctx_read"));
331 assert!(!state.is_tool_active("ctx_architecture"));
332 assert!(!state.is_tool_active("ctx_benchmark"));
333 assert!(!state.is_tool_active("ctx_semantic_search"));
334 }
335
336 #[test]
339 fn from_config_all_categories_enables_everything_except_internal() {
340 let cats = vec![
341 "core".to_string(),
342 "arch".to_string(),
343 "debug".to_string(),
344 "memory".to_string(),
345 "metrics".to_string(),
346 "session".to_string(),
347 ];
348 let mut state = DynamicToolState::from_config(&cats);
349 state.set_supports_list_changed(true);
350 assert!(state.is_tool_active("ctx_read"));
351 assert!(state.is_tool_active("ctx_architecture"));
352 assert!(state.is_tool_active("ctx_benchmark"));
353 assert!(state.is_tool_active("ctx_semantic_search"));
354 assert!(state.is_tool_active("ctx_fill"));
355 assert!(state.is_tool_active("ctx_workflow"));
356 assert!(!state.is_tool_active("ctx_metrics"));
357 }
358
359 #[test]
362 fn from_config_only_debug() {
363 let cats = vec!["debug".to_string()];
364 let mut state = DynamicToolState::from_config(&cats);
365 state.set_supports_list_changed(true);
366 assert!(state.is_tool_active("ctx_read"));
367 assert!(state.is_tool_active("ctx_benchmark"));
368 assert!(!state.is_tool_active("ctx_architecture"));
369 assert!(!state.is_tool_active("ctx_workflow"));
370 }
371
372 #[test]
375 fn from_config_ignores_unknown_categories() {
376 let cats = vec![
377 "core".to_string(),
378 "nonexistent".to_string(),
379 "foobar".to_string(),
380 ];
381 let mut state = DynamicToolState::from_config(&cats);
382 state.set_supports_list_changed(true);
383 assert!(state.is_tool_active("ctx_read"));
384 assert!(!state.is_tool_active("ctx_architecture"));
385 }
386
387 #[test]
388 fn from_config_only_invalid_still_has_core() {
389 let cats = vec!["invalid".to_string(), "bogus".to_string()];
390 let mut state = DynamicToolState::from_config(&cats);
391 state.set_supports_list_changed(true);
392 assert!(state.is_tool_active("ctx_read"));
393 assert!(!state.is_tool_active("ctx_benchmark"));
394 }
395
396 #[test]
399 fn from_config_duplicates_are_harmless() {
400 let cats = vec!["arch".to_string(), "arch".to_string(), "arch".to_string()];
401 let mut state = DynamicToolState::from_config(&cats);
402 state.set_supports_list_changed(true);
403 assert!(state.is_tool_active("ctx_architecture"));
404 let active = state.active_categories();
405 let arch_count = active.iter().filter(|&&c| c == "arch").count();
406 assert_eq!(arch_count, 1);
407 }
408
409 #[test]
412 fn from_config_internal_category_not_parseable() {
413 assert!(ToolCategory::parse("internal").is_none());
414 }
415
416 #[test]
419 fn from_config_alias_architecture_maps_to_arch() {
420 let cats = vec!["architecture".to_string()];
421 let mut state = DynamicToolState::from_config(&cats);
422 state.set_supports_list_changed(true);
423 assert!(state.is_tool_active("ctx_architecture"));
424 }
425
426 #[test]
427 fn from_config_alias_profiling_maps_to_debug() {
428 let cats = vec!["profiling".to_string()];
429 let mut state = DynamicToolState::from_config(&cats);
430 state.set_supports_list_changed(true);
431 assert!(state.is_tool_active("ctx_benchmark"));
432 }
433
434 #[test]
435 fn from_config_alias_semantic_maps_to_memory() {
436 let cats = vec!["semantic".to_string()];
437 let mut state = DynamicToolState::from_config(&cats);
438 state.set_supports_list_changed(true);
439 assert!(state.is_tool_active("ctx_semantic_search"));
440 }
441
442 #[test]
445 fn from_config_then_load_additional_category() {
446 let cats = vec!["core".to_string()];
447 let mut state = DynamicToolState::from_config(&cats);
448 state.set_supports_list_changed(true);
449 assert!(!state.is_tool_active("ctx_architecture"));
450 state.load_category(ToolCategory::Arch);
451 assert!(state.is_tool_active("ctx_architecture"));
452 }
453
454 #[test]
455 fn from_config_then_unload_non_core_category() {
456 let cats = vec!["core".to_string(), "arch".to_string()];
457 let mut state = DynamicToolState::from_config(&cats);
458 state.set_supports_list_changed(true);
459 assert!(state.is_tool_active("ctx_architecture"));
460 state.unload_category(ToolCategory::Arch);
461 assert!(!state.is_tool_active("ctx_architecture"));
462 }
463
464 #[test]
465 fn from_config_cannot_unload_core() {
466 let cats = vec!["core".to_string(), "arch".to_string()];
467 let mut state = DynamicToolState::from_config(&cats);
468 assert!(!state.unload_category(ToolCategory::Core));
469 }
470
471 #[test]
474 fn from_config_without_list_changed_shows_all() {
475 let cats = vec!["core".to_string()];
476 let state = DynamicToolState::from_config(&cats);
477 assert!(state.is_tool_active("ctx_architecture"));
478 assert!(state.is_tool_active("ctx_benchmark"));
479 assert!(!state.is_tool_active("ctx_metrics"));
480 }
481
482 #[test]
483 fn categorize_known_tools() {
484 assert_eq!(categorize_tool("ctx_read"), ToolCategory::Core);
485 assert_eq!(categorize_tool("ctx_graph"), ToolCategory::Core);
486 assert_eq!(categorize_tool("ctx_benchmark"), ToolCategory::Debug);
487 assert_eq!(categorize_tool("ctx_semantic_search"), ToolCategory::Memory);
488 assert_eq!(categorize_tool("ctx_metrics"), ToolCategory::Internal);
489 assert_eq!(categorize_tool("ctx_workflow"), ToolCategory::Session);
490 }
491
492 #[test]
493 fn readonly_classification() {
494 assert!(is_readonly_tool("ctx_read"));
495 assert!(is_readonly_tool("ctx_search"));
496 assert!(is_readonly_tool("ctx_tree"));
497 assert!(is_readonly_tool("ctx_overview"));
498 assert!(is_readonly_tool("ctx_provider"));
499
500 assert!(!is_readonly_tool("ctx_edit"));
501 assert!(!is_readonly_tool("ctx_shell"));
502 assert!(!is_readonly_tool("ctx_compile"));
503 assert!(!is_readonly_tool("ctx_execute"));
504 assert!(!is_readonly_tool("ctx_cache"));
505 }
506
507 #[test]
508 fn plan_mode_tools_are_all_readonly() {
509 for tool in crate::core::editor_registry::plan_mode::plan_mode_tools() {
510 assert!(
511 is_readonly_tool(tool),
512 "{tool} is listed as plan mode tool but not marked readonly"
513 );
514 }
515 }
516}