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