Skip to main content

lean_ctx/core/
tool_profiles.rs

1use std::fmt;
2
3/// Controls which MCP tools are exposed to agents.
4///
5/// Three built-in tiers reduce tool-list overwhelm for new users
6/// while letting power users keep everything.
7///
8/// When NO profile is pinned (no config key, no env var), the server
9/// advertises only the lazy core set (`CORE_TOOL_NAMES`) and the
10/// effective profile falls back to `Power` — which acts as a pure call-gate
11/// ("everything reachable via ctx_call"), not as an advertisement list.
12/// Pinning a profile makes the advertised set explicit and authoritative
13/// (#358), which costs schema tokens: `standard` advertises its 15 full
14/// schemas, `power` the whole registry (#575).
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum ToolProfile {
17    Minimal,
18    Standard,
19    Power,
20    Auto,
21    Custom(Vec<String>),
22}
23
24impl ToolProfile {
25    pub fn parse(s: &str) -> Option<Self> {
26        match s.to_lowercase().as_str() {
27            "minimal" | "min" => Some(Self::Minimal),
28            "standard" | "std" | "default" => Some(Self::Standard),
29            "power" | "full" | "all" => Some(Self::Power),
30            "auto" => Some(Self::Auto),
31            _ => None,
32        }
33    }
34
35    pub fn as_str(&self) -> &str {
36        match self {
37            Self::Minimal => "minimal",
38            Self::Standard => "standard",
39            Self::Power => "power",
40            Self::Auto => "auto",
41            Self::Custom(_) => "custom",
42        }
43    }
44
45    pub fn description(&self) -> &str {
46        match self {
47            Self::Minimal => "5 surgical tools — each irreplaceable (recommended)",
48            Self::Standard => "15 balanced tools (adds compose, callgraph, graph, patch, more)",
49            Self::Power => "All tools exposed",
50            Self::Auto => "Starts minimal, escalates based on session complexity",
51            Self::Custom(v) => {
52                if v.is_empty() {
53                    "Custom tool list (empty)"
54                } else {
55                    "Custom tool list"
56                }
57            }
58        }
59    }
60
61    /// Returns a profile that behaves identically except `tool_name` reports as
62    /// disabled. Used to align rules-injection with the effective tool visibility
63    /// for native-editor clients that hide specific tools (#1008).
64    #[must_use]
65    pub fn without_tool(&self, tool_name: &str) -> Self {
66        if !self.is_tool_enabled(tool_name) {
67            return self.clone();
68        }
69        match self {
70            Self::Power => Self::Custom(
71                crate::tool_defs::CORE_TOOL_NAMES
72                    .iter()
73                    .chain(STANDARD_TOOLS.iter())
74                    .copied()
75                    .filter(|t| *t != tool_name)
76                    .collect::<std::collections::BTreeSet<_>>()
77                    .into_iter()
78                    .map(String::from)
79                    .collect(),
80            ),
81            Self::Standard => Self::Custom(
82                STANDARD_TOOLS
83                    .iter()
84                    .filter(|t| **t != tool_name)
85                    .map(|t| String::from(*t))
86                    .collect(),
87            ),
88            Self::Minimal => Self::Custom(
89                MINIMAL_TOOLS
90                    .iter()
91                    .filter(|t| **t != tool_name)
92                    .map(|t| String::from(*t))
93                    .collect(),
94            ),
95            Self::Auto => Self::Auto,
96            Self::Custom(list) => Self::Custom(
97                list.iter()
98                    .filter(|t| t.as_str() != tool_name)
99                    .cloned()
100                    .collect(),
101            ),
102        }
103    }
104
105    pub fn is_tool_enabled(&self, tool_name: &str) -> bool {
106        match self {
107            Self::Power => true,
108            Self::Minimal | Self::Auto => MINIMAL_TOOLS.contains(&tool_name),
109            Self::Standard => STANDARD_TOOLS.contains(&tool_name),
110            Self::Custom(list) => list.iter().any(|t| t == tool_name),
111        }
112    }
113
114    pub fn tool_count(&self) -> usize {
115        match self {
116            Self::Minimal | Self::Auto => MINIMAL_TOOLS.len(),
117            Self::Standard => STANDARD_TOOLS.len(),
118            Self::Power => 0, // dynamic — caller should use registry count
119            Self::Custom(list) => list.len(),
120        }
121    }
122
123    pub fn tool_names(&self) -> Vec<&str> {
124        match self {
125            Self::Minimal | Self::Auto => MINIMAL_TOOLS.to_vec(),
126            Self::Standard => STANDARD_TOOLS.to_vec(),
127            Self::Power | Self::Custom(_) => vec![],
128        }
129    }
130
131    /// Resolves the `Auto` profile to a concrete profile based on session signals.
132    ///
133    /// This is a pure function: the caller tracks session state and passes the
134    /// current values. The server calls this on every `tools/list` request when
135    /// the configured profile is `Auto`.
136    pub fn resolve_auto(
137        turn_count: u64,
138        has_used_ctx_tools: bool,
139        system_prompt_tokens: usize,
140    ) -> ToolProfile {
141        if has_used_ctx_tools {
142            return ToolProfile::Standard;
143        }
144        if turn_count >= 10 {
145            return ToolProfile::Power;
146        }
147        if turn_count < 2 && system_prompt_tokens < 2000 {
148            return ToolProfile::Minimal;
149        }
150        ToolProfile::Standard
151    }
152
153    /// Resolves the active tool profile from environment, then config.
154    ///
155    /// Priority: `LEAN_CTX_TOOL_PROFILE` env > config `tool_profile` > config `tools.enabled` > default.
156    /// Existing installs default to `power` (backward compat).
157    /// New installs set `standard` during setup.
158    pub fn from_config(cfg: &super::config::Config) -> Self {
159        if let Ok(val) = std::env::var("LEAN_CTX_TOOL_PROFILE") {
160            let trimmed = val.trim();
161            if let Some(profile) = Self::parse(trimmed) {
162                return profile;
163            }
164            // Same "unpin" sentinel handling as for the config key below (#431).
165            if !trimmed.is_empty() && !is_unpinned_alias(trimmed) {
166                tracing::warn!("Unknown LEAN_CTX_TOOL_PROFILE value '{trimmed}', using config");
167            }
168        }
169
170        if let Some(ref profile_name) = cfg.tool_profile {
171            if let Some(profile) = Self::parse(profile_name) {
172                return profile;
173            }
174            // `lean`/`lazy`/`reset` are the *unpinned* sentinel (lazy core
175            // advertised, everything reachable via ctx_call) — not a pinned
176            // tier. They can legitimately land in config (older versions, the
177            // dashboard's "Lean" button, manual edits), so resolve them
178            // silently to the default instead of warning + falling back (#431).
179            if !is_unpinned_alias(profile_name) {
180                tracing::warn!("Unknown tool_profile '{profile_name}' in config, using default");
181            }
182        }
183
184        if !cfg.tools_enabled.is_empty() {
185            return Self::Custom(cfg.tools_enabled.clone());
186        }
187
188        Self::Power
189    }
190}
191
192impl fmt::Display for ToolProfile {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        write!(f, "{}", self.as_str())
195    }
196}
197
198/// `""`/`lean`/`lazy`/`reset` are not pinned tiers — they are the *unpin*
199/// sentinel that clears any pin so the default returns (lazy core advertised,
200/// everything callable via `ctx_call`). Empty/whitespace is the documented
201/// default of `tool_profile` (#613); treating it as unpinned avoids a spurious
202/// `Unknown tool_profile ''` warning on the documented default. Centralised so
203/// the config loader, the CLI (`lean-ctx profile lean`) and the dashboard all
204/// agree on the same set (#431).
205pub fn is_unpinned_alias(name: &str) -> bool {
206    matches!(
207        name.trim().to_ascii_lowercase().as_str(),
208        "" | "lean" | "lazy" | "reset"
209    )
210}
211
212/// Surgical core — each tool is irreplaceable. Agent learns <10 tools,
213/// reliably picks the right one for every intent.
214///
215/// #509: symbol lookup folded into `ctx_search` (action="symbol"), so the
216/// former `ctx_symbol` entry is gone — one search entry, not two.
217pub const MINIMAL_TOOLS: &[&str] = &[
218    "ctx_read",
219    "ctx_shell",
220    "ctx_search",
221    "ctx_glob",
222    "ctx_tree",
223];
224
225/// Balanced set. Adds power-user tools the agent picks regularly but
226/// are not essential for every session.
227///
228/// #509: `ctx_semantic_search` and `ctx_symbol` are folded into `ctx_search`
229/// (action="semantic"/"symbol") and dropped from the advertised set.
230///
231/// #1008: `ctx_patch` (hash-anchored editing) joins Standard — a pinned profile
232/// is the user's explicit, client-agnostic choice, so the anchored editor is
233/// always part of it. `ctx_edit` (str_replace) stays power-only.
234const STANDARD_TOOLS: &[&str] = &[
235    "ctx_read",
236    "ctx_shell",
237    "ctx_search",
238    "ctx_glob",
239    "ctx_tree",
240    "ctx_compose",
241    "ctx_knowledge",
242    "ctx_session",
243    "ctx_callgraph",
244    "ctx_graph",
245    "ctx_delta",
246    "ctx_execute",
247    "ctx_expand",
248    "ctx_url_read",
249    "ctx_patch",
250];
251
252/// Available built-in profile names.
253pub const PROFILE_NAMES: &[&str] = &["minimal", "standard", "power", "auto"];
254
255pub struct ProfileInfo {
256    pub name: &'static str,
257    pub tool_count: &'static str,
258    pub description: &'static str,
259}
260
261pub fn list_profiles() -> Vec<ProfileInfo> {
262    vec![
263        ProfileInfo {
264            name: "minimal",
265            tool_count: "5",
266            description: "Surgical core — each tool irreplaceable (recommended)",
267        },
268        ProfileInfo {
269            name: "standard",
270            tool_count: "15",
271            description: "Balanced set — adds compose, knowledge, session, callgraph, patch, more",
272        },
273        ProfileInfo {
274            name: "power",
275            tool_count: "all",
276            description: "Every tool exposed (backward compatible)",
277        },
278        ProfileInfo {
279            name: "auto",
280            tool_count: "5→all",
281            description: "Starts minimal, escalates based on session complexity",
282        },
283    ]
284}
285
286/// Writes the `tool_profile` setting to config.toml, preserving all comments,
287/// formatting, and unrelated keys (robust against substring/comment matches).
288pub fn set_profile_in_config(profile_name: &str) -> Result<(), String> {
289    // Canonical config location (RO-safe config category, GH #408). Writing it
290    // anywhere else than `Config::load` reads would split-brain once the data
291    // default flips to `$XDG_DATA_HOME`.
292    let config_path = crate::core::config::Config::path()
293        .ok_or_else(|| "Cannot determine config dir".to_string())?;
294
295    let mut doc = crate::config_io::load_toml_document(&config_path);
296    doc["tool_profile"] = toml_edit::value(profile_name);
297    crate::config_io::write_toml_document(&config_path, &doc)?;
298    Ok(())
299}
300
301/// Removes the `tool_profile` key from config.toml, restoring the lean
302/// default: only the lazy core set is advertised in `tools/list`,
303/// while every registered tool stays reachable through `ctx_call`. This is
304/// the recommended low-overhead mode (#575).
305pub fn clear_profile_in_config() -> Result<(), String> {
306    let config_path = crate::core::config::Config::path()
307        .ok_or_else(|| "Cannot determine config dir".to_string())?;
308    if !config_path.exists() {
309        return Ok(());
310    }
311
312    let mut doc = crate::config_io::load_toml_document(&config_path);
313    if doc.remove("tool_profile").is_none() {
314        return Ok(());
315    }
316    crate::config_io::write_toml_document(&config_path, &doc)?;
317    Ok(())
318}
319
320#[cfg(test)]
321mod tests {
322    use super::{
323        MINIMAL_TOOLS, PROFILE_NAMES, STANDARD_TOOLS, ToolProfile, clear_profile_in_config,
324        is_unpinned_alias, list_profiles, set_profile_in_config,
325    };
326
327    #[test]
328    fn parse_known_profiles() {
329        assert_eq!(ToolProfile::parse("minimal"), Some(ToolProfile::Minimal));
330        assert_eq!(ToolProfile::parse("min"), Some(ToolProfile::Minimal));
331        assert_eq!(ToolProfile::parse("standard"), Some(ToolProfile::Standard));
332        assert_eq!(ToolProfile::parse("std"), Some(ToolProfile::Standard));
333        assert_eq!(ToolProfile::parse("default"), Some(ToolProfile::Standard));
334        assert_eq!(ToolProfile::parse("power"), Some(ToolProfile::Power));
335        assert_eq!(ToolProfile::parse("full"), Some(ToolProfile::Power));
336        assert_eq!(ToolProfile::parse("all"), Some(ToolProfile::Power));
337        assert_eq!(ToolProfile::parse("auto"), Some(ToolProfile::Auto));
338    }
339
340    #[test]
341    fn parse_case_insensitive() {
342        assert_eq!(ToolProfile::parse("MINIMAL"), Some(ToolProfile::Minimal));
343        assert_eq!(ToolProfile::parse("Standard"), Some(ToolProfile::Standard));
344        assert_eq!(ToolProfile::parse("POWER"), Some(ToolProfile::Power));
345    }
346
347    #[test]
348    fn parse_unknown_returns_none() {
349        assert_eq!(ToolProfile::parse("unknown"), None);
350        assert_eq!(ToolProfile::parse(""), None);
351    }
352
353    #[test]
354    fn minimal_is_subset_of_standard() {
355        for tool in MINIMAL_TOOLS {
356            assert!(
357                STANDARD_TOOLS.contains(tool),
358                "minimal tool {tool} missing from standard"
359            );
360        }
361    }
362
363    #[test]
364    fn power_enables_everything() {
365        let profile = ToolProfile::Power;
366        assert!(profile.is_tool_enabled("ctx_read"));
367        assert!(profile.is_tool_enabled("ctx_anything"));
368        assert!(profile.is_tool_enabled("nonexistent_tool"));
369    }
370
371    #[test]
372    fn minimal_filters_correctly() {
373        let profile = ToolProfile::Minimal;
374        assert!(profile.is_tool_enabled("ctx_read"));
375        assert!(profile.is_tool_enabled("ctx_shell"));
376        assert!(profile.is_tool_enabled("ctx_search"));
377        assert!(profile.is_tool_enabled("ctx_glob"));
378        assert!(profile.is_tool_enabled("ctx_tree"));
379        // #509: symbol/semantic lookups are now ctx_search actions, not their
380        // own minimal tools.
381        assert!(!profile.is_tool_enabled("ctx_callgraph"));
382    }
383
384    #[test]
385    fn standard_filters_correctly() {
386        let profile = ToolProfile::Standard;
387        assert!(profile.is_tool_enabled("ctx_read"));
388        assert!(profile.is_tool_enabled("ctx_compose"));
389        assert!(profile.is_tool_enabled("ctx_glob"));
390        assert!(profile.is_tool_enabled("ctx_callgraph"));
391        assert!(profile.is_tool_enabled("ctx_graph"));
392        assert!(profile.is_tool_enabled("ctx_session"));
393        assert!(profile.is_tool_enabled("ctx_delta"));
394        assert!(profile.is_tool_enabled("ctx_expand"));
395        assert!(profile.is_tool_enabled("ctx_execute"));
396        // Focus 2026-10-15: folded into ctx_compose, dropped from Standard.
397        // #509: ctx_symbol + ctx_semantic_search folded into ctx_search (action=…),
398        // ctx_multi_read into ctx_read (paths=…) — all dropped from Standard.
399        assert!(profile.is_tool_enabled("ctx_url_read"));
400        // #1008: anchored editing is part of the pinned Standard surface.
401        assert!(profile.is_tool_enabled("ctx_patch"));
402        assert!(!profile.is_tool_enabled("ctx_edit"));
403    }
404
405    #[test]
406    fn custom_profile_uses_provided_list() {
407        let profile = ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()]);
408        assert!(profile.is_tool_enabled("ctx_read"));
409        assert!(profile.is_tool_enabled("ctx_shell"));
410        assert!(!profile.is_tool_enabled("ctx_search"));
411    }
412
413    #[test]
414    fn profile_display_counts_match_tool_arrays() {
415        // The numbers shown by `lean-ctx tools` must equal the actual array
416        // lengths, so adding/removing a profile tool (e.g. the `shell` alias)
417        // can never silently desync the advertised count from reality.
418        let profiles = list_profiles();
419        assert_eq!(
420            profiles[0].tool_count.parse::<usize>().unwrap(),
421            MINIMAL_TOOLS.len(),
422            "minimal count must match MINIMAL_TOOLS length",
423        );
424        assert_eq!(
425            profiles[1].tool_count.parse::<usize>().unwrap(),
426            STANDARD_TOOLS.len(),
427            "standard count must match STANDARD_TOOLS length",
428        );
429        assert_eq!(profiles[2].tool_count, "all");
430    }
431
432    #[test]
433    fn custom_empty_enables_nothing() {
434        let profile = ToolProfile::Custom(vec![]);
435        assert!(!profile.is_tool_enabled("ctx_read"));
436    }
437
438    #[test]
439    fn display_matches_as_str() {
440        assert_eq!(format!("{}", ToolProfile::Minimal), "minimal");
441        assert_eq!(format!("{}", ToolProfile::Standard), "standard");
442        assert_eq!(format!("{}", ToolProfile::Power), "power");
443        assert_eq!(
444            format!("{}", ToolProfile::Custom(vec!["ctx_read".into()])),
445            "custom"
446        );
447    }
448
449    #[test]
450    fn tool_count_matches_list_length() {
451        assert_eq!(ToolProfile::Minimal.tool_count(), MINIMAL_TOOLS.len());
452        assert_eq!(ToolProfile::Standard.tool_count(), STANDARD_TOOLS.len());
453        assert_eq!(ToolProfile::Power.tool_count(), 0);
454        assert_eq!(ToolProfile::Auto.tool_count(), MINIMAL_TOOLS.len());
455    }
456
457    #[test]
458    fn from_config_defaults_to_power_for_backward_compat() {
459        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
460            return;
461        }
462        let cfg = crate::core::config::Config {
463            tool_profile: None,
464            tools_enabled: vec![],
465            ..Default::default()
466        };
467        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Power);
468    }
469
470    #[test]
471    fn from_config_respects_tool_profile_field() {
472        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
473            return;
474        }
475        let cfg = crate::core::config::Config {
476            tool_profile: Some("minimal".to_string()),
477            tools_enabled: vec![],
478            ..Default::default()
479        };
480        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Minimal);
481    }
482
483    #[test]
484    fn from_config_tools_enabled_creates_custom() {
485        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
486            return;
487        }
488        let cfg = crate::core::config::Config {
489            tool_profile: None,
490            tools_enabled: vec!["ctx_read".to_string(), "ctx_shell".to_string()],
491            ..Default::default()
492        };
493        let profile = ToolProfile::from_config(&cfg);
494        assert_eq!(
495            profile,
496            ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()])
497        );
498    }
499
500    #[test]
501    fn tool_profile_takes_precedence_over_tools_enabled() {
502        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
503            return;
504        }
505        let cfg = crate::core::config::Config {
506            tool_profile: Some("standard".to_string()),
507            tools_enabled: vec!["ctx_read".to_string()],
508            ..Default::default()
509        };
510        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Standard);
511    }
512
513    #[test]
514    fn empty_tool_profile_is_unpinned_so_tools_enabled_applies() {
515        // #613: `tool_profile = ""` is the documented default ("unpin"), not a
516        // bogus profile. It must resolve silently via the unpinned path (so an
517        // explicit `tools_enabled` takes effect) instead of warning
518        // "Unknown tool_profile ''".
519        assert!(is_unpinned_alias(""));
520        assert!(is_unpinned_alias("   "));
521
522        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
523            return;
524        }
525        let cfg = crate::core::config::Config {
526            tool_profile: Some(String::new()),
527            tools_enabled: vec!["ctx_read".to_string()],
528            ..Default::default()
529        };
530        assert_eq!(
531            ToolProfile::from_config(&cfg),
532            ToolProfile::Custom(vec!["ctx_read".to_string()]),
533            "empty tool_profile must unpin so tools_enabled takes effect"
534        );
535    }
536
537    #[test]
538    fn all_profile_names_are_parseable() {
539        for name in PROFILE_NAMES {
540            assert!(
541                ToolProfile::parse(name).is_some(),
542                "profile name '{name}' should be parseable"
543            );
544        }
545    }
546
547    #[test]
548    fn list_profiles_returns_four_entries() {
549        let profiles = list_profiles();
550        assert_eq!(profiles.len(), 4);
551    }
552
553    #[test]
554    fn resolve_auto_short_session_returns_minimal() {
555        assert_eq!(
556            ToolProfile::resolve_auto(0, false, 500),
557            ToolProfile::Minimal
558        );
559        assert_eq!(
560            ToolProfile::resolve_auto(1, false, 1500),
561            ToolProfile::Minimal
562        );
563    }
564
565    #[test]
566    fn resolve_auto_ctx_tools_used_returns_standard() {
567        assert_eq!(
568            ToolProfile::resolve_auto(1, true, 500),
569            ToolProfile::Standard
570        );
571        assert_eq!(ToolProfile::resolve_auto(0, true, 0), ToolProfile::Standard);
572    }
573
574    #[test]
575    fn resolve_auto_long_session_returns_power() {
576        assert_eq!(
577            ToolProfile::resolve_auto(10, false, 500),
578            ToolProfile::Power
579        );
580        assert_eq!(
581            ToolProfile::resolve_auto(20, false, 3000),
582            ToolProfile::Power
583        );
584    }
585
586    #[test]
587    fn resolve_auto_medium_session_returns_standard() {
588        assert_eq!(
589            ToolProfile::resolve_auto(5, false, 3000),
590            ToolProfile::Standard
591        );
592    }
593
594    #[test]
595    fn auto_profile_display() {
596        assert_eq!(ToolProfile::Auto.as_str(), "auto");
597        assert_eq!(format!("{}", ToolProfile::Auto), "auto");
598    }
599
600    #[test]
601    fn standard_includes_all_core_tools() {
602        let profile = ToolProfile::Standard;
603        assert!(
604            profile.is_tool_enabled("ctx_graph"),
605            "ctx_graph must be in standard"
606        );
607        assert!(
608            profile.is_tool_enabled("ctx_delta"),
609            "ctx_delta must be in standard"
610        );
611        assert!(
612            profile.is_tool_enabled("ctx_expand"),
613            "ctx_expand must be in standard"
614        );
615        assert!(
616            profile.is_tool_enabled("ctx_execute"),
617            "ctx_execute must be in standard (sandboxed code execution)"
618        );
619        // ctx_edit is power-only — for editing, Standard carries the anchored
620        // ctx_patch instead (#1008); str_replace stays a power fallback.
621        assert!(
622            !profile.is_tool_enabled("ctx_edit"),
623            "ctx_edit must NOT be in standard (ctx_patch is the standard editor)"
624        );
625        assert!(
626            profile.is_tool_enabled("ctx_patch"),
627            "ctx_patch must be in standard (#1008 anchored editing)"
628        );
629    }
630
631    #[test]
632    fn standard_includes_url_read() {
633        let profile = ToolProfile::Standard;
634        assert!(
635            profile.is_tool_enabled("ctx_url_read"),
636            "ctx_url_read must be in standard (web/research context)"
637        );
638    }
639
640    #[test]
641    fn clear_profile_removes_key_and_is_idempotent() {
642        let iso = crate::core::data_dir::isolated_data_dir();
643        set_profile_in_config("power").unwrap();
644        let config_path = iso.path().join("config.toml");
645        assert!(
646            std::fs::read_to_string(&config_path)
647                .unwrap()
648                .contains("tool_profile"),
649            "set_profile_in_config must write the key"
650        );
651
652        clear_profile_in_config().unwrap();
653        assert!(
654            !std::fs::read_to_string(&config_path)
655                .unwrap()
656                .contains("tool_profile"),
657            "clear_profile_in_config must remove the key (lean default, #575)"
658        );
659
660        // Idempotent: clearing again (and on a missing file) must not fail.
661        clear_profile_in_config().unwrap();
662    }
663
664    #[test]
665    fn clear_profile_on_missing_config_is_ok() {
666        let _iso = crate::core::data_dir::isolated_data_dir();
667        clear_profile_in_config().unwrap();
668    }
669
670    #[test]
671    fn without_tool_removes_from_power() {
672        let filtered = ToolProfile::Power.without_tool("ctx_patch");
673        assert!(!filtered.is_tool_enabled("ctx_patch"));
674        assert!(filtered.is_tool_enabled("ctx_read"));
675        assert!(filtered.is_tool_enabled("ctx_search"));
676    }
677
678    #[test]
679    fn without_tool_removes_from_standard() {
680        let filtered = ToolProfile::Standard.without_tool("ctx_patch");
681        assert!(!filtered.is_tool_enabled("ctx_patch"));
682        assert!(filtered.is_tool_enabled("ctx_read"));
683        assert!(filtered.is_tool_enabled("ctx_compose"));
684    }
685
686    #[test]
687    fn without_tool_noop_for_already_missing() {
688        let filtered = ToolProfile::Minimal.without_tool("ctx_patch");
689        assert!(!filtered.is_tool_enabled("ctx_patch"));
690        assert!(filtered.is_tool_enabled("ctx_read"));
691    }
692}