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 if !trimmed.is_empty() && !is_unpinned_alias(trimmed) {
96 tracing::warn!("Unknown LEAN_CTX_TOOL_PROFILE value '{trimmed}', using config");
97 }
98 }
99
100 if let Some(ref profile_name) = cfg.tool_profile {
101 if let Some(profile) = Self::parse(profile_name) {
102 return profile;
103 }
104 if !is_unpinned_alias(profile_name) {
110 tracing::warn!("Unknown tool_profile '{profile_name}' in config, using default");
111 }
112 }
113
114 if !cfg.tools_enabled.is_empty() {
115 return Self::Custom(cfg.tools_enabled.clone());
116 }
117
118 Self::Power
119 }
120}
121
122impl fmt::Display for ToolProfile {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 write!(f, "{}", self.as_str())
125 }
126}
127
128pub fn is_unpinned_alias(name: &str) -> bool {
133 matches!(
134 name.trim().to_ascii_lowercase().as_str(),
135 "lean" | "lazy" | "reset"
136 )
137}
138
139const MINIMAL_TOOLS: &[&str] = &[
140 "ctx_read",
141 "ctx_shell",
142 "shell",
143 "ctx_search",
144 "ctx_tree",
145 "ctx_session",
146];
147
148const STANDARD_TOOLS: &[&str] = &[
149 "ctx_read",
151 "ctx_shell",
152 "shell",
153 "ctx_search",
154 "ctx_tree",
155 "ctx_session",
156 "ctx_semantic_search",
158 "ctx_knowledge",
159 "ctx_overview",
160 "ctx_repomap",
161 "ctx_callgraph",
162 "ctx_impact",
163 "ctx_compress",
164 "ctx_multi_read",
165 "ctx_delta",
166 "ctx_edit",
167 "ctx_agent",
168 "ctx_architecture",
169 "ctx_pack",
170 "ctx_routes",
171 "ctx_refactor",
172 "ctx_url_read",
174];
175
176pub const PROFILE_NAMES: &[&str] = &["minimal", "standard", "power"];
178
179pub struct ProfileInfo {
180 pub name: &'static str,
181 pub tool_count: &'static str,
182 pub description: &'static str,
183}
184
185pub fn list_profiles() -> Vec<ProfileInfo> {
186 vec![
187 ProfileInfo {
188 name: "minimal",
189 tool_count: "6",
190 description: "Essential tools for new users / skeptics",
191 },
192 ProfileInfo {
193 name: "standard",
194 tool_count: "22",
195 description: "Balanced set (recommended for most users)",
196 },
197 ProfileInfo {
198 name: "power",
199 tool_count: "all",
200 description: "Every tool exposed (backward compatible)",
201 },
202 ]
203}
204
205pub fn set_profile_in_config(profile_name: &str) -> Result<(), String> {
208 let config_path = crate::core::config::Config::path()
212 .ok_or_else(|| "Cannot determine config dir".to_string())?;
213
214 let mut doc = crate::config_io::load_toml_document(&config_path);
215 doc["tool_profile"] = toml_edit::value(profile_name);
216 crate::config_io::write_toml_document(&config_path, &doc)?;
217 Ok(())
218}
219
220pub fn clear_profile_in_config() -> Result<(), String> {
225 let config_path = crate::core::config::Config::path()
226 .ok_or_else(|| "Cannot determine config dir".to_string())?;
227 if !config_path.exists() {
228 return Ok(());
229 }
230
231 let mut doc = crate::config_io::load_toml_document(&config_path);
232 if doc.remove("tool_profile").is_none() {
233 return Ok(());
234 }
235 crate::config_io::write_toml_document(&config_path, &doc)?;
236 Ok(())
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn parse_known_profiles() {
245 assert_eq!(ToolProfile::parse("minimal"), Some(ToolProfile::Minimal));
246 assert_eq!(ToolProfile::parse("min"), Some(ToolProfile::Minimal));
247 assert_eq!(ToolProfile::parse("standard"), Some(ToolProfile::Standard));
248 assert_eq!(ToolProfile::parse("std"), Some(ToolProfile::Standard));
249 assert_eq!(ToolProfile::parse("default"), Some(ToolProfile::Standard));
250 assert_eq!(ToolProfile::parse("power"), Some(ToolProfile::Power));
251 assert_eq!(ToolProfile::parse("full"), Some(ToolProfile::Power));
252 assert_eq!(ToolProfile::parse("all"), Some(ToolProfile::Power));
253 }
254
255 #[test]
256 fn parse_case_insensitive() {
257 assert_eq!(ToolProfile::parse("MINIMAL"), Some(ToolProfile::Minimal));
258 assert_eq!(ToolProfile::parse("Standard"), Some(ToolProfile::Standard));
259 assert_eq!(ToolProfile::parse("POWER"), Some(ToolProfile::Power));
260 }
261
262 #[test]
263 fn parse_unknown_returns_none() {
264 assert_eq!(ToolProfile::parse("unknown"), None);
265 assert_eq!(ToolProfile::parse(""), None);
266 }
267
268 #[test]
269 fn minimal_has_6_tools() {
270 assert_eq!(MINIMAL_TOOLS.len(), 6);
271 }
272
273 #[test]
274 fn minimal_profile_schema_budget() {
275 const MINIMAL_SCHEMA_BUDGET_TOKENS: usize = 1500;
281 let defs = crate::server::registry::build_registry().tool_defs();
282 let total: usize = defs
283 .iter()
284 .filter(|t| MINIMAL_TOOLS.contains(&t.name.as_ref()))
285 .map(crate::core::context_overhead::tool_tokens)
286 .sum();
287 assert!(total > 0, "minimal tools must exist in the registry");
288 assert!(
289 total <= MINIMAL_SCHEMA_BUDGET_TOKENS,
290 "minimal-profile tool schemas = {total} tok, budget {MINIMAL_SCHEMA_BUDGET_TOKENS}"
291 );
292 }
293
294 #[test]
295 fn standard_has_22_tools() {
296 assert_eq!(STANDARD_TOOLS.len(), 22);
297 }
298
299 #[test]
300 fn minimal_is_subset_of_standard() {
301 for tool in MINIMAL_TOOLS {
302 assert!(
303 STANDARD_TOOLS.contains(tool),
304 "minimal tool {tool} missing from standard"
305 );
306 }
307 }
308
309 #[test]
310 fn power_enables_everything() {
311 let profile = ToolProfile::Power;
312 assert!(profile.is_tool_enabled("ctx_read"));
313 assert!(profile.is_tool_enabled("ctx_anything"));
314 assert!(profile.is_tool_enabled("nonexistent_tool"));
315 }
316
317 #[test]
318 fn minimal_filters_correctly() {
319 let profile = ToolProfile::Minimal;
320 assert!(profile.is_tool_enabled("ctx_read"));
321 assert!(profile.is_tool_enabled("ctx_shell"));
322 assert!(profile.is_tool_enabled("ctx_search"));
323 assert!(profile.is_tool_enabled("ctx_tree"));
324 assert!(profile.is_tool_enabled("ctx_session"));
325 assert!(!profile.is_tool_enabled("ctx_semantic_search"));
326 assert!(!profile.is_tool_enabled("ctx_architecture"));
327 assert!(!profile.is_tool_enabled("ctx_benchmark"));
328 }
329
330 #[test]
331 fn standard_filters_correctly() {
332 let profile = ToolProfile::Standard;
333 assert!(profile.is_tool_enabled("ctx_read"));
334 assert!(profile.is_tool_enabled("ctx_semantic_search"));
335 assert!(profile.is_tool_enabled("ctx_architecture"));
336 assert!(!profile.is_tool_enabled("ctx_benchmark"));
337 assert!(!profile.is_tool_enabled("ctx_analyze"));
338 assert!(!profile.is_tool_enabled("ctx_smells"));
339 }
340
341 #[test]
342 fn custom_profile_uses_provided_list() {
343 let profile = ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()]);
344 assert!(profile.is_tool_enabled("ctx_read"));
345 assert!(profile.is_tool_enabled("ctx_shell"));
346 assert!(!profile.is_tool_enabled("ctx_search"));
347 }
348
349 #[test]
350 fn profile_display_counts_match_tool_arrays() {
351 let profiles = list_profiles();
355 assert_eq!(
356 profiles[0].tool_count.parse::<usize>().unwrap(),
357 MINIMAL_TOOLS.len(),
358 "minimal count must match MINIMAL_TOOLS length",
359 );
360 assert_eq!(
361 profiles[1].tool_count.parse::<usize>().unwrap(),
362 STANDARD_TOOLS.len(),
363 "standard count must match STANDARD_TOOLS length",
364 );
365 assert_eq!(profiles[2].tool_count, "all");
366 }
367
368 #[test]
369 fn custom_empty_enables_nothing() {
370 let profile = ToolProfile::Custom(vec![]);
371 assert!(!profile.is_tool_enabled("ctx_read"));
372 }
373
374 #[test]
375 fn display_matches_as_str() {
376 assert_eq!(format!("{}", ToolProfile::Minimal), "minimal");
377 assert_eq!(format!("{}", ToolProfile::Standard), "standard");
378 assert_eq!(format!("{}", ToolProfile::Power), "power");
379 assert_eq!(
380 format!("{}", ToolProfile::Custom(vec!["ctx_read".into()])),
381 "custom"
382 );
383 }
384
385 #[test]
386 fn tool_count_matches_list_length() {
387 assert_eq!(ToolProfile::Minimal.tool_count(), MINIMAL_TOOLS.len());
388 assert_eq!(ToolProfile::Standard.tool_count(), STANDARD_TOOLS.len());
389 assert_eq!(ToolProfile::Power.tool_count(), 0);
390 }
391
392 #[test]
393 fn from_config_defaults_to_power_for_backward_compat() {
394 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
395 return;
396 }
397 let cfg = crate::core::config::Config {
398 tool_profile: None,
399 tools_enabled: vec![],
400 ..Default::default()
401 };
402 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Power);
403 }
404
405 #[test]
406 fn from_config_respects_tool_profile_field() {
407 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
408 return;
409 }
410 let cfg = crate::core::config::Config {
411 tool_profile: Some("minimal".to_string()),
412 tools_enabled: vec![],
413 ..Default::default()
414 };
415 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Minimal);
416 }
417
418 #[test]
419 fn from_config_tools_enabled_creates_custom() {
420 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
421 return;
422 }
423 let cfg = crate::core::config::Config {
424 tool_profile: None,
425 tools_enabled: vec!["ctx_read".to_string(), "ctx_shell".to_string()],
426 ..Default::default()
427 };
428 let profile = ToolProfile::from_config(&cfg);
429 assert_eq!(
430 profile,
431 ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()])
432 );
433 }
434
435 #[test]
436 fn tool_profile_takes_precedence_over_tools_enabled() {
437 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
438 return;
439 }
440 let cfg = crate::core::config::Config {
441 tool_profile: Some("standard".to_string()),
442 tools_enabled: vec!["ctx_read".to_string()],
443 ..Default::default()
444 };
445 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Standard);
446 }
447
448 #[test]
449 fn all_profile_names_are_parseable() {
450 for name in PROFILE_NAMES {
451 assert!(
452 ToolProfile::parse(name).is_some(),
453 "profile name '{name}' should be parseable"
454 );
455 }
456 }
457
458 #[test]
459 fn list_profiles_returns_three_entries() {
460 let profiles = list_profiles();
461 assert_eq!(profiles.len(), 3);
462 }
463
464 #[test]
465 fn standard_includes_edit_and_delta() {
466 let profile = ToolProfile::Standard;
467 assert!(
468 profile.is_tool_enabled("ctx_edit"),
469 "ctx_edit must be in standard"
470 );
471 assert!(
472 profile.is_tool_enabled("ctx_delta"),
473 "ctx_delta must be in standard"
474 );
475 }
476
477 #[test]
478 fn standard_includes_url_read() {
479 let profile = ToolProfile::Standard;
480 assert!(
481 profile.is_tool_enabled("ctx_url_read"),
482 "ctx_url_read must be in standard (web/research context)"
483 );
484 }
485
486 #[test]
487 fn clear_profile_removes_key_and_is_idempotent() {
488 let iso = crate::core::data_dir::isolated_data_dir();
489 set_profile_in_config("power").unwrap();
490 let config_path = iso.path().join("config.toml");
491 assert!(
492 std::fs::read_to_string(&config_path)
493 .unwrap()
494 .contains("tool_profile"),
495 "set_profile_in_config must write the key"
496 );
497
498 clear_profile_in_config().unwrap();
499 assert!(
500 !std::fs::read_to_string(&config_path)
501 .unwrap()
502 .contains("tool_profile"),
503 "clear_profile_in_config must remove the key (lean default, #575)"
504 );
505
506 clear_profile_in_config().unwrap();
508 }
509
510 #[test]
511 fn clear_profile_on_missing_config_is_ok() {
512 let _iso = crate::core::data_dir::isolated_data_dir();
513 clear_profile_in_config().unwrap();
514 }
515}