Skip to main content

lean_ctx/core/
dynamic_tools.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Tool categories, the public-surface name lists (local collaboration,
3//! internal host hooks, Hive) and the dynamic per-category tool state.
4//! Pure name logic: it needs no tool registry, so engine code outside the MCP
5//! server reads it here. The call-resolving helpers (`ctx_call` spellings)
6//! stay in `server::dynamic_tools`, which re-exports this module.
7
8use std::collections::HashSet;
9use std::sync::{Mutex, OnceLock};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum ToolCategory {
13    Core,
14    Internal,
15    Arch,
16    Debug,
17    Memory,
18    Metrics,
19    Session,
20}
21
22impl ToolCategory {
23    pub fn parse(s: &str) -> Option<Self> {
24        match s {
25            "core" => Some(Self::Core),
26            "arch" | "architecture" => Some(Self::Arch),
27            "debug" | "profiling" => Some(Self::Debug),
28            "memory" | "semantic" => Some(Self::Memory),
29            "metrics" | "stats" => Some(Self::Metrics),
30            "session" => Some(Self::Session),
31            _ => None,
32        }
33    }
34
35    pub fn as_str(&self) -> &'static str {
36        match self {
37            Self::Core => "core",
38            Self::Internal => "internal",
39            Self::Arch => "arch",
40            Self::Debug => "debug",
41            Self::Memory => "memory",
42            Self::Metrics => "metrics",
43            Self::Session => "session",
44        }
45    }
46}
47
48#[allow(clippy::match_same_arms)]
49pub fn categorize_tool(name: &str) -> ToolCategory {
50    match name {
51        // Internal: meta/self-referential + automated mechanisms (never exposed)
52        "ctx_metrics"
53        | "ctx_cost"
54        | "ctx_gain"
55        | "ctx_radar"
56        | "ctx_heatmap"
57        | "ctx_feedback"
58        | "ctx_discover"
59        | "ctx_discover_tools"
60        | "ctx_load_tools"
61        | "ctx_dedup"
62        | "ctx_preload"
63        | "ctx_prefetch"
64        | "ctx_compress_memory"
65        | "ctx_shape" => ToolCategory::Internal,
66
67        // Core: always visible. Must cover every CORE_TOOL_NAMES entry —
68        // otherwise the category gate silently drops a lazy-core tool for
69        // list_changed-capable clients (ctx_expand was lost this way, #575).
70        // ctx_callgraph joined the lazy core in #578 (INTENT routes
71        // callers/impact to it), so it must be Core here too.
72        // ctx_patch joined in #1008 (anchored editing is a core workflow).
73        "ctx_read" | "ctx_search" | "ctx_shell" | "shell" | "ctx_tree" | "ctx_edit"
74        | "ctx_patch" | "ctx_session" | "ctx_checkpoint" | "ctx_knowledge" | "ctx_doc"
75        | "ctx_graph" | "ctx_callgraph" | "ctx_call" | "ctx_compress" | "ctx_expand"
76        | "ctx_delta" | "ctx_outline" | "ctx_context" => ToolCategory::Core,
77
78        // Arch: on-demand index maintenance
79        "ctx_index" => ToolCategory::Arch,
80
81        // Debug/Verify: on-demand verification
82        "ctx_verify" | "ctx_profile" | "ctx_proof" => ToolCategory::Debug,
83
84        // Provider + URL/Git readers + the MCP gateway are Core: gateways to
85        // external context (GitHub issues, Jira, Postgres, web pages, YouTube,
86        // remote git repos, downstream MCP servers) — always available.
87        "ctx_provider" | "ctx_url_read" | "ctx_git_read" | "ctx_tools" => ToolCategory::Core,
88
89        // Memory: on-demand artifact retrieval
90        "ctx_artifacts" => ToolCategory::Memory,
91
92        // Batch: on-demand batch/PR/sandbox tools
93        "ctx_execute" | "ctx_pack" | "ctx_control" => ToolCategory::Metrics,
94
95        // Multi-agent: on-demand collaboration
96        "ctx_agent" | "ctx_share" | "ctx_handoff" | "ctx_work_graph" | "ctx_tasks" => {
97            ToolCategory::Session
98        }
99
100        _ => ToolCategory::Core,
101    }
102}
103
104/// A tool that was removed: folded into a primary tool (#509) or retired
105/// without a successor (focus release, 2026-10). Only the name survives, so a
106/// call from an older agent prompt or config gets the way forward instead of
107/// a bare "Unknown tool" (`docs/migration/focus-2026-10.md`).
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct RemovedTool {
110    /// The primary tool that covers this use case (e.g. `"ctx_read"`), or
111    /// `None` for a tool retired from the Context Gateway without a successor.
112    pub replacement: Option<&'static str>,
113    /// One-line migration hint (e.g. how the primary covers this use case).
114    pub hint: &'static str,
115}
116
117const fn superseded(replacement: &'static str, hint: &'static str) -> Option<RemovedTool> {
118    Some(RemovedTool {
119        replacement: Some(replacement),
120        hint,
121    })
122}
123
124const fn retired(hint: &'static str) -> Option<RemovedTool> {
125    Some(RemovedTool {
126        replacement: None,
127        hint,
128    })
129}
130
131/// The successor and migration hint for a removed tool name, else `None`.
132#[must_use]
133pub fn removed_tool(name: &str) -> Option<RemovedTool> {
134    match name {
135        "ctx_smart_read" => superseded(
136            "ctx_read",
137            "ctx_read auto-selects the mode (omit `mode`, or pass mode=\"auto\")",
138        ),
139        "ctx_multi_read" => superseded(
140            "ctx_read",
141            "ctx_read now batch-reads via paths=[\"a.rs\",\"b.rs\"]",
142        ),
143        // #509 search consolidation: one ctx_search entry, `action` picks the engine.
144        "ctx_semantic_search" => superseded(
145            "ctx_search",
146            "ctx_search with action=\"semantic\" (query=…); reindex/find_related are actions too",
147        ),
148        "ctx_symbol" => superseded(
149            "ctx_search",
150            "ctx_search with action=\"symbol\" (name=…, optional file/kind)",
151        ),
152        // Focus release 2026-10-15, Select: one task-ranked entry point.
153        "ctx_overview" | "ctx_summary" | "ctx_explore" => superseded(
154            "ctx_compose",
155            "ctx_compose(task=…) returns the ranked files, symbols and structure for a task",
156        ),
157        "ctx_plan" | "ctx_compile" | "ctx_fill" => superseded(
158            "ctx_compose",
159            "ctx_compose selects under the turn budget; read chosen files with ctx_read",
160        ),
161        // One code-graph tool beside ctx_callgraph.
162        "ctx_impact" => superseded(
163            "ctx_graph",
164            "ctx_graph action=\"impact\" (path=…), action=\"path\" for chains, action=\"diff\" for changes",
165        ),
166        "ctx_architecture" | "ctx_repomap" => superseded(
167            "ctx_graph",
168            "ctx_graph action=\"diagram\"|\"explain\"|\"related\"; ctx_tree for the directory map",
169        ),
170        "ctx_routes" => superseded(
171            "ctx_search",
172            "ctx_search for route definitions (pattern=…), ctx_graph action=\"related\" for handlers",
173        ),
174        "ctx_retrieve" => superseded(
175            "ctx_expand",
176            "ctx_expand id=… returns withheld or archived content",
177        ),
178        // Maintenance, not an agent decision: the CLI keeps it. (`ctx_index`
179        // stays public: it carries `ctx_search`'s former reindex action, #1624.)
180        "ctx_cache" => superseded(
181            "ctx_read",
182            "ctx_read fresh=true re-reads; maintenance runs via `lean-ctx cache prune|reset`",
183        ),
184        // Focus release 2026-10-15, retired: not part of Select · Control · Prove.
185        "ctx_smells" | "ctx_quality" | "ctx_optimize" | "ctx_review" => {
186            retired("code review and linting belong to the host's own tooling")
187        }
188        "ctx_quality_lab" | "ctx_benchmark" | "ctx_perf" | "ctx_cognitive" | "ctx_ledger"
189        | "ctx_live_zone" => retired(
190            "measurement and diagnostics live in `lean-ctx gain`, `lean-ctx doctor` and the dashboard",
191        ),
192        "ctx_refactor" => retired(
193            "write refactorings are host execution; ctx_callgraph keeps the LSP read side (references)",
194        ),
195        "ctx_analyze" | "ctx_compare" => retired(
196            "ctx_read mode=\"auto\" picks the mode; `lean-ctx compress diff <file>` previews compression",
197        ),
198        "ctx_crush" => {
199            retired("ctx_read and ctx_shell apply the JSON/CSV/YAML crushers automatically")
200        }
201        "ctx_response" => {
202            retired("LeanCTX shapes the context the model reads, not the model's own responses")
203        }
204        "ctx_skillify" | "ctx_rules" => retired(
205            "agent rule files are host configuration; use `lean-ctx rules status|diff` or `lean-ctx doctor`",
206        ),
207        "ctx_workflow" => retired(
208            "workflow execution belongs to the host; ctx_work_graph keeps the shared coordination state",
209        ),
210        // Retired 2026-10-17: no calls from any installation in 30 days.
211        "ctx_task" => retired(
212            "task assignment is host execution; ctx_work_graph keeps the shared coordination state",
213        ),
214        "ctx_intent" => retired(
215            "LeanCTX infers intent from the tool calls; ctx_compose(task=…) selects context for a task",
216        ),
217        _ => None,
218    }
219}
220
221/// Local collaboration substrate that remains callable for installed users but
222/// is excluded from every public MCP tool surface.
223///
224/// LeanCTX is a Context SDK, not an agent coordination product. These tools
225/// support local development compatibility only; they do not imply a supported
226/// public agent bus, task scheduler, workflow engine, or A2A product.
227pub const LOCAL_COLLABORATION_COMPATIBILITY_TOOLS: &[&str] =
228    &["ctx_agent", "ctx_share", "ctx_handoff"];
229
230/// Whether a registered tool is retained only for local compatibility and must
231/// never be advertised from a public tool-definition path.
232#[must_use]
233pub fn is_local_collaboration_compatibility_tool(name: &str) -> bool {
234    LOCAL_COLLABORATION_COMPATIBILITY_TOOLS.contains(&name)
235}
236
237/// Host-integration hooks: called by a host extension (the Claude Code mod's
238/// `$.mcp.call`, through the listed `ctx_call` since hosts may refuse unlisted
239/// tools, #2089), never by an agent. Advertising them would only spend prompt
240/// tokens on a tool the model must not use.
241pub const INTERNAL_HOST_TOOLS: &[&str] = &["ctx_shape"];
242
243/// Hive MCP tools, gated with the connector (see [`crate::core::hive_gate::HIVE_TOOLS`]).
244pub use crate::core::hive_gate::HIVE_TOOLS;
245
246/// Whether `name` is a Hive tool withheld by the release switch.
247#[must_use]
248pub fn is_withheld_hive_tool(name: &str) -> bool {
249    HIVE_TOOLS.contains(&name) && !crate::core::hive_gate::available_locally()
250}
251
252/// Whether a registered tool belongs on a public MCP tool surface.
253///
254/// Local collaboration substrate and internal host hooks remain directly
255/// callable, including through `ctx_call`; this gate only controls discovery.
256/// Withheld Hive tools are neither advertised nor callable.
257#[must_use]
258pub fn is_publicly_advertised_tool(name: &str) -> bool {
259    !is_local_collaboration_compatibility_tool(name)
260        && !INTERNAL_HOST_TOOLS.contains(&name)
261        && !is_withheld_hive_tool(name)
262}
263
264/// The error text for a call to a removed tool. Stable per name (no
265/// timestamps/counters) so provider-side prompt caching stays byte-stable (#498).
266#[must_use]
267pub fn removed_tool_message(name: &str) -> Option<String> {
268    removed_tool(name).map(|r| match r.replacement {
269        Some(replacement) => format!(
270            "{name} was removed; use {replacement} instead — {}.",
271            r.hint
272        ),
273        None => format!("{name} was removed — {}.", r.hint),
274    })
275}
276
277pub fn is_readonly_tool(name: &str) -> bool {
278    matches!(
279        name,
280        "ctx_read"
281            | "ctx_search"
282            | "ctx_tree"
283            | "ctx_metrics"
284            | "ctx_compress"
285            | "ctx_session"
286            | "ctx_knowledge"
287            | "ctx_graph"
288            | "ctx_provider"
289            | "ctx_delta"
290            | "ctx_outline"
291            | "ctx_context"
292            | "ctx_call"
293            | "ctx_url_read"
294            | "ctx_git_read"
295            | "ctx_callgraph"
296            | "ctx_index"
297            | "ctx_artifacts"
298            | "ctx_cost"
299            | "ctx_gain"
300            | "ctx_heatmap"
301    )
302}
303
304#[derive(Debug)]
305pub struct DynamicToolState {
306    active_categories: HashSet<ToolCategory>,
307    supports_list_changed: bool,
308}
309
310impl Default for DynamicToolState {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316impl DynamicToolState {
317    pub fn new() -> Self {
318        let mut active = HashSet::new();
319        active.insert(ToolCategory::Core);
320        Self {
321            active_categories: active,
322            supports_list_changed: false,
323        }
324    }
325
326    /// Creates state with categories from config (env var > config.toml > default).
327    pub fn from_config(categories: &[String]) -> Self {
328        let mut active = HashSet::new();
329        active.insert(ToolCategory::Core);
330        for cat_str in categories {
331            if let Some(cat) = ToolCategory::parse(cat_str) {
332                active.insert(cat);
333            }
334        }
335        Self {
336            active_categories: active,
337            supports_list_changed: false,
338        }
339    }
340
341    pub fn all_enabled() -> Self {
342        let mut active = HashSet::new();
343        active.insert(ToolCategory::Core);
344        active.insert(ToolCategory::Arch);
345        active.insert(ToolCategory::Debug);
346        active.insert(ToolCategory::Memory);
347        active.insert(ToolCategory::Metrics);
348        active.insert(ToolCategory::Session);
349        Self {
350            active_categories: active,
351            supports_list_changed: false,
352        }
353    }
354
355    pub fn set_supports_list_changed(&mut self, val: bool) {
356        self.supports_list_changed = val;
357    }
358
359    pub fn supports_list_changed(&self) -> bool {
360        self.supports_list_changed
361    }
362
363    pub fn load_category(&mut self, cat: ToolCategory) -> bool {
364        self.active_categories.insert(cat)
365    }
366
367    pub fn unload_category(&mut self, cat: ToolCategory) -> bool {
368        if cat == ToolCategory::Core || cat == ToolCategory::Internal {
369            return false;
370        }
371        self.active_categories.remove(&cat)
372    }
373
374    pub fn is_tool_active(&self, name: &str) -> bool {
375        let cat = categorize_tool(name);
376        if cat == ToolCategory::Internal {
377            return false;
378        }
379        if !self.supports_list_changed {
380            // Clients without `list_changed` cannot progressively reveal a
381            // category. Keep experimental local collaboration out of their
382            // default surface too; an explicit config category still enables it.
383            return cat != ToolCategory::Session || self.active_categories.contains(&cat);
384        }
385        self.active_categories.contains(&cat)
386    }
387
388    pub fn active_categories(&self) -> Vec<&'static str> {
389        let mut cats: Vec<_> = self
390            .active_categories
391            .iter()
392            .map(ToolCategory::as_str)
393            .collect();
394        cats.sort_unstable();
395        cats
396    }
397
398    pub fn all_categories() -> Vec<&'static str> {
399        vec!["core", "arch", "debug", "memory", "metrics", "session"]
400    }
401}
402
403static GLOBAL: OnceLock<Mutex<DynamicToolState>> = OnceLock::new();
404
405pub fn global() -> &'static Mutex<DynamicToolState> {
406    GLOBAL.get_or_init(|| Mutex::new(DynamicToolState::new()))
407}
408
409pub fn init_all_enabled() {
410    let _ = GLOBAL.set(Mutex::new(DynamicToolState::all_enabled()));
411}
412
413/// Initializes the global state from user config (env var > config.toml > default).
414/// Call once during server startup after config is loaded.
415/// If the global was already initialized (e.g. by a concurrent `global()` call),
416/// applies the categories to the existing state instead.
417pub fn init_from_config(categories: &[String]) {
418    if GLOBAL
419        .set(Mutex::new(DynamicToolState::from_config(categories)))
420        .is_err()
421        && let Ok(mut state) = global().lock()
422    {
423        let desired = DynamicToolState::from_config(categories);
424        *state = desired;
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn core_tools_always_active() {
434        let state = DynamicToolState::new();
435        assert!(state.is_tool_active("ctx_read"));
436        assert!(state.is_tool_active("ctx_search"));
437        assert!(!state.is_tool_active("ctx_agent"));
438    }
439
440    #[test]
441    fn local_collaboration_substrate_is_compatibility_only() {
442        for name in LOCAL_COLLABORATION_COMPATIBILITY_TOOLS {
443            assert!(is_local_collaboration_compatibility_tool(name));
444            assert!(!is_publicly_advertised_tool(name));
445        }
446        assert!(!is_local_collaboration_compatibility_tool("ctx_session"));
447        assert!(is_publicly_advertised_tool("ctx_session"));
448        // Host hooks are callable but must never cost agents prompt tokens.
449        assert!(!is_publicly_advertised_tool("ctx_shape"));
450    }
451
452    #[test]
453    fn removed_tool_message_names_the_way_forward() {
454        let message = removed_tool_message("ctx_multi_read").unwrap();
455        assert!(message.starts_with("ctx_multi_read was removed; use ctx_read instead — "));
456        assert!(message.contains("paths="));
457        assert!(removed_tool_message("ctx_read").is_none());
458        let retired = removed_tool_message("ctx_smells").unwrap();
459        assert!(retired.starts_with("ctx_smells was removed — "));
460    }
461
462    #[test]
463    fn dynamic_tools_filtered_when_list_changed() {
464        let mut state = DynamicToolState::new();
465        state.set_supports_list_changed(true);
466        assert!(!state.is_tool_active("ctx_verify"));
467        assert!(!state.is_tool_active("ctx_index"));
468        assert!(state.is_tool_active("ctx_read"));
469    }
470
471    #[test]
472    fn load_category_enables_tools() {
473        let mut state = DynamicToolState::new();
474        state.set_supports_list_changed(true);
475        assert!(!state.is_tool_active("ctx_index"));
476        state.load_category(ToolCategory::Arch);
477        assert!(state.is_tool_active("ctx_index"));
478    }
479
480    #[test]
481    fn cannot_unload_core() {
482        let mut state = DynamicToolState::new();
483        assert!(!state.unload_category(ToolCategory::Core));
484    }
485
486    #[test]
487    fn all_tools_visible_without_list_changed() {
488        let state = DynamicToolState::new();
489        assert!(state.is_tool_active("ctx_graph"));
490        assert!(!state.is_tool_active("ctx_agent"));
491        assert!(!state.is_tool_active("ctx_metrics")); // Internal tools never active
492    }
493
494    #[test]
495    fn internal_tools_never_active() {
496        let state = DynamicToolState::all_enabled();
497        assert!(!state.is_tool_active("ctx_metrics"));
498        assert!(!state.is_tool_active("ctx_cost"));
499        assert!(!state.is_tool_active("ctx_discover_tools"));
500        assert!(!state.is_tool_active("ctx_dedup"));
501    }
502
503    // --- from_config: basic scenarios ---
504
505    #[test]
506    fn from_config_core_arch_memory() {
507        let cats = vec!["core".to_string(), "arch".to_string(), "memory".to_string()];
508        let mut state = DynamicToolState::from_config(&cats);
509        state.set_supports_list_changed(true);
510        assert!(state.is_tool_active("ctx_read"));
511        assert!(state.is_tool_active("ctx_index"));
512        assert!(state.is_tool_active("ctx_artifacts"));
513        assert!(!state.is_tool_active("ctx_verify"));
514        assert!(!state.is_tool_active("ctx_pack"));
515    }
516
517    #[test]
518    fn from_config_empty_still_has_core() {
519        let mut state = DynamicToolState::from_config(&[]);
520        state.set_supports_list_changed(true);
521        assert!(state.is_tool_active("ctx_read"));
522        assert!(!state.is_tool_active("ctx_index"));
523        assert!(!state.is_tool_active("ctx_verify"));
524        assert!(!state.is_tool_active("ctx_artifacts"));
525    }
526
527    // --- from_config: all categories ---
528
529    #[test]
530    fn from_config_all_categories_enables_everything_except_internal() {
531        let cats = vec![
532            "core".to_string(),
533            "arch".to_string(),
534            "debug".to_string(),
535            "memory".to_string(),
536            "metrics".to_string(),
537            "session".to_string(),
538        ];
539        let mut state = DynamicToolState::from_config(&cats);
540        state.set_supports_list_changed(true);
541        assert!(state.is_tool_active("ctx_read"));
542        assert!(state.is_tool_active("ctx_index"));
543        assert!(state.is_tool_active("ctx_verify"));
544        assert!(state.is_tool_active("ctx_provider"));
545        assert!(state.is_tool_active("ctx_pack"));
546        assert!(state.is_tool_active("ctx_work_graph"));
547        assert!(!state.is_tool_active("ctx_metrics"));
548    }
549
550    // --- from_config: single category ---
551
552    #[test]
553    fn from_config_only_debug() {
554        let cats = vec!["debug".to_string()];
555        let mut state = DynamicToolState::from_config(&cats);
556        state.set_supports_list_changed(true);
557        assert!(state.is_tool_active("ctx_read"));
558        assert!(state.is_tool_active("ctx_verify"));
559        assert!(!state.is_tool_active("ctx_index"));
560        assert!(!state.is_tool_active("ctx_work_graph"));
561    }
562
563    // --- from_config: invalid categories are silently ignored ---
564
565    #[test]
566    fn from_config_ignores_unknown_categories() {
567        let cats = vec![
568            "core".to_string(),
569            "nonexistent".to_string(),
570            "foobar".to_string(),
571        ];
572        let mut state = DynamicToolState::from_config(&cats);
573        state.set_supports_list_changed(true);
574        assert!(state.is_tool_active("ctx_read"));
575        assert!(!state.is_tool_active("ctx_index"));
576    }
577
578    #[test]
579    fn from_config_only_invalid_still_has_core() {
580        let cats = vec!["invalid".to_string(), "bogus".to_string()];
581        let mut state = DynamicToolState::from_config(&cats);
582        state.set_supports_list_changed(true);
583        assert!(state.is_tool_active("ctx_read"));
584        assert!(!state.is_tool_active("ctx_verify"));
585    }
586
587    // --- from_config: duplicate categories are idempotent ---
588
589    #[test]
590    fn from_config_duplicates_are_harmless() {
591        let cats = vec!["arch".to_string(), "arch".to_string(), "arch".to_string()];
592        let mut state = DynamicToolState::from_config(&cats);
593        state.set_supports_list_changed(true);
594        assert!(state.is_tool_active("ctx_index"));
595        let active = state.active_categories();
596        let arch_count = active.iter().filter(|&&c| c == "arch").count();
597        assert_eq!(arch_count, 1);
598    }
599
600    // --- from_config: internal category is never user-activatable ---
601
602    #[test]
603    fn from_config_internal_category_not_parseable() {
604        assert!(ToolCategory::parse("internal").is_none());
605    }
606
607    // --- from_config: category aliases work ---
608
609    #[test]
610    fn from_config_alias_architecture_maps_to_arch() {
611        let cats = vec!["architecture".to_string()];
612        let mut state = DynamicToolState::from_config(&cats);
613        state.set_supports_list_changed(true);
614        assert!(state.is_tool_active("ctx_index"));
615    }
616
617    #[test]
618    fn from_config_alias_profiling_maps_to_debug() {
619        let cats = vec!["profiling".to_string()];
620        let mut state = DynamicToolState::from_config(&cats);
621        state.set_supports_list_changed(true);
622        assert!(state.is_tool_active("ctx_verify"));
623    }
624
625    #[test]
626    fn from_config_alias_semantic_maps_to_memory() {
627        let cats = vec!["semantic".to_string()];
628        let mut state = DynamicToolState::from_config(&cats);
629        state.set_supports_list_changed(true);
630        assert!(state.is_tool_active("ctx_artifacts"));
631    }
632
633    // --- from_config: subsequent load/unload still works ---
634
635    #[test]
636    fn from_config_then_load_additional_category() {
637        let cats = vec!["core".to_string()];
638        let mut state = DynamicToolState::from_config(&cats);
639        state.set_supports_list_changed(true);
640        assert!(!state.is_tool_active("ctx_index"));
641        state.load_category(ToolCategory::Arch);
642        assert!(state.is_tool_active("ctx_index"));
643    }
644
645    #[test]
646    fn from_config_then_unload_non_core_category() {
647        let cats = vec!["core".to_string(), "arch".to_string()];
648        let mut state = DynamicToolState::from_config(&cats);
649        state.set_supports_list_changed(true);
650        assert!(state.is_tool_active("ctx_index"));
651        state.unload_category(ToolCategory::Arch);
652        assert!(!state.is_tool_active("ctx_index"));
653    }
654
655    #[test]
656    fn from_config_cannot_unload_core() {
657        let cats = vec!["core".to_string(), "arch".to_string()];
658        let mut state = DynamicToolState::from_config(&cats);
659        assert!(!state.unload_category(ToolCategory::Core));
660    }
661
662    // --- from_config: without list_changed, all tools visible ---
663
664    #[test]
665    fn from_config_without_list_changed_shows_all() {
666        let cats = vec!["core".to_string()];
667        let state = DynamicToolState::from_config(&cats);
668        assert!(state.is_tool_active("ctx_index"));
669        assert!(state.is_tool_active("ctx_verify"));
670        assert!(!state.is_tool_active("ctx_metrics"));
671    }
672
673    #[test]
674    fn lazy_core_tools_survive_default_category_gate() {
675        // Regression for #575: every advertised lazy-core tool must stay
676        // active under the default category gate (Core + Session) when the
677        // client supports list_changed — otherwise Cursor silently loses
678        // tools like ctx_expand from the 13-tool core set.
679        let mut state = DynamicToolState::new();
680        state.set_supports_list_changed(true);
681        for name in crate::tool_defs::core_tool_names() {
682            assert!(
683                state.is_tool_active(name),
684                "{name} is in CORE_TOOL_NAMES but dropped by the default category gate"
685            );
686        }
687    }
688
689    #[test]
690    fn categorize_known_tools() {
691        assert_eq!(categorize_tool("ctx_read"), ToolCategory::Core);
692        assert_eq!(categorize_tool("ctx_graph"), ToolCategory::Core);
693        // #1008: anchored editing is a core workflow — an explicit Core entry so
694        // the category gate can never hide the lazy-core editor.
695        assert_eq!(categorize_tool("ctx_patch"), ToolCategory::Core);
696        assert_eq!(categorize_tool("ctx_verify"), ToolCategory::Debug);
697        assert_eq!(categorize_tool("ctx_provider"), ToolCategory::Core);
698        assert_eq!(categorize_tool("ctx_artifacts"), ToolCategory::Memory);
699        assert_eq!(categorize_tool("ctx_metrics"), ToolCategory::Internal);
700        assert_eq!(categorize_tool("ctx_work_graph"), ToolCategory::Session);
701    }
702
703    #[test]
704    fn readonly_classification() {
705        assert!(is_readonly_tool("ctx_read"));
706        assert!(is_readonly_tool("ctx_search"));
707        assert!(is_readonly_tool("ctx_tree"));
708        assert!(is_readonly_tool("ctx_graph"));
709        assert!(is_readonly_tool("ctx_provider"));
710
711        assert!(!is_readonly_tool("ctx_edit"));
712        assert!(!is_readonly_tool("ctx_shell"));
713        assert!(!is_readonly_tool("ctx_patch"));
714        assert!(!is_readonly_tool("ctx_execute"));
715        assert!(!is_readonly_tool("ctx_work_graph"));
716    }
717
718    #[test]
719    fn plan_mode_tools_are_all_readonly() {
720        for tool in crate::core::editor_registry::plan_mode::plan_mode_tools() {
721            assert!(
722                is_readonly_tool(tool),
723                "{tool} is listed as plan mode tool but not marked readonly"
724            );
725        }
726    }
727}