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