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 16 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 => "16 balanced tools (adds compose, explore, callgraph, 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.
217const 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_explore",
242    "ctx_knowledge",
243    "ctx_session",
244    "ctx_callgraph",
245    "ctx_graph",
246    "ctx_delta",
247    "ctx_execute",
248    "ctx_expand",
249    "ctx_overview",
250    "ctx_url_read",
251    "ctx_patch",
252];
253
254/// Available built-in profile names.
255pub const PROFILE_NAMES: &[&str] = &["minimal", "standard", "power", "auto"];
256
257pub struct ProfileInfo {
258    pub name: &'static str,
259    pub tool_count: &'static str,
260    pub description: &'static str,
261}
262
263pub fn list_profiles() -> Vec<ProfileInfo> {
264    vec![
265        ProfileInfo {
266            name: "minimal",
267            tool_count: "5",
268            description: "Surgical core — each tool irreplaceable (recommended)",
269        },
270        ProfileInfo {
271            name: "standard",
272            tool_count: "17",
273            description: "Balanced set — adds compose, knowledge, session, callgraph, patch, more",
274        },
275        ProfileInfo {
276            name: "power",
277            tool_count: "all",
278            description: "Every tool exposed (backward compatible)",
279        },
280        ProfileInfo {
281            name: "auto",
282            tool_count: "5→all",
283            description: "Starts minimal, escalates based on session complexity",
284        },
285    ]
286}
287
288/// Writes the `tool_profile` setting to config.toml, preserving all comments,
289/// formatting, and unrelated keys (robust against substring/comment matches).
290pub fn set_profile_in_config(profile_name: &str) -> Result<(), String> {
291    // Canonical config location (RO-safe config category, GH #408). Writing it
292    // anywhere else than `Config::load` reads would split-brain once the data
293    // default flips to `$XDG_DATA_HOME`.
294    let config_path = crate::core::config::Config::path()
295        .ok_or_else(|| "Cannot determine config dir".to_string())?;
296
297    let mut doc = crate::config_io::load_toml_document(&config_path);
298    doc["tool_profile"] = toml_edit::value(profile_name);
299    crate::config_io::write_toml_document(&config_path, &doc)?;
300    Ok(())
301}
302
303/// Removes the `tool_profile` key from config.toml, restoring the lean
304/// default: only the lazy core set is advertised in `tools/list`,
305/// while every registered tool stays reachable through `ctx_call`. This is
306/// the recommended low-overhead mode (#575).
307pub fn clear_profile_in_config() -> Result<(), String> {
308    let config_path = crate::core::config::Config::path()
309        .ok_or_else(|| "Cannot determine config dir".to_string())?;
310    if !config_path.exists() {
311        return Ok(());
312    }
313
314    let mut doc = crate::config_io::load_toml_document(&config_path);
315    if doc.remove("tool_profile").is_none() {
316        return Ok(());
317    }
318    crate::config_io::write_toml_document(&config_path, &doc)?;
319    Ok(())
320}
321
322#[cfg(test)]
323mod tests {
324    use super::{
325        MINIMAL_TOOLS, PROFILE_NAMES, STANDARD_TOOLS, ToolProfile, clear_profile_in_config,
326        is_unpinned_alias, list_profiles, set_profile_in_config,
327    };
328
329    #[test]
330    fn parse_known_profiles() {
331        assert_eq!(ToolProfile::parse("minimal"), Some(ToolProfile::Minimal));
332        assert_eq!(ToolProfile::parse("min"), Some(ToolProfile::Minimal));
333        assert_eq!(ToolProfile::parse("standard"), Some(ToolProfile::Standard));
334        assert_eq!(ToolProfile::parse("std"), Some(ToolProfile::Standard));
335        assert_eq!(ToolProfile::parse("default"), Some(ToolProfile::Standard));
336        assert_eq!(ToolProfile::parse("power"), Some(ToolProfile::Power));
337        assert_eq!(ToolProfile::parse("full"), Some(ToolProfile::Power));
338        assert_eq!(ToolProfile::parse("all"), Some(ToolProfile::Power));
339        assert_eq!(ToolProfile::parse("auto"), Some(ToolProfile::Auto));
340    }
341
342    #[test]
343    fn parse_case_insensitive() {
344        assert_eq!(ToolProfile::parse("MINIMAL"), Some(ToolProfile::Minimal));
345        assert_eq!(ToolProfile::parse("Standard"), Some(ToolProfile::Standard));
346        assert_eq!(ToolProfile::parse("POWER"), Some(ToolProfile::Power));
347    }
348
349    #[test]
350    fn parse_unknown_returns_none() {
351        assert_eq!(ToolProfile::parse("unknown"), None);
352        assert_eq!(ToolProfile::parse(""), None);
353    }
354
355    #[test]
356    fn minimal_profile_schema_budget() {
357        // tool_profile=minimal advertises the 9-tool surgical set; the schemas
358        // they re-send every turn (description + input schema) must stay small.
359        // This is the tool-side of the faithful-arm per-turn prefix tax (#361).
360        const MINIMAL_SCHEMA_BUDGET_TOKENS: usize = 2800;
361        let defs = crate::server::registry::build_registry().tool_defs();
362        let total: usize = defs
363            .iter()
364            .filter(|t| MINIMAL_TOOLS.contains(&t.name.as_ref()))
365            .map(crate::core::context_overhead::tool_tokens)
366            .sum();
367        assert!(total > 0, "minimal tools must exist in the registry");
368        assert!(
369            total <= MINIMAL_SCHEMA_BUDGET_TOKENS,
370            "minimal-profile tool schemas = {total} tok, budget {MINIMAL_SCHEMA_BUDGET_TOKENS}"
371        );
372    }
373
374    #[test]
375    fn minimal_is_subset_of_standard() {
376        for tool in MINIMAL_TOOLS {
377            assert!(
378                STANDARD_TOOLS.contains(tool),
379                "minimal tool {tool} missing from standard"
380            );
381        }
382    }
383
384    #[test]
385    fn power_enables_everything() {
386        let profile = ToolProfile::Power;
387        assert!(profile.is_tool_enabled("ctx_read"));
388        assert!(profile.is_tool_enabled("ctx_anything"));
389        assert!(profile.is_tool_enabled("nonexistent_tool"));
390    }
391
392    #[test]
393    fn minimal_filters_correctly() {
394        let profile = ToolProfile::Minimal;
395        assert!(profile.is_tool_enabled("ctx_read"));
396        assert!(profile.is_tool_enabled("ctx_shell"));
397        assert!(profile.is_tool_enabled("ctx_search"));
398        assert!(profile.is_tool_enabled("ctx_glob"));
399        assert!(profile.is_tool_enabled("ctx_tree"));
400        // #509: symbol/semantic lookups are now ctx_search actions, not their
401        // own minimal tools.
402        assert!(!profile.is_tool_enabled("ctx_symbol"));
403        assert!(!profile.is_tool_enabled("ctx_semantic_search"));
404        assert!(!profile.is_tool_enabled("ctx_callgraph"));
405        assert!(!profile.is_tool_enabled("ctx_benchmark"));
406    }
407
408    #[test]
409    fn standard_filters_correctly() {
410        let profile = ToolProfile::Standard;
411        assert!(profile.is_tool_enabled("ctx_read"));
412        assert!(profile.is_tool_enabled("ctx_compose"));
413        assert!(profile.is_tool_enabled("ctx_explore"));
414        assert!(profile.is_tool_enabled("ctx_glob"));
415        assert!(profile.is_tool_enabled("ctx_callgraph"));
416        assert!(profile.is_tool_enabled("ctx_graph"));
417        assert!(profile.is_tool_enabled("ctx_session"));
418        assert!(profile.is_tool_enabled("ctx_delta"));
419        assert!(profile.is_tool_enabled("ctx_expand"));
420        assert!(profile.is_tool_enabled("ctx_execute"));
421        assert!(profile.is_tool_enabled("ctx_overview"));
422        // #509: ctx_symbol + ctx_semantic_search folded into ctx_search (action=…),
423        // ctx_multi_read into ctx_read (paths=…) — all dropped from Standard.
424        assert!(!profile.is_tool_enabled("ctx_symbol"));
425        assert!(!profile.is_tool_enabled("ctx_semantic_search"));
426        assert!(!profile.is_tool_enabled("ctx_multi_read"));
427        assert!(profile.is_tool_enabled("ctx_url_read"));
428        // #1008: anchored editing is part of the pinned Standard surface.
429        assert!(profile.is_tool_enabled("ctx_patch"));
430        assert!(!profile.is_tool_enabled("ctx_benchmark"));
431        assert!(!profile.is_tool_enabled("ctx_analyze"));
432        assert!(!profile.is_tool_enabled("ctx_refactor"));
433        assert!(!profile.is_tool_enabled("ctx_edit"));
434    }
435
436    #[test]
437    fn custom_profile_uses_provided_list() {
438        let profile = ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()]);
439        assert!(profile.is_tool_enabled("ctx_read"));
440        assert!(profile.is_tool_enabled("ctx_shell"));
441        assert!(!profile.is_tool_enabled("ctx_search"));
442    }
443
444    #[test]
445    fn profile_display_counts_match_tool_arrays() {
446        // The numbers shown by `lean-ctx tools` must equal the actual array
447        // lengths, so adding/removing a profile tool (e.g. the `shell` alias)
448        // can never silently desync the advertised count from reality.
449        let profiles = list_profiles();
450        assert_eq!(
451            profiles[0].tool_count.parse::<usize>().unwrap(),
452            MINIMAL_TOOLS.len(),
453            "minimal count must match MINIMAL_TOOLS length",
454        );
455        assert_eq!(
456            profiles[1].tool_count.parse::<usize>().unwrap(),
457            STANDARD_TOOLS.len(),
458            "standard count must match STANDARD_TOOLS length",
459        );
460        assert_eq!(profiles[2].tool_count, "all");
461    }
462
463    #[test]
464    fn custom_empty_enables_nothing() {
465        let profile = ToolProfile::Custom(vec![]);
466        assert!(!profile.is_tool_enabled("ctx_read"));
467    }
468
469    #[test]
470    fn display_matches_as_str() {
471        assert_eq!(format!("{}", ToolProfile::Minimal), "minimal");
472        assert_eq!(format!("{}", ToolProfile::Standard), "standard");
473        assert_eq!(format!("{}", ToolProfile::Power), "power");
474        assert_eq!(
475            format!("{}", ToolProfile::Custom(vec!["ctx_read".into()])),
476            "custom"
477        );
478    }
479
480    #[test]
481    fn tool_count_matches_list_length() {
482        assert_eq!(ToolProfile::Minimal.tool_count(), MINIMAL_TOOLS.len());
483        assert_eq!(ToolProfile::Standard.tool_count(), STANDARD_TOOLS.len());
484        assert_eq!(ToolProfile::Power.tool_count(), 0);
485        assert_eq!(ToolProfile::Auto.tool_count(), MINIMAL_TOOLS.len());
486    }
487
488    #[test]
489    fn from_config_defaults_to_power_for_backward_compat() {
490        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
491            return;
492        }
493        let cfg = crate::core::config::Config {
494            tool_profile: None,
495            tools_enabled: vec![],
496            ..Default::default()
497        };
498        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Power);
499    }
500
501    #[test]
502    fn from_config_respects_tool_profile_field() {
503        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
504            return;
505        }
506        let cfg = crate::core::config::Config {
507            tool_profile: Some("minimal".to_string()),
508            tools_enabled: vec![],
509            ..Default::default()
510        };
511        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Minimal);
512    }
513
514    #[test]
515    fn from_config_tools_enabled_creates_custom() {
516        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
517            return;
518        }
519        let cfg = crate::core::config::Config {
520            tool_profile: None,
521            tools_enabled: vec!["ctx_read".to_string(), "ctx_shell".to_string()],
522            ..Default::default()
523        };
524        let profile = ToolProfile::from_config(&cfg);
525        assert_eq!(
526            profile,
527            ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()])
528        );
529    }
530
531    #[test]
532    fn tool_profile_takes_precedence_over_tools_enabled() {
533        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
534            return;
535        }
536        let cfg = crate::core::config::Config {
537            tool_profile: Some("standard".to_string()),
538            tools_enabled: vec!["ctx_read".to_string()],
539            ..Default::default()
540        };
541        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Standard);
542    }
543
544    #[test]
545    fn empty_tool_profile_is_unpinned_so_tools_enabled_applies() {
546        // #613: `tool_profile = ""` is the documented default ("unpin"), not a
547        // bogus profile. It must resolve silently via the unpinned path (so an
548        // explicit `tools_enabled` takes effect) instead of warning
549        // "Unknown tool_profile ''".
550        assert!(is_unpinned_alias(""));
551        assert!(is_unpinned_alias("   "));
552
553        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
554            return;
555        }
556        let cfg = crate::core::config::Config {
557            tool_profile: Some(String::new()),
558            tools_enabled: vec!["ctx_read".to_string()],
559            ..Default::default()
560        };
561        assert_eq!(
562            ToolProfile::from_config(&cfg),
563            ToolProfile::Custom(vec!["ctx_read".to_string()]),
564            "empty tool_profile must unpin so tools_enabled takes effect"
565        );
566    }
567
568    #[test]
569    fn all_profile_names_are_parseable() {
570        for name in PROFILE_NAMES {
571            assert!(
572                ToolProfile::parse(name).is_some(),
573                "profile name '{name}' should be parseable"
574            );
575        }
576    }
577
578    #[test]
579    fn list_profiles_returns_four_entries() {
580        let profiles = list_profiles();
581        assert_eq!(profiles.len(), 4);
582    }
583
584    #[test]
585    fn resolve_auto_short_session_returns_minimal() {
586        assert_eq!(
587            ToolProfile::resolve_auto(0, false, 500),
588            ToolProfile::Minimal
589        );
590        assert_eq!(
591            ToolProfile::resolve_auto(1, false, 1500),
592            ToolProfile::Minimal
593        );
594    }
595
596    #[test]
597    fn resolve_auto_ctx_tools_used_returns_standard() {
598        assert_eq!(
599            ToolProfile::resolve_auto(1, true, 500),
600            ToolProfile::Standard
601        );
602        assert_eq!(ToolProfile::resolve_auto(0, true, 0), ToolProfile::Standard);
603    }
604
605    #[test]
606    fn resolve_auto_long_session_returns_power() {
607        assert_eq!(
608            ToolProfile::resolve_auto(10, false, 500),
609            ToolProfile::Power
610        );
611        assert_eq!(
612            ToolProfile::resolve_auto(20, false, 3000),
613            ToolProfile::Power
614        );
615    }
616
617    #[test]
618    fn resolve_auto_medium_session_returns_standard() {
619        assert_eq!(
620            ToolProfile::resolve_auto(5, false, 3000),
621            ToolProfile::Standard
622        );
623    }
624
625    #[test]
626    fn auto_profile_display() {
627        assert_eq!(ToolProfile::Auto.as_str(), "auto");
628        assert_eq!(format!("{}", ToolProfile::Auto), "auto");
629    }
630
631    #[test]
632    fn standard_includes_all_core_tools() {
633        let profile = ToolProfile::Standard;
634        assert!(
635            profile.is_tool_enabled("ctx_graph"),
636            "ctx_graph must be in standard"
637        );
638        assert!(
639            profile.is_tool_enabled("ctx_delta"),
640            "ctx_delta must be in standard"
641        );
642        assert!(
643            profile.is_tool_enabled("ctx_expand"),
644            "ctx_expand must be in standard"
645        );
646        assert!(
647            profile.is_tool_enabled("ctx_execute"),
648            "ctx_execute must be in standard (sandboxed code execution)"
649        );
650        // ctx_edit is power-only — for editing, Standard carries the anchored
651        // ctx_patch instead (#1008); str_replace stays a power fallback.
652        assert!(
653            !profile.is_tool_enabled("ctx_edit"),
654            "ctx_edit must NOT be in standard (ctx_patch is the standard editor)"
655        );
656        assert!(
657            profile.is_tool_enabled("ctx_patch"),
658            "ctx_patch must be in standard (#1008 anchored editing)"
659        );
660    }
661
662    #[test]
663    fn standard_includes_url_read() {
664        let profile = ToolProfile::Standard;
665        assert!(
666            profile.is_tool_enabled("ctx_url_read"),
667            "ctx_url_read must be in standard (web/research context)"
668        );
669    }
670
671    #[test]
672    fn clear_profile_removes_key_and_is_idempotent() {
673        let iso = crate::core::data_dir::isolated_data_dir();
674        set_profile_in_config("power").unwrap();
675        let config_path = iso.path().join("config.toml");
676        assert!(
677            std::fs::read_to_string(&config_path)
678                .unwrap()
679                .contains("tool_profile"),
680            "set_profile_in_config must write the key"
681        );
682
683        clear_profile_in_config().unwrap();
684        assert!(
685            !std::fs::read_to_string(&config_path)
686                .unwrap()
687                .contains("tool_profile"),
688            "clear_profile_in_config must remove the key (lean default, #575)"
689        );
690
691        // Idempotent: clearing again (and on a missing file) must not fail.
692        clear_profile_in_config().unwrap();
693    }
694
695    #[test]
696    fn clear_profile_on_missing_config_is_ok() {
697        let _iso = crate::core::data_dir::isolated_data_dir();
698        clear_profile_in_config().unwrap();
699    }
700
701    #[test]
702    fn without_tool_removes_from_power() {
703        let filtered = ToolProfile::Power.without_tool("ctx_patch");
704        assert!(!filtered.is_tool_enabled("ctx_patch"));
705        assert!(filtered.is_tool_enabled("ctx_read"));
706        assert!(filtered.is_tool_enabled("ctx_search"));
707    }
708
709    #[test]
710    fn without_tool_removes_from_standard() {
711        let filtered = ToolProfile::Standard.without_tool("ctx_patch");
712        assert!(!filtered.is_tool_enabled("ctx_patch"));
713        assert!(filtered.is_tool_enabled("ctx_read"));
714        assert!(filtered.is_tool_enabled("ctx_compose"));
715    }
716
717    #[test]
718    fn without_tool_noop_for_already_missing() {
719        let filtered = ToolProfile::Minimal.without_tool("ctx_patch");
720        assert!(!filtered.is_tool_enabled("ctx_patch"));
721        assert!(filtered.is_tool_enabled("ctx_read"));
722    }
723}