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