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 => "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, 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 "shell",
114 "ctx_search",
115 "ctx_tree",
116 "ctx_session",
117];
118
119const STANDARD_TOOLS: &[&str] = &[
120 "ctx_read",
122 "ctx_shell",
123 "shell",
124 "ctx_search",
125 "ctx_tree",
126 "ctx_session",
127 "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 "ctx_url_read",
145];
146
147pub 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
176pub 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 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}