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 => "5 surgical tools — each irreplaceable (recommended)",
45 Self::Standard => "15 balanced tools (adds compose, explore, callgraph, execute, more)",
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] = &[
145 "ctx_read",
146 "ctx_shell",
147 "ctx_search",
148 "ctx_glob",
149 "ctx_tree",
150];
151
152const STANDARD_TOOLS: &[&str] = &[
158 "ctx_read",
159 "ctx_shell",
160 "ctx_search",
161 "ctx_glob",
162 "ctx_tree",
163 "ctx_compose",
164 "ctx_explore",
165 "ctx_knowledge",
166 "ctx_callgraph",
167 "ctx_graph",
168 "ctx_delta",
169 "ctx_execute",
170 "ctx_expand",
171 "ctx_overview",
172 "ctx_url_read",
173];
174
175pub const PROFILE_NAMES: &[&str] = &["minimal", "standard", "power"];
177
178pub struct ProfileInfo {
179 pub name: &'static str,
180 pub tool_count: &'static str,
181 pub description: &'static str,
182}
183
184pub fn list_profiles() -> Vec<ProfileInfo> {
185 vec![
186 ProfileInfo {
187 name: "minimal",
188 tool_count: "5",
189 description: "Surgical core — each tool irreplaceable (recommended)",
190 },
191 ProfileInfo {
192 name: "standard",
193 tool_count: "15",
194 description: "Balanced set — adds compose, explore, callgraph, execute, delta, more",
195 },
196 ProfileInfo {
197 name: "power",
198 tool_count: "all",
199 description: "Every tool exposed (backward compatible)",
200 },
201 ]
202}
203
204pub fn set_profile_in_config(profile_name: &str) -> Result<(), String> {
207 let config_path = crate::core::config::Config::path()
211 .ok_or_else(|| "Cannot determine config dir".to_string())?;
212
213 let mut doc = crate::config_io::load_toml_document(&config_path);
214 doc["tool_profile"] = toml_edit::value(profile_name);
215 crate::config_io::write_toml_document(&config_path, &doc)?;
216 Ok(())
217}
218
219pub fn clear_profile_in_config() -> Result<(), String> {
224 let config_path = crate::core::config::Config::path()
225 .ok_or_else(|| "Cannot determine config dir".to_string())?;
226 if !config_path.exists() {
227 return Ok(());
228 }
229
230 let mut doc = crate::config_io::load_toml_document(&config_path);
231 if doc.remove("tool_profile").is_none() {
232 return Ok(());
233 }
234 crate::config_io::write_toml_document(&config_path, &doc)?;
235 Ok(())
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn parse_known_profiles() {
244 assert_eq!(ToolProfile::parse("minimal"), Some(ToolProfile::Minimal));
245 assert_eq!(ToolProfile::parse("min"), Some(ToolProfile::Minimal));
246 assert_eq!(ToolProfile::parse("standard"), Some(ToolProfile::Standard));
247 assert_eq!(ToolProfile::parse("std"), Some(ToolProfile::Standard));
248 assert_eq!(ToolProfile::parse("default"), Some(ToolProfile::Standard));
249 assert_eq!(ToolProfile::parse("power"), Some(ToolProfile::Power));
250 assert_eq!(ToolProfile::parse("full"), Some(ToolProfile::Power));
251 assert_eq!(ToolProfile::parse("all"), Some(ToolProfile::Power));
252 }
253
254 #[test]
255 fn parse_case_insensitive() {
256 assert_eq!(ToolProfile::parse("MINIMAL"), Some(ToolProfile::Minimal));
257 assert_eq!(ToolProfile::parse("Standard"), Some(ToolProfile::Standard));
258 assert_eq!(ToolProfile::parse("POWER"), Some(ToolProfile::Power));
259 }
260
261 #[test]
262 fn parse_unknown_returns_none() {
263 assert_eq!(ToolProfile::parse("unknown"), None);
264 assert_eq!(ToolProfile::parse(""), None);
265 }
266
267 #[test]
268 fn minimal_profile_schema_budget() {
269 const MINIMAL_SCHEMA_BUDGET_TOKENS: usize = 2800;
273 let defs = crate::server::registry::build_registry().tool_defs();
274 let total: usize = defs
275 .iter()
276 .filter(|t| MINIMAL_TOOLS.contains(&t.name.as_ref()))
277 .map(crate::core::context_overhead::tool_tokens)
278 .sum();
279 assert!(total > 0, "minimal tools must exist in the registry");
280 assert!(
281 total <= MINIMAL_SCHEMA_BUDGET_TOKENS,
282 "minimal-profile tool schemas = {total} tok, budget {MINIMAL_SCHEMA_BUDGET_TOKENS}"
283 );
284 }
285
286 #[test]
287 fn minimal_is_subset_of_standard() {
288 for tool in MINIMAL_TOOLS {
289 assert!(
290 STANDARD_TOOLS.contains(tool),
291 "minimal tool {tool} missing from standard"
292 );
293 }
294 }
295
296 #[test]
297 fn power_enables_everything() {
298 let profile = ToolProfile::Power;
299 assert!(profile.is_tool_enabled("ctx_read"));
300 assert!(profile.is_tool_enabled("ctx_anything"));
301 assert!(profile.is_tool_enabled("nonexistent_tool"));
302 }
303
304 #[test]
305 fn minimal_filters_correctly() {
306 let profile = ToolProfile::Minimal;
307 assert!(profile.is_tool_enabled("ctx_read"));
308 assert!(profile.is_tool_enabled("ctx_shell"));
309 assert!(profile.is_tool_enabled("ctx_search"));
310 assert!(profile.is_tool_enabled("ctx_glob"));
311 assert!(profile.is_tool_enabled("ctx_tree"));
312 assert!(!profile.is_tool_enabled("ctx_symbol"));
315 assert!(!profile.is_tool_enabled("ctx_semantic_search"));
316 assert!(!profile.is_tool_enabled("ctx_callgraph"));
317 assert!(!profile.is_tool_enabled("ctx_benchmark"));
318 }
319
320 #[test]
321 fn standard_filters_correctly() {
322 let profile = ToolProfile::Standard;
323 assert!(profile.is_tool_enabled("ctx_read"));
324 assert!(profile.is_tool_enabled("ctx_compose"));
325 assert!(profile.is_tool_enabled("ctx_explore"));
326 assert!(profile.is_tool_enabled("ctx_glob"));
327 assert!(profile.is_tool_enabled("ctx_callgraph"));
328 assert!(profile.is_tool_enabled("ctx_graph"));
329 assert!(profile.is_tool_enabled("ctx_delta"));
330 assert!(profile.is_tool_enabled("ctx_expand"));
331 assert!(profile.is_tool_enabled("ctx_execute"));
332 assert!(profile.is_tool_enabled("ctx_overview"));
333 assert!(!profile.is_tool_enabled("ctx_symbol"));
336 assert!(!profile.is_tool_enabled("ctx_semantic_search"));
337 assert!(!profile.is_tool_enabled("ctx_multi_read"));
338 assert!(profile.is_tool_enabled("ctx_url_read"));
339 assert!(!profile.is_tool_enabled("ctx_benchmark"));
340 assert!(!profile.is_tool_enabled("ctx_analyze"));
341 assert!(!profile.is_tool_enabled("ctx_refactor"));
342 assert!(!profile.is_tool_enabled("ctx_edit"));
343 }
344
345 #[test]
346 fn custom_profile_uses_provided_list() {
347 let profile = ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()]);
348 assert!(profile.is_tool_enabled("ctx_read"));
349 assert!(profile.is_tool_enabled("ctx_shell"));
350 assert!(!profile.is_tool_enabled("ctx_search"));
351 }
352
353 #[test]
354 fn profile_display_counts_match_tool_arrays() {
355 let profiles = list_profiles();
359 assert_eq!(
360 profiles[0].tool_count.parse::<usize>().unwrap(),
361 MINIMAL_TOOLS.len(),
362 "minimal count must match MINIMAL_TOOLS length",
363 );
364 assert_eq!(
365 profiles[1].tool_count.parse::<usize>().unwrap(),
366 STANDARD_TOOLS.len(),
367 "standard count must match STANDARD_TOOLS length",
368 );
369 assert_eq!(profiles[2].tool_count, "all");
370 }
371
372 #[test]
373 fn custom_empty_enables_nothing() {
374 let profile = ToolProfile::Custom(vec![]);
375 assert!(!profile.is_tool_enabled("ctx_read"));
376 }
377
378 #[test]
379 fn display_matches_as_str() {
380 assert_eq!(format!("{}", ToolProfile::Minimal), "minimal");
381 assert_eq!(format!("{}", ToolProfile::Standard), "standard");
382 assert_eq!(format!("{}", ToolProfile::Power), "power");
383 assert_eq!(
384 format!("{}", ToolProfile::Custom(vec!["ctx_read".into()])),
385 "custom"
386 );
387 }
388
389 #[test]
390 fn tool_count_matches_list_length() {
391 assert_eq!(ToolProfile::Minimal.tool_count(), MINIMAL_TOOLS.len());
392 assert_eq!(ToolProfile::Standard.tool_count(), STANDARD_TOOLS.len());
393 assert_eq!(ToolProfile::Power.tool_count(), 0);
394 }
395
396 #[test]
397 fn from_config_defaults_to_power_for_backward_compat() {
398 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
399 return;
400 }
401 let cfg = crate::core::config::Config {
402 tool_profile: None,
403 tools_enabled: vec![],
404 ..Default::default()
405 };
406 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Power);
407 }
408
409 #[test]
410 fn from_config_respects_tool_profile_field() {
411 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
412 return;
413 }
414 let cfg = crate::core::config::Config {
415 tool_profile: Some("minimal".to_string()),
416 tools_enabled: vec![],
417 ..Default::default()
418 };
419 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Minimal);
420 }
421
422 #[test]
423 fn from_config_tools_enabled_creates_custom() {
424 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
425 return;
426 }
427 let cfg = crate::core::config::Config {
428 tool_profile: None,
429 tools_enabled: vec!["ctx_read".to_string(), "ctx_shell".to_string()],
430 ..Default::default()
431 };
432 let profile = ToolProfile::from_config(&cfg);
433 assert_eq!(
434 profile,
435 ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()])
436 );
437 }
438
439 #[test]
440 fn tool_profile_takes_precedence_over_tools_enabled() {
441 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
442 return;
443 }
444 let cfg = crate::core::config::Config {
445 tool_profile: Some("standard".to_string()),
446 tools_enabled: vec!["ctx_read".to_string()],
447 ..Default::default()
448 };
449 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Standard);
450 }
451
452 #[test]
453 fn all_profile_names_are_parseable() {
454 for name in PROFILE_NAMES {
455 assert!(
456 ToolProfile::parse(name).is_some(),
457 "profile name '{name}' should be parseable"
458 );
459 }
460 }
461
462 #[test]
463 fn list_profiles_returns_three_entries() {
464 let profiles = list_profiles();
465 assert_eq!(profiles.len(), 3);
466 }
467
468 #[test]
469 fn standard_includes_all_core_tools() {
470 let profile = ToolProfile::Standard;
471 assert!(
472 profile.is_tool_enabled("ctx_graph"),
473 "ctx_graph must be in standard"
474 );
475 assert!(
476 profile.is_tool_enabled("ctx_delta"),
477 "ctx_delta must be in standard"
478 );
479 assert!(
480 profile.is_tool_enabled("ctx_expand"),
481 "ctx_expand must be in standard"
482 );
483 assert!(
484 profile.is_tool_enabled("ctx_execute"),
485 "ctx_execute must be in standard (sandboxed code execution)"
486 );
487 assert!(
489 !profile.is_tool_enabled("ctx_edit"),
490 "ctx_edit must NOT be in standard (native Edit preferred)"
491 );
492 }
493
494 #[test]
495 fn standard_includes_url_read() {
496 let profile = ToolProfile::Standard;
497 assert!(
498 profile.is_tool_enabled("ctx_url_read"),
499 "ctx_url_read must be in standard (web/research context)"
500 );
501 }
502
503 #[test]
504 fn clear_profile_removes_key_and_is_idempotent() {
505 let iso = crate::core::data_dir::isolated_data_dir();
506 set_profile_in_config("power").unwrap();
507 let config_path = iso.path().join("config.toml");
508 assert!(
509 std::fs::read_to_string(&config_path)
510 .unwrap()
511 .contains("tool_profile"),
512 "set_profile_in_config must write the key"
513 );
514
515 clear_profile_in_config().unwrap();
516 assert!(
517 !std::fs::read_to_string(&config_path)
518 .unwrap()
519 .contains("tool_profile"),
520 "clear_profile_in_config must remove the key (lean default, #575)"
521 );
522
523 clear_profile_in_config().unwrap();
525 }
526
527 #[test]
528 fn clear_profile_on_missing_config_is_ok() {
529 let _iso = crate::core::data_dir::isolated_data_dir();
530 clear_profile_in_config().unwrap();
531 }
532}