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 (~13 tools, `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 22 full schemas,
14/// `power` the whole registry (#575).
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum ToolProfile {
17    Minimal,
18    Standard,
19    Power,
20    Custom(Vec<String>),
21}
22
23impl ToolProfile {
24    pub fn parse(s: &str) -> Option<Self> {
25        match s.to_lowercase().as_str() {
26            "minimal" | "min" => Some(Self::Minimal),
27            "standard" | "std" | "default" => Some(Self::Standard),
28            "power" | "full" | "all" => Some(Self::Power),
29            _ => None,
30        }
31    }
32
33    pub fn as_str(&self) -> &str {
34        match self {
35            Self::Minimal => "minimal",
36            Self::Standard => "standard",
37            Self::Power => "power",
38            Self::Custom(_) => "custom",
39        }
40    }
41
42    pub fn description(&self) -> &str {
43        match self {
44            Self::Minimal => "6 essential tools for new users",
45            Self::Standard => "22 balanced tools (recommended)",
46            Self::Power => "All tools exposed",
47            Self::Custom(v) => {
48                if v.is_empty() {
49                    "Custom tool list (empty)"
50                } else {
51                    "Custom tool list"
52                }
53            }
54        }
55    }
56
57    pub fn is_tool_enabled(&self, tool_name: &str) -> bool {
58        match self {
59            Self::Power => true,
60            Self::Minimal => MINIMAL_TOOLS.contains(&tool_name),
61            Self::Standard => STANDARD_TOOLS.contains(&tool_name),
62            Self::Custom(list) => list.iter().any(|t| t == tool_name),
63        }
64    }
65
66    pub fn tool_count(&self) -> usize {
67        match self {
68            Self::Minimal => MINIMAL_TOOLS.len(),
69            Self::Standard => STANDARD_TOOLS.len(),
70            Self::Power => 0, // dynamic — caller should use registry count
71            Self::Custom(list) => list.len(),
72        }
73    }
74
75    pub fn tool_names(&self) -> Vec<&str> {
76        match self {
77            Self::Minimal => MINIMAL_TOOLS.to_vec(),
78            Self::Standard => STANDARD_TOOLS.to_vec(),
79            Self::Power | Self::Custom(_) => vec![],
80        }
81    }
82
83    /// Resolves the active tool profile from environment, then config.
84    ///
85    /// Priority: `LEAN_CTX_TOOL_PROFILE` env > config `tool_profile` > config `tools.enabled` > default.
86    /// Existing installs default to `power` (backward compat).
87    /// New installs set `standard` during setup.
88    pub fn from_config(cfg: &super::config::Config) -> Self {
89        if let Ok(val) = std::env::var("LEAN_CTX_TOOL_PROFILE") {
90            let trimmed = val.trim();
91            if let Some(profile) = Self::parse(trimmed) {
92                return profile;
93            }
94            tracing::warn!("Unknown LEAN_CTX_TOOL_PROFILE value '{trimmed}', using config");
95        }
96
97        if let Some(ref profile_name) = cfg.tool_profile {
98            if let Some(profile) = Self::parse(profile_name) {
99                return profile;
100            }
101            tracing::warn!("Unknown tool_profile '{profile_name}' in config, using default");
102        }
103
104        if !cfg.tools_enabled.is_empty() {
105            return Self::Custom(cfg.tools_enabled.clone());
106        }
107
108        Self::Power
109    }
110}
111
112impl fmt::Display for ToolProfile {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        write!(f, "{}", self.as_str())
115    }
116}
117
118const MINIMAL_TOOLS: &[&str] = &[
119    "ctx_read",
120    "ctx_shell",
121    "shell",
122    "ctx_search",
123    "ctx_tree",
124    "ctx_session",
125];
126
127const STANDARD_TOOLS: &[&str] = &[
128    // Everything in minimal
129    "ctx_read",
130    "ctx_shell",
131    "shell",
132    "ctx_search",
133    "ctx_tree",
134    "ctx_session",
135    // Plus balanced additions
136    "ctx_semantic_search",
137    "ctx_knowledge",
138    "ctx_overview",
139    "ctx_repomap",
140    "ctx_callgraph",
141    "ctx_impact",
142    "ctx_compress",
143    "ctx_multi_read",
144    "ctx_delta",
145    "ctx_edit",
146    "ctx_agent",
147    "ctx_architecture",
148    "ctx_pack",
149    "ctx_routes",
150    "ctx_refactor",
151    // Web/research context: fetch + cite external pages and YouTube transcripts
152    "ctx_url_read",
153];
154
155/// Available built-in profile names.
156pub const PROFILE_NAMES: &[&str] = &["minimal", "standard", "power"];
157
158pub struct ProfileInfo {
159    pub name: &'static str,
160    pub tool_count: &'static str,
161    pub description: &'static str,
162}
163
164pub fn list_profiles() -> Vec<ProfileInfo> {
165    vec![
166        ProfileInfo {
167            name: "minimal",
168            tool_count: "6",
169            description: "Essential tools for new users / skeptics",
170        },
171        ProfileInfo {
172            name: "standard",
173            tool_count: "22",
174            description: "Balanced set (recommended for most users)",
175        },
176        ProfileInfo {
177            name: "power",
178            tool_count: "all",
179            description: "Every tool exposed (backward compatible)",
180        },
181    ]
182}
183
184/// Writes the `tool_profile` setting to config.toml, preserving all comments,
185/// formatting, and unrelated keys (robust against substring/comment matches).
186pub fn set_profile_in_config(profile_name: &str) -> Result<(), String> {
187    let config_dir = crate::core::data_dir::lean_ctx_data_dir()
188        .map_err(|e| format!("Cannot determine config dir: {e}"))?;
189    let config_path = config_dir.join("config.toml");
190
191    let mut doc = crate::config_io::load_toml_document(&config_path);
192    doc["tool_profile"] = toml_edit::value(profile_name);
193    crate::config_io::write_toml_document(&config_path, &doc)?;
194    Ok(())
195}
196
197/// Removes the `tool_profile` key from config.toml, restoring the lean
198/// default: only the lazy core set (~13 tools) is advertised in `tools/list`,
199/// while every registered tool stays reachable through `ctx_call`. This is
200/// the recommended low-overhead mode (#575).
201pub fn clear_profile_in_config() -> Result<(), String> {
202    let config_dir = crate::core::data_dir::lean_ctx_data_dir()
203        .map_err(|e| format!("Cannot determine config dir: {e}"))?;
204    let config_path = config_dir.join("config.toml");
205    if !config_path.exists() {
206        return Ok(());
207    }
208
209    let mut doc = crate::config_io::load_toml_document(&config_path);
210    if doc.remove("tool_profile").is_none() {
211        return Ok(());
212    }
213    crate::config_io::write_toml_document(&config_path, &doc)?;
214    Ok(())
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn parse_known_profiles() {
223        assert_eq!(ToolProfile::parse("minimal"), Some(ToolProfile::Minimal));
224        assert_eq!(ToolProfile::parse("min"), Some(ToolProfile::Minimal));
225        assert_eq!(ToolProfile::parse("standard"), Some(ToolProfile::Standard));
226        assert_eq!(ToolProfile::parse("std"), Some(ToolProfile::Standard));
227        assert_eq!(ToolProfile::parse("default"), Some(ToolProfile::Standard));
228        assert_eq!(ToolProfile::parse("power"), Some(ToolProfile::Power));
229        assert_eq!(ToolProfile::parse("full"), Some(ToolProfile::Power));
230        assert_eq!(ToolProfile::parse("all"), Some(ToolProfile::Power));
231    }
232
233    #[test]
234    fn parse_case_insensitive() {
235        assert_eq!(ToolProfile::parse("MINIMAL"), Some(ToolProfile::Minimal));
236        assert_eq!(ToolProfile::parse("Standard"), Some(ToolProfile::Standard));
237        assert_eq!(ToolProfile::parse("POWER"), Some(ToolProfile::Power));
238    }
239
240    #[test]
241    fn parse_unknown_returns_none() {
242        assert_eq!(ToolProfile::parse("unknown"), None);
243        assert_eq!(ToolProfile::parse(""), None);
244    }
245
246    #[test]
247    fn minimal_has_6_tools() {
248        assert_eq!(MINIMAL_TOOLS.len(), 6);
249    }
250
251    #[test]
252    fn standard_has_22_tools() {
253        assert_eq!(STANDARD_TOOLS.len(), 22);
254    }
255
256    #[test]
257    fn minimal_is_subset_of_standard() {
258        for tool in MINIMAL_TOOLS {
259            assert!(
260                STANDARD_TOOLS.contains(tool),
261                "minimal tool {tool} missing from standard"
262            );
263        }
264    }
265
266    #[test]
267    fn power_enables_everything() {
268        let profile = ToolProfile::Power;
269        assert!(profile.is_tool_enabled("ctx_read"));
270        assert!(profile.is_tool_enabled("ctx_anything"));
271        assert!(profile.is_tool_enabled("nonexistent_tool"));
272    }
273
274    #[test]
275    fn minimal_filters_correctly() {
276        let profile = ToolProfile::Minimal;
277        assert!(profile.is_tool_enabled("ctx_read"));
278        assert!(profile.is_tool_enabled("ctx_shell"));
279        assert!(profile.is_tool_enabled("ctx_search"));
280        assert!(profile.is_tool_enabled("ctx_tree"));
281        assert!(profile.is_tool_enabled("ctx_session"));
282        assert!(!profile.is_tool_enabled("ctx_semantic_search"));
283        assert!(!profile.is_tool_enabled("ctx_architecture"));
284        assert!(!profile.is_tool_enabled("ctx_benchmark"));
285    }
286
287    #[test]
288    fn standard_filters_correctly() {
289        let profile = ToolProfile::Standard;
290        assert!(profile.is_tool_enabled("ctx_read"));
291        assert!(profile.is_tool_enabled("ctx_semantic_search"));
292        assert!(profile.is_tool_enabled("ctx_architecture"));
293        assert!(!profile.is_tool_enabled("ctx_benchmark"));
294        assert!(!profile.is_tool_enabled("ctx_analyze"));
295        assert!(!profile.is_tool_enabled("ctx_smells"));
296    }
297
298    #[test]
299    fn custom_profile_uses_provided_list() {
300        let profile = ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()]);
301        assert!(profile.is_tool_enabled("ctx_read"));
302        assert!(profile.is_tool_enabled("ctx_shell"));
303        assert!(!profile.is_tool_enabled("ctx_search"));
304    }
305
306    #[test]
307    fn profile_display_counts_match_tool_arrays() {
308        // The numbers shown by `lean-ctx tools` must equal the actual array
309        // lengths, so adding/removing a profile tool (e.g. the `shell` alias)
310        // can never silently desync the advertised count from reality.
311        let profiles = list_profiles();
312        assert_eq!(
313            profiles[0].tool_count.parse::<usize>().unwrap(),
314            MINIMAL_TOOLS.len(),
315            "minimal count must match MINIMAL_TOOLS length",
316        );
317        assert_eq!(
318            profiles[1].tool_count.parse::<usize>().unwrap(),
319            STANDARD_TOOLS.len(),
320            "standard count must match STANDARD_TOOLS length",
321        );
322        assert_eq!(profiles[2].tool_count, "all");
323    }
324
325    #[test]
326    fn custom_empty_enables_nothing() {
327        let profile = ToolProfile::Custom(vec![]);
328        assert!(!profile.is_tool_enabled("ctx_read"));
329    }
330
331    #[test]
332    fn display_matches_as_str() {
333        assert_eq!(format!("{}", ToolProfile::Minimal), "minimal");
334        assert_eq!(format!("{}", ToolProfile::Standard), "standard");
335        assert_eq!(format!("{}", ToolProfile::Power), "power");
336        assert_eq!(
337            format!("{}", ToolProfile::Custom(vec!["ctx_read".into()])),
338            "custom"
339        );
340    }
341
342    #[test]
343    fn tool_count_matches_list_length() {
344        assert_eq!(ToolProfile::Minimal.tool_count(), MINIMAL_TOOLS.len());
345        assert_eq!(ToolProfile::Standard.tool_count(), STANDARD_TOOLS.len());
346        assert_eq!(ToolProfile::Power.tool_count(), 0);
347    }
348
349    #[test]
350    fn from_config_defaults_to_power_for_backward_compat() {
351        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
352            return;
353        }
354        let cfg = crate::core::config::Config {
355            tool_profile: None,
356            tools_enabled: vec![],
357            ..Default::default()
358        };
359        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Power);
360    }
361
362    #[test]
363    fn from_config_respects_tool_profile_field() {
364        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
365            return;
366        }
367        let cfg = crate::core::config::Config {
368            tool_profile: Some("minimal".to_string()),
369            tools_enabled: vec![],
370            ..Default::default()
371        };
372        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Minimal);
373    }
374
375    #[test]
376    fn from_config_tools_enabled_creates_custom() {
377        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
378            return;
379        }
380        let cfg = crate::core::config::Config {
381            tool_profile: None,
382            tools_enabled: vec!["ctx_read".to_string(), "ctx_shell".to_string()],
383            ..Default::default()
384        };
385        let profile = ToolProfile::from_config(&cfg);
386        assert_eq!(
387            profile,
388            ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()])
389        );
390    }
391
392    #[test]
393    fn tool_profile_takes_precedence_over_tools_enabled() {
394        if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
395            return;
396        }
397        let cfg = crate::core::config::Config {
398            tool_profile: Some("standard".to_string()),
399            tools_enabled: vec!["ctx_read".to_string()],
400            ..Default::default()
401        };
402        assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Standard);
403    }
404
405    #[test]
406    fn all_profile_names_are_parseable() {
407        for name in PROFILE_NAMES {
408            assert!(
409                ToolProfile::parse(name).is_some(),
410                "profile name '{name}' should be parseable"
411            );
412        }
413    }
414
415    #[test]
416    fn list_profiles_returns_three_entries() {
417        let profiles = list_profiles();
418        assert_eq!(profiles.len(), 3);
419    }
420
421    #[test]
422    fn standard_includes_edit_and_delta() {
423        let profile = ToolProfile::Standard;
424        assert!(
425            profile.is_tool_enabled("ctx_edit"),
426            "ctx_edit must be in standard"
427        );
428        assert!(
429            profile.is_tool_enabled("ctx_delta"),
430            "ctx_delta must be in standard"
431        );
432    }
433
434    #[test]
435    fn standard_includes_url_read() {
436        let profile = ToolProfile::Standard;
437        assert!(
438            profile.is_tool_enabled("ctx_url_read"),
439            "ctx_url_read must be in standard (web/research context)"
440        );
441    }
442
443    #[test]
444    fn clear_profile_removes_key_and_is_idempotent() {
445        let iso = crate::core::data_dir::isolated_data_dir();
446        set_profile_in_config("power").unwrap();
447        let config_path = iso.path().join("config.toml");
448        assert!(
449            std::fs::read_to_string(&config_path)
450                .unwrap()
451                .contains("tool_profile"),
452            "set_profile_in_config must write the key"
453        );
454
455        clear_profile_in_config().unwrap();
456        assert!(
457            !std::fs::read_to_string(&config_path)
458                .unwrap()
459                .contains("tool_profile"),
460            "clear_profile_in_config must remove the key (lean default, #575)"
461        );
462
463        // Idempotent: clearing again (and on a missing file) must not fail.
464        clear_profile_in_config().unwrap();
465    }
466
467    #[test]
468    fn clear_profile_on_missing_config_is_ok() {
469        let _iso = crate::core::data_dir::isolated_data_dir();
470        clear_profile_in_config().unwrap();
471    }
472}