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