1use 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
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct DeprecatedAlias {
117 pub replacement: &'static str,
119 pub hint: &'static str,
121}
122
123#[must_use]
131pub fn deprecated_alias(name: &str) -> Option<DeprecatedAlias> {
132 match name {
133 "ctx_smart_read" => Some(DeprecatedAlias {
134 replacement: "ctx_read",
135 hint: "ctx_read auto-selects the mode (omit `mode`, or pass mode=\"auto\")",
136 }),
137 "ctx_multi_read" => Some(DeprecatedAlias {
138 replacement: "ctx_read",
139 hint: "ctx_read now batch-reads via paths=[\"a.rs\",\"b.rs\"]",
140 }),
141 _ => None,
142 }
143}
144
145#[must_use]
147pub fn is_deprecated_alias(name: &str) -> bool {
148 deprecated_alias(name).is_some()
149}
150
151#[must_use]
155pub fn deprecation_notice(name: &str) -> Option<String> {
156 deprecated_alias(name).map(|d| {
157 format!(
158 "[DEPRECATED] {name} is superseded by {} — {}. This alias is hidden from \
159 tools/list and will be removed in a future release.",
160 d.replacement, d.hint
161 )
162 })
163}
164
165pub fn is_readonly_tool(name: &str) -> bool {
166 matches!(
167 name,
168 "ctx_read"
169 | "ctx_search"
170 | "ctx_tree"
171 | "ctx_overview"
172 | "ctx_plan"
173 | "ctx_metrics"
174 | "ctx_compress"
175 | "ctx_session"
176 | "ctx_knowledge"
177 | "ctx_graph"
178 | "ctx_retrieve"
179 | "ctx_provider"
180 | "ctx_multi_read"
181 | "ctx_smart_read"
182 | "ctx_delta"
183 | "ctx_outline"
184 | "ctx_context"
185 | "ctx_call"
186 | "ctx_url_read"
187 | "ctx_git_read"
188 | "ctx_architecture"
189 | "ctx_impact"
190 | "ctx_callgraph"
191 | "ctx_symbol"
192 | "ctx_routes"
193 | "ctx_smells"
194 | "ctx_index"
195 | "ctx_semantic_search"
196 | "ctx_explore"
197 | "ctx_artifacts"
198 | "ctx_cost"
199 | "ctx_gain"
200 | "ctx_heatmap"
201 )
202}
203
204#[derive(Debug)]
205pub struct DynamicToolState {
206 active_categories: HashSet<ToolCategory>,
207 supports_list_changed: bool,
208}
209
210impl Default for DynamicToolState {
211 fn default() -> Self {
212 Self::new()
213 }
214}
215
216impl DynamicToolState {
217 pub fn new() -> Self {
218 let mut active = HashSet::new();
219 active.insert(ToolCategory::Core);
220 active.insert(ToolCategory::Session);
221 Self {
222 active_categories: active,
223 supports_list_changed: false,
224 }
225 }
226
227 pub fn from_config(categories: &[String]) -> Self {
229 let mut active = HashSet::new();
230 active.insert(ToolCategory::Core);
231 for cat_str in categories {
232 if let Some(cat) = ToolCategory::parse(cat_str) {
233 active.insert(cat);
234 }
235 }
236 Self {
237 active_categories: active,
238 supports_list_changed: false,
239 }
240 }
241
242 pub fn all_enabled() -> Self {
243 let mut active = HashSet::new();
244 active.insert(ToolCategory::Core);
245 active.insert(ToolCategory::Arch);
246 active.insert(ToolCategory::Debug);
247 active.insert(ToolCategory::Memory);
248 active.insert(ToolCategory::Metrics);
249 active.insert(ToolCategory::Session);
250 Self {
251 active_categories: active,
252 supports_list_changed: false,
253 }
254 }
255
256 pub fn set_supports_list_changed(&mut self, val: bool) {
257 self.supports_list_changed = val;
258 }
259
260 pub fn supports_list_changed(&self) -> bool {
261 self.supports_list_changed
262 }
263
264 pub fn load_category(&mut self, cat: ToolCategory) -> bool {
265 self.active_categories.insert(cat)
266 }
267
268 pub fn unload_category(&mut self, cat: ToolCategory) -> bool {
269 if cat == ToolCategory::Core || cat == ToolCategory::Internal {
270 return false;
271 }
272 self.active_categories.remove(&cat)
273 }
274
275 pub fn is_tool_active(&self, name: &str) -> bool {
276 let cat = categorize_tool(name);
277 if cat == ToolCategory::Internal {
278 return false;
279 }
280 if !self.supports_list_changed {
281 return true;
282 }
283 self.active_categories.contains(&cat)
284 }
285
286 pub fn active_categories(&self) -> Vec<&'static str> {
287 let mut cats: Vec<_> = self
288 .active_categories
289 .iter()
290 .map(ToolCategory::as_str)
291 .collect();
292 cats.sort_unstable();
293 cats
294 }
295
296 pub fn all_categories() -> Vec<&'static str> {
297 vec!["core", "arch", "debug", "memory", "metrics", "session"]
298 }
299}
300
301static GLOBAL: OnceLock<Mutex<DynamicToolState>> = OnceLock::new();
302
303pub fn global() -> &'static Mutex<DynamicToolState> {
304 GLOBAL.get_or_init(|| Mutex::new(DynamicToolState::new()))
305}
306
307pub fn init_all_enabled() {
308 let _ = GLOBAL.set(Mutex::new(DynamicToolState::all_enabled()));
309}
310
311pub fn init_from_config(categories: &[String]) {
316 if GLOBAL
317 .set(Mutex::new(DynamicToolState::from_config(categories)))
318 .is_err()
319 && let Ok(mut state) = global().lock()
320 {
321 let desired = DynamicToolState::from_config(categories);
322 *state = desired;
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn core_tools_always_active() {
332 let state = DynamicToolState::new();
333 assert!(state.is_tool_active("ctx_read"));
334 assert!(state.is_tool_active("ctx_search"));
335 }
336
337 #[test]
338 fn deprecated_alias_maps_read_cluster_to_ctx_read() {
339 assert_eq!(
342 deprecated_alias("ctx_smart_read").unwrap().replacement,
343 "ctx_read"
344 );
345 assert_eq!(
346 deprecated_alias("ctx_multi_read").unwrap().replacement,
347 "ctx_read"
348 );
349 assert!(deprecated_alias("ctx_read").is_none());
350 assert!(deprecated_alias("ctx_search").is_none());
351 assert!(is_deprecated_alias("ctx_multi_read"));
352 assert!(!is_deprecated_alias("ctx_read"));
353 }
354
355 #[test]
356 fn deprecation_notice_is_stable_and_names_replacement() {
357 let notice = deprecation_notice("ctx_multi_read").unwrap();
360 assert!(notice.starts_with("[DEPRECATED] ctx_multi_read is superseded by ctx_read"));
361 assert!(notice.contains("paths="));
362 assert_eq!(notice, deprecation_notice("ctx_multi_read").unwrap());
363 assert!(deprecation_notice("ctx_read").is_none());
364 }
365
366 #[test]
367 fn dynamic_tools_filtered_when_list_changed() {
368 let mut state = DynamicToolState::new();
369 state.set_supports_list_changed(true);
370 assert!(!state.is_tool_active("ctx_benchmark"));
371 assert!(!state.is_tool_active("ctx_architecture"));
372 assert!(state.is_tool_active("ctx_read"));
373 }
374
375 #[test]
376 fn load_category_enables_tools() {
377 let mut state = DynamicToolState::new();
378 state.set_supports_list_changed(true);
379 assert!(!state.is_tool_active("ctx_architecture"));
380 state.load_category(ToolCategory::Arch);
381 assert!(state.is_tool_active("ctx_architecture"));
382 }
383
384 #[test]
385 fn cannot_unload_core() {
386 let mut state = DynamicToolState::new();
387 assert!(!state.unload_category(ToolCategory::Core));
388 }
389
390 #[test]
391 fn all_tools_visible_without_list_changed() {
392 let state = DynamicToolState::new();
393 assert!(state.is_tool_active("ctx_graph"));
394 assert!(!state.is_tool_active("ctx_metrics")); }
396
397 #[test]
398 fn internal_tools_never_active() {
399 let state = DynamicToolState::all_enabled();
400 assert!(!state.is_tool_active("ctx_metrics"));
401 assert!(!state.is_tool_active("ctx_cost"));
402 assert!(!state.is_tool_active("ctx_discover_tools"));
403 assert!(!state.is_tool_active("ctx_dedup"));
404 }
405
406 #[test]
409 fn from_config_core_arch_memory() {
410 let cats = vec!["core".to_string(), "arch".to_string(), "memory".to_string()];
411 let mut state = DynamicToolState::from_config(&cats);
412 state.set_supports_list_changed(true);
413 assert!(state.is_tool_active("ctx_read"));
414 assert!(state.is_tool_active("ctx_architecture"));
415 assert!(state.is_tool_active("ctx_artifacts"));
416 assert!(!state.is_tool_active("ctx_benchmark"));
417 assert!(!state.is_tool_active("ctx_fill"));
418 }
419
420 #[test]
421 fn from_config_empty_still_has_core() {
422 let mut state = DynamicToolState::from_config(&[]);
423 state.set_supports_list_changed(true);
424 assert!(state.is_tool_active("ctx_read"));
425 assert!(!state.is_tool_active("ctx_architecture"));
426 assert!(!state.is_tool_active("ctx_benchmark"));
427 assert!(!state.is_tool_active("ctx_artifacts"));
428 }
429
430 #[test]
433 fn from_config_all_categories_enables_everything_except_internal() {
434 let cats = vec![
435 "core".to_string(),
436 "arch".to_string(),
437 "debug".to_string(),
438 "memory".to_string(),
439 "metrics".to_string(),
440 "session".to_string(),
441 ];
442 let mut state = DynamicToolState::from_config(&cats);
443 state.set_supports_list_changed(true);
444 assert!(state.is_tool_active("ctx_read"));
445 assert!(state.is_tool_active("ctx_architecture"));
446 assert!(state.is_tool_active("ctx_benchmark"));
447 assert!(state.is_tool_active("ctx_semantic_search"));
448 assert!(state.is_tool_active("ctx_fill"));
449 assert!(state.is_tool_active("ctx_workflow"));
450 assert!(!state.is_tool_active("ctx_metrics"));
451 }
452
453 #[test]
456 fn from_config_only_debug() {
457 let cats = vec!["debug".to_string()];
458 let mut state = DynamicToolState::from_config(&cats);
459 state.set_supports_list_changed(true);
460 assert!(state.is_tool_active("ctx_read"));
461 assert!(state.is_tool_active("ctx_benchmark"));
462 assert!(!state.is_tool_active("ctx_architecture"));
463 assert!(!state.is_tool_active("ctx_workflow"));
464 }
465
466 #[test]
469 fn from_config_ignores_unknown_categories() {
470 let cats = vec![
471 "core".to_string(),
472 "nonexistent".to_string(),
473 "foobar".to_string(),
474 ];
475 let mut state = DynamicToolState::from_config(&cats);
476 state.set_supports_list_changed(true);
477 assert!(state.is_tool_active("ctx_read"));
478 assert!(!state.is_tool_active("ctx_architecture"));
479 }
480
481 #[test]
482 fn from_config_only_invalid_still_has_core() {
483 let cats = vec!["invalid".to_string(), "bogus".to_string()];
484 let mut state = DynamicToolState::from_config(&cats);
485 state.set_supports_list_changed(true);
486 assert!(state.is_tool_active("ctx_read"));
487 assert!(!state.is_tool_active("ctx_benchmark"));
488 }
489
490 #[test]
493 fn from_config_duplicates_are_harmless() {
494 let cats = vec!["arch".to_string(), "arch".to_string(), "arch".to_string()];
495 let mut state = DynamicToolState::from_config(&cats);
496 state.set_supports_list_changed(true);
497 assert!(state.is_tool_active("ctx_architecture"));
498 let active = state.active_categories();
499 let arch_count = active.iter().filter(|&&c| c == "arch").count();
500 assert_eq!(arch_count, 1);
501 }
502
503 #[test]
506 fn from_config_internal_category_not_parseable() {
507 assert!(ToolCategory::parse("internal").is_none());
508 }
509
510 #[test]
513 fn from_config_alias_architecture_maps_to_arch() {
514 let cats = vec!["architecture".to_string()];
515 let mut state = DynamicToolState::from_config(&cats);
516 state.set_supports_list_changed(true);
517 assert!(state.is_tool_active("ctx_architecture"));
518 }
519
520 #[test]
521 fn from_config_alias_profiling_maps_to_debug() {
522 let cats = vec!["profiling".to_string()];
523 let mut state = DynamicToolState::from_config(&cats);
524 state.set_supports_list_changed(true);
525 assert!(state.is_tool_active("ctx_benchmark"));
526 }
527
528 #[test]
529 fn from_config_alias_semantic_maps_to_memory() {
530 let cats = vec!["semantic".to_string()];
531 let mut state = DynamicToolState::from_config(&cats);
532 state.set_supports_list_changed(true);
533 assert!(state.is_tool_active("ctx_artifacts"));
534 }
535
536 #[test]
539 fn from_config_then_load_additional_category() {
540 let cats = vec!["core".to_string()];
541 let mut state = DynamicToolState::from_config(&cats);
542 state.set_supports_list_changed(true);
543 assert!(!state.is_tool_active("ctx_architecture"));
544 state.load_category(ToolCategory::Arch);
545 assert!(state.is_tool_active("ctx_architecture"));
546 }
547
548 #[test]
549 fn from_config_then_unload_non_core_category() {
550 let cats = vec!["core".to_string(), "arch".to_string()];
551 let mut state = DynamicToolState::from_config(&cats);
552 state.set_supports_list_changed(true);
553 assert!(state.is_tool_active("ctx_architecture"));
554 state.unload_category(ToolCategory::Arch);
555 assert!(!state.is_tool_active("ctx_architecture"));
556 }
557
558 #[test]
559 fn from_config_cannot_unload_core() {
560 let cats = vec!["core".to_string(), "arch".to_string()];
561 let mut state = DynamicToolState::from_config(&cats);
562 assert!(!state.unload_category(ToolCategory::Core));
563 }
564
565 #[test]
568 fn from_config_without_list_changed_shows_all() {
569 let cats = vec!["core".to_string()];
570 let state = DynamicToolState::from_config(&cats);
571 assert!(state.is_tool_active("ctx_architecture"));
572 assert!(state.is_tool_active("ctx_benchmark"));
573 assert!(!state.is_tool_active("ctx_metrics"));
574 }
575
576 #[test]
577 fn lazy_core_tools_survive_default_category_gate() {
578 let mut state = DynamicToolState::new();
583 state.set_supports_list_changed(true);
584 for name in crate::tool_defs::core_tool_names() {
585 assert!(
586 state.is_tool_active(name),
587 "{name} is in CORE_TOOL_NAMES but dropped by the default category gate"
588 );
589 }
590 }
591
592 #[test]
593 fn categorize_known_tools() {
594 assert_eq!(categorize_tool("ctx_read"), ToolCategory::Core);
595 assert_eq!(categorize_tool("ctx_graph"), ToolCategory::Core);
596 assert_eq!(categorize_tool("ctx_benchmark"), ToolCategory::Debug);
597 assert_eq!(categorize_tool("ctx_semantic_search"), ToolCategory::Core);
598 assert_eq!(categorize_tool("ctx_artifacts"), ToolCategory::Memory);
599 assert_eq!(categorize_tool("ctx_metrics"), ToolCategory::Internal);
600 assert_eq!(categorize_tool("ctx_workflow"), ToolCategory::Session);
601 }
602
603 #[test]
604 fn readonly_classification() {
605 assert!(is_readonly_tool("ctx_read"));
606 assert!(is_readonly_tool("ctx_search"));
607 assert!(is_readonly_tool("ctx_tree"));
608 assert!(is_readonly_tool("ctx_overview"));
609 assert!(is_readonly_tool("ctx_provider"));
610
611 assert!(!is_readonly_tool("ctx_edit"));
612 assert!(!is_readonly_tool("ctx_shell"));
613 assert!(!is_readonly_tool("ctx_compile"));
614 assert!(!is_readonly_tool("ctx_execute"));
615 assert!(!is_readonly_tool("ctx_cache"));
616 }
617
618 #[test]
619 fn plan_mode_tools_are_all_readonly() {
620 for tool in crate::core::editor_registry::plan_mode::plan_mode_tools() {
621 assert!(
622 is_readonly_tool(tool),
623 "{tool} is listed as plan mode tool but not marked readonly"
624 );
625 }
626 }
627}