1use std::fmt;
2
3#[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 => "5 essential tools for new users",
37 Self::Standard => "20 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, 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 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 "ctx_search",
114 "ctx_tree",
115 "ctx_session",
116];
117
118const STANDARD_TOOLS: &[&str] = &[
119 "ctx_read",
121 "ctx_shell",
122 "ctx_search",
123 "ctx_tree",
124 "ctx_session",
125 "ctx_semantic_search",
127 "ctx_knowledge",
128 "ctx_overview",
129 "ctx_repomap",
130 "ctx_callgraph",
131 "ctx_impact",
132 "ctx_compress",
133 "ctx_multi_read",
134 "ctx_delta",
135 "ctx_edit",
136 "ctx_agent",
137 "ctx_architecture",
138 "ctx_pack",
139 "ctx_routes",
140 "ctx_refactor",
141];
142
143pub const PROFILE_NAMES: &[&str] = &["minimal", "standard", "power"];
145
146pub struct ProfileInfo {
147 pub name: &'static str,
148 pub tool_count: &'static str,
149 pub description: &'static str,
150}
151
152pub fn list_profiles() -> Vec<ProfileInfo> {
153 vec![
154 ProfileInfo {
155 name: "minimal",
156 tool_count: "5",
157 description: "Essential tools for new users / skeptics",
158 },
159 ProfileInfo {
160 name: "standard",
161 tool_count: "20",
162 description: "Balanced set (recommended for most users)",
163 },
164 ProfileInfo {
165 name: "power",
166 tool_count: "all",
167 description: "Every tool exposed (backward compatible)",
168 },
169 ]
170}
171
172pub fn set_profile_in_config(profile_name: &str) -> Result<(), String> {
175 let config_dir = crate::core::data_dir::lean_ctx_data_dir()
176 .map_err(|e| format!("Cannot determine config dir: {e}"))?;
177 let config_path = config_dir.join("config.toml");
178
179 let mut doc = crate::config_io::load_toml_document(&config_path);
180 doc["tool_profile"] = toml_edit::value(profile_name);
181 crate::config_io::write_toml_document(&config_path, &doc)?;
182 Ok(())
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn parse_known_profiles() {
191 assert_eq!(ToolProfile::parse("minimal"), Some(ToolProfile::Minimal));
192 assert_eq!(ToolProfile::parse("min"), Some(ToolProfile::Minimal));
193 assert_eq!(ToolProfile::parse("standard"), Some(ToolProfile::Standard));
194 assert_eq!(ToolProfile::parse("std"), Some(ToolProfile::Standard));
195 assert_eq!(ToolProfile::parse("default"), Some(ToolProfile::Standard));
196 assert_eq!(ToolProfile::parse("power"), Some(ToolProfile::Power));
197 assert_eq!(ToolProfile::parse("full"), Some(ToolProfile::Power));
198 assert_eq!(ToolProfile::parse("all"), Some(ToolProfile::Power));
199 }
200
201 #[test]
202 fn parse_case_insensitive() {
203 assert_eq!(ToolProfile::parse("MINIMAL"), Some(ToolProfile::Minimal));
204 assert_eq!(ToolProfile::parse("Standard"), Some(ToolProfile::Standard));
205 assert_eq!(ToolProfile::parse("POWER"), Some(ToolProfile::Power));
206 }
207
208 #[test]
209 fn parse_unknown_returns_none() {
210 assert_eq!(ToolProfile::parse("unknown"), None);
211 assert_eq!(ToolProfile::parse(""), None);
212 }
213
214 #[test]
215 fn minimal_has_5_tools() {
216 assert_eq!(MINIMAL_TOOLS.len(), 5);
217 }
218
219 #[test]
220 fn standard_has_20_tools() {
221 assert_eq!(STANDARD_TOOLS.len(), 20);
222 }
223
224 #[test]
225 fn minimal_is_subset_of_standard() {
226 for tool in MINIMAL_TOOLS {
227 assert!(
228 STANDARD_TOOLS.contains(tool),
229 "minimal tool {tool} missing from standard"
230 );
231 }
232 }
233
234 #[test]
235 fn power_enables_everything() {
236 let profile = ToolProfile::Power;
237 assert!(profile.is_tool_enabled("ctx_read"));
238 assert!(profile.is_tool_enabled("ctx_anything"));
239 assert!(profile.is_tool_enabled("nonexistent_tool"));
240 }
241
242 #[test]
243 fn minimal_filters_correctly() {
244 let profile = ToolProfile::Minimal;
245 assert!(profile.is_tool_enabled("ctx_read"));
246 assert!(profile.is_tool_enabled("ctx_shell"));
247 assert!(profile.is_tool_enabled("ctx_search"));
248 assert!(profile.is_tool_enabled("ctx_tree"));
249 assert!(profile.is_tool_enabled("ctx_session"));
250 assert!(!profile.is_tool_enabled("ctx_semantic_search"));
251 assert!(!profile.is_tool_enabled("ctx_architecture"));
252 assert!(!profile.is_tool_enabled("ctx_benchmark"));
253 }
254
255 #[test]
256 fn standard_filters_correctly() {
257 let profile = ToolProfile::Standard;
258 assert!(profile.is_tool_enabled("ctx_read"));
259 assert!(profile.is_tool_enabled("ctx_semantic_search"));
260 assert!(profile.is_tool_enabled("ctx_architecture"));
261 assert!(!profile.is_tool_enabled("ctx_benchmark"));
262 assert!(!profile.is_tool_enabled("ctx_analyze"));
263 assert!(!profile.is_tool_enabled("ctx_smells"));
264 }
265
266 #[test]
267 fn custom_profile_uses_provided_list() {
268 let profile = ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()]);
269 assert!(profile.is_tool_enabled("ctx_read"));
270 assert!(profile.is_tool_enabled("ctx_shell"));
271 assert!(!profile.is_tool_enabled("ctx_search"));
272 }
273
274 #[test]
275 fn custom_empty_enables_nothing() {
276 let profile = ToolProfile::Custom(vec![]);
277 assert!(!profile.is_tool_enabled("ctx_read"));
278 }
279
280 #[test]
281 fn display_matches_as_str() {
282 assert_eq!(format!("{}", ToolProfile::Minimal), "minimal");
283 assert_eq!(format!("{}", ToolProfile::Standard), "standard");
284 assert_eq!(format!("{}", ToolProfile::Power), "power");
285 assert_eq!(
286 format!("{}", ToolProfile::Custom(vec!["ctx_read".into()])),
287 "custom"
288 );
289 }
290
291 #[test]
292 fn tool_count_matches_list_length() {
293 assert_eq!(ToolProfile::Minimal.tool_count(), MINIMAL_TOOLS.len());
294 assert_eq!(ToolProfile::Standard.tool_count(), STANDARD_TOOLS.len());
295 assert_eq!(ToolProfile::Power.tool_count(), 0);
296 }
297
298 #[test]
299 fn from_config_defaults_to_power_for_backward_compat() {
300 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
301 return;
302 }
303 let cfg = crate::core::config::Config {
304 tool_profile: None,
305 tools_enabled: vec![],
306 ..Default::default()
307 };
308 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Power);
309 }
310
311 #[test]
312 fn from_config_respects_tool_profile_field() {
313 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
314 return;
315 }
316 let cfg = crate::core::config::Config {
317 tool_profile: Some("minimal".to_string()),
318 tools_enabled: vec![],
319 ..Default::default()
320 };
321 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Minimal);
322 }
323
324 #[test]
325 fn from_config_tools_enabled_creates_custom() {
326 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
327 return;
328 }
329 let cfg = crate::core::config::Config {
330 tool_profile: None,
331 tools_enabled: vec!["ctx_read".to_string(), "ctx_shell".to_string()],
332 ..Default::default()
333 };
334 let profile = ToolProfile::from_config(&cfg);
335 assert_eq!(
336 profile,
337 ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()])
338 );
339 }
340
341 #[test]
342 fn tool_profile_takes_precedence_over_tools_enabled() {
343 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
344 return;
345 }
346 let cfg = crate::core::config::Config {
347 tool_profile: Some("standard".to_string()),
348 tools_enabled: vec!["ctx_read".to_string()],
349 ..Default::default()
350 };
351 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Standard);
352 }
353
354 #[test]
355 fn all_profile_names_are_parseable() {
356 for name in PROFILE_NAMES {
357 assert!(
358 ToolProfile::parse(name).is_some(),
359 "profile name '{name}' should be parseable"
360 );
361 }
362 }
363
364 #[test]
365 fn list_profiles_returns_three_entries() {
366 let profiles = list_profiles();
367 assert_eq!(profiles.len(), 3);
368 }
369
370 #[test]
371 fn standard_includes_edit_and_delta() {
372 let profile = ToolProfile::Standard;
373 assert!(
374 profile.is_tool_enabled("ctx_edit"),
375 "ctx_edit must be in standard"
376 );
377 assert!(
378 profile.is_tool_enabled("ctx_delta"),
379 "ctx_delta must be in standard"
380 );
381 }
382}