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_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
197pub 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 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 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}