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