1use std::fmt;
2
3#[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, 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 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 "ctx_read",
130 "ctx_shell",
131 "shell",
132 "ctx_search",
133 "ctx_tree",
134 "ctx_session",
135 "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 "ctx_url_read",
153];
154
155pub 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
184pub fn set_profile_in_config(profile_name: &str) -> Result<(), String> {
187 let config_path = crate::core::config::Config::path()
191 .ok_or_else(|| "Cannot determine config dir".to_string())?;
192
193 let mut doc = crate::config_io::load_toml_document(&config_path);
194 doc["tool_profile"] = toml_edit::value(profile_name);
195 crate::config_io::write_toml_document(&config_path, &doc)?;
196 Ok(())
197}
198
199pub fn clear_profile_in_config() -> Result<(), String> {
204 let config_path = crate::core::config::Config::path()
205 .ok_or_else(|| "Cannot determine config dir".to_string())?;
206 if !config_path.exists() {
207 return Ok(());
208 }
209
210 let mut doc = crate::config_io::load_toml_document(&config_path);
211 if doc.remove("tool_profile").is_none() {
212 return Ok(());
213 }
214 crate::config_io::write_toml_document(&config_path, &doc)?;
215 Ok(())
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn parse_known_profiles() {
224 assert_eq!(ToolProfile::parse("minimal"), Some(ToolProfile::Minimal));
225 assert_eq!(ToolProfile::parse("min"), Some(ToolProfile::Minimal));
226 assert_eq!(ToolProfile::parse("standard"), Some(ToolProfile::Standard));
227 assert_eq!(ToolProfile::parse("std"), Some(ToolProfile::Standard));
228 assert_eq!(ToolProfile::parse("default"), Some(ToolProfile::Standard));
229 assert_eq!(ToolProfile::parse("power"), Some(ToolProfile::Power));
230 assert_eq!(ToolProfile::parse("full"), Some(ToolProfile::Power));
231 assert_eq!(ToolProfile::parse("all"), Some(ToolProfile::Power));
232 }
233
234 #[test]
235 fn parse_case_insensitive() {
236 assert_eq!(ToolProfile::parse("MINIMAL"), Some(ToolProfile::Minimal));
237 assert_eq!(ToolProfile::parse("Standard"), Some(ToolProfile::Standard));
238 assert_eq!(ToolProfile::parse("POWER"), Some(ToolProfile::Power));
239 }
240
241 #[test]
242 fn parse_unknown_returns_none() {
243 assert_eq!(ToolProfile::parse("unknown"), None);
244 assert_eq!(ToolProfile::parse(""), None);
245 }
246
247 #[test]
248 fn minimal_has_6_tools() {
249 assert_eq!(MINIMAL_TOOLS.len(), 6);
250 }
251
252 #[test]
253 fn standard_has_22_tools() {
254 assert_eq!(STANDARD_TOOLS.len(), 22);
255 }
256
257 #[test]
258 fn minimal_is_subset_of_standard() {
259 for tool in MINIMAL_TOOLS {
260 assert!(
261 STANDARD_TOOLS.contains(tool),
262 "minimal tool {tool} missing from standard"
263 );
264 }
265 }
266
267 #[test]
268 fn power_enables_everything() {
269 let profile = ToolProfile::Power;
270 assert!(profile.is_tool_enabled("ctx_read"));
271 assert!(profile.is_tool_enabled("ctx_anything"));
272 assert!(profile.is_tool_enabled("nonexistent_tool"));
273 }
274
275 #[test]
276 fn minimal_filters_correctly() {
277 let profile = ToolProfile::Minimal;
278 assert!(profile.is_tool_enabled("ctx_read"));
279 assert!(profile.is_tool_enabled("ctx_shell"));
280 assert!(profile.is_tool_enabled("ctx_search"));
281 assert!(profile.is_tool_enabled("ctx_tree"));
282 assert!(profile.is_tool_enabled("ctx_session"));
283 assert!(!profile.is_tool_enabled("ctx_semantic_search"));
284 assert!(!profile.is_tool_enabled("ctx_architecture"));
285 assert!(!profile.is_tool_enabled("ctx_benchmark"));
286 }
287
288 #[test]
289 fn standard_filters_correctly() {
290 let profile = ToolProfile::Standard;
291 assert!(profile.is_tool_enabled("ctx_read"));
292 assert!(profile.is_tool_enabled("ctx_semantic_search"));
293 assert!(profile.is_tool_enabled("ctx_architecture"));
294 assert!(!profile.is_tool_enabled("ctx_benchmark"));
295 assert!(!profile.is_tool_enabled("ctx_analyze"));
296 assert!(!profile.is_tool_enabled("ctx_smells"));
297 }
298
299 #[test]
300 fn custom_profile_uses_provided_list() {
301 let profile = ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()]);
302 assert!(profile.is_tool_enabled("ctx_read"));
303 assert!(profile.is_tool_enabled("ctx_shell"));
304 assert!(!profile.is_tool_enabled("ctx_search"));
305 }
306
307 #[test]
308 fn profile_display_counts_match_tool_arrays() {
309 let profiles = list_profiles();
313 assert_eq!(
314 profiles[0].tool_count.parse::<usize>().unwrap(),
315 MINIMAL_TOOLS.len(),
316 "minimal count must match MINIMAL_TOOLS length",
317 );
318 assert_eq!(
319 profiles[1].tool_count.parse::<usize>().unwrap(),
320 STANDARD_TOOLS.len(),
321 "standard count must match STANDARD_TOOLS length",
322 );
323 assert_eq!(profiles[2].tool_count, "all");
324 }
325
326 #[test]
327 fn custom_empty_enables_nothing() {
328 let profile = ToolProfile::Custom(vec![]);
329 assert!(!profile.is_tool_enabled("ctx_read"));
330 }
331
332 #[test]
333 fn display_matches_as_str() {
334 assert_eq!(format!("{}", ToolProfile::Minimal), "minimal");
335 assert_eq!(format!("{}", ToolProfile::Standard), "standard");
336 assert_eq!(format!("{}", ToolProfile::Power), "power");
337 assert_eq!(
338 format!("{}", ToolProfile::Custom(vec!["ctx_read".into()])),
339 "custom"
340 );
341 }
342
343 #[test]
344 fn tool_count_matches_list_length() {
345 assert_eq!(ToolProfile::Minimal.tool_count(), MINIMAL_TOOLS.len());
346 assert_eq!(ToolProfile::Standard.tool_count(), STANDARD_TOOLS.len());
347 assert_eq!(ToolProfile::Power.tool_count(), 0);
348 }
349
350 #[test]
351 fn from_config_defaults_to_power_for_backward_compat() {
352 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
353 return;
354 }
355 let cfg = crate::core::config::Config {
356 tool_profile: None,
357 tools_enabled: vec![],
358 ..Default::default()
359 };
360 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Power);
361 }
362
363 #[test]
364 fn from_config_respects_tool_profile_field() {
365 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
366 return;
367 }
368 let cfg = crate::core::config::Config {
369 tool_profile: Some("minimal".to_string()),
370 tools_enabled: vec![],
371 ..Default::default()
372 };
373 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Minimal);
374 }
375
376 #[test]
377 fn from_config_tools_enabled_creates_custom() {
378 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
379 return;
380 }
381 let cfg = crate::core::config::Config {
382 tool_profile: None,
383 tools_enabled: vec!["ctx_read".to_string(), "ctx_shell".to_string()],
384 ..Default::default()
385 };
386 let profile = ToolProfile::from_config(&cfg);
387 assert_eq!(
388 profile,
389 ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()])
390 );
391 }
392
393 #[test]
394 fn tool_profile_takes_precedence_over_tools_enabled() {
395 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
396 return;
397 }
398 let cfg = crate::core::config::Config {
399 tool_profile: Some("standard".to_string()),
400 tools_enabled: vec!["ctx_read".to_string()],
401 ..Default::default()
402 };
403 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Standard);
404 }
405
406 #[test]
407 fn all_profile_names_are_parseable() {
408 for name in PROFILE_NAMES {
409 assert!(
410 ToolProfile::parse(name).is_some(),
411 "profile name '{name}' should be parseable"
412 );
413 }
414 }
415
416 #[test]
417 fn list_profiles_returns_three_entries() {
418 let profiles = list_profiles();
419 assert_eq!(profiles.len(), 3);
420 }
421
422 #[test]
423 fn standard_includes_edit_and_delta() {
424 let profile = ToolProfile::Standard;
425 assert!(
426 profile.is_tool_enabled("ctx_edit"),
427 "ctx_edit must be in standard"
428 );
429 assert!(
430 profile.is_tool_enabled("ctx_delta"),
431 "ctx_delta must be in standard"
432 );
433 }
434
435 #[test]
436 fn standard_includes_url_read() {
437 let profile = ToolProfile::Standard;
438 assert!(
439 profile.is_tool_enabled("ctx_url_read"),
440 "ctx_url_read must be in standard (web/research context)"
441 );
442 }
443
444 #[test]
445 fn clear_profile_removes_key_and_is_idempotent() {
446 let iso = crate::core::data_dir::isolated_data_dir();
447 set_profile_in_config("power").unwrap();
448 let config_path = iso.path().join("config.toml");
449 assert!(
450 std::fs::read_to_string(&config_path)
451 .unwrap()
452 .contains("tool_profile"),
453 "set_profile_in_config must write the key"
454 );
455
456 clear_profile_in_config().unwrap();
457 assert!(
458 !std::fs::read_to_string(&config_path)
459 .unwrap()
460 .contains("tool_profile"),
461 "clear_profile_in_config must remove the key (lean default, #575)"
462 );
463
464 clear_profile_in_config().unwrap();
466 }
467
468 #[test]
469 fn clear_profile_on_missing_config_is_ok() {
470 let _iso = crate::core::data_dir::isolated_data_dir();
471 clear_profile_in_config().unwrap();
472 }
473}