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 => "16 balanced tools (adds compose, explore, callgraph, patch, 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 #[must_use]
61 pub fn without_tool(&self, tool_name: &str) -> Self {
62 if !self.is_tool_enabled(tool_name) {
63 return self.clone();
64 }
65 match self {
66 Self::Power => Self::Custom(
67 crate::tool_defs::CORE_TOOL_NAMES
68 .iter()
69 .chain(STANDARD_TOOLS.iter())
70 .copied()
71 .filter(|t| *t != tool_name)
72 .collect::<std::collections::BTreeSet<_>>()
73 .into_iter()
74 .map(String::from)
75 .collect(),
76 ),
77 Self::Standard => Self::Custom(
78 STANDARD_TOOLS
79 .iter()
80 .filter(|t| **t != tool_name)
81 .map(|t| String::from(*t))
82 .collect(),
83 ),
84 Self::Minimal => Self::Custom(
85 MINIMAL_TOOLS
86 .iter()
87 .filter(|t| **t != tool_name)
88 .map(|t| String::from(*t))
89 .collect(),
90 ),
91 Self::Custom(list) => Self::Custom(
92 list.iter()
93 .filter(|t| t.as_str() != tool_name)
94 .cloned()
95 .collect(),
96 ),
97 }
98 }
99
100 pub fn is_tool_enabled(&self, tool_name: &str) -> bool {
101 match self {
102 Self::Power => true,
103 Self::Minimal => MINIMAL_TOOLS.contains(&tool_name),
104 Self::Standard => STANDARD_TOOLS.contains(&tool_name),
105 Self::Custom(list) => list.iter().any(|t| t == tool_name),
106 }
107 }
108
109 pub fn tool_count(&self) -> usize {
110 match self {
111 Self::Minimal => MINIMAL_TOOLS.len(),
112 Self::Standard => STANDARD_TOOLS.len(),
113 Self::Power => 0, Self::Custom(list) => list.len(),
115 }
116 }
117
118 pub fn tool_names(&self) -> Vec<&str> {
119 match self {
120 Self::Minimal => MINIMAL_TOOLS.to_vec(),
121 Self::Standard => STANDARD_TOOLS.to_vec(),
122 Self::Power | Self::Custom(_) => vec![],
123 }
124 }
125
126 pub fn from_config(cfg: &super::config::Config) -> Self {
132 if let Ok(val) = std::env::var("LEAN_CTX_TOOL_PROFILE") {
133 let trimmed = val.trim();
134 if let Some(profile) = Self::parse(trimmed) {
135 return profile;
136 }
137 if !trimmed.is_empty() && !is_unpinned_alias(trimmed) {
139 tracing::warn!("Unknown LEAN_CTX_TOOL_PROFILE value '{trimmed}', using config");
140 }
141 }
142
143 if let Some(ref profile_name) = cfg.tool_profile {
144 if let Some(profile) = Self::parse(profile_name) {
145 return profile;
146 }
147 if !is_unpinned_alias(profile_name) {
153 tracing::warn!("Unknown tool_profile '{profile_name}' in config, using default");
154 }
155 }
156
157 if !cfg.tools_enabled.is_empty() {
158 return Self::Custom(cfg.tools_enabled.clone());
159 }
160
161 Self::Power
162 }
163}
164
165impl fmt::Display for ToolProfile {
166 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167 write!(f, "{}", self.as_str())
168 }
169}
170
171pub fn is_unpinned_alias(name: &str) -> bool {
179 matches!(
180 name.trim().to_ascii_lowercase().as_str(),
181 "" | "lean" | "lazy" | "reset"
182 )
183}
184
185const MINIMAL_TOOLS: &[&str] = &[
191 "ctx_read",
192 "ctx_shell",
193 "ctx_search",
194 "ctx_glob",
195 "ctx_tree",
196];
197
198const STANDARD_TOOLS: &[&str] = &[
208 "ctx_read",
209 "ctx_shell",
210 "ctx_search",
211 "ctx_glob",
212 "ctx_tree",
213 "ctx_compose",
214 "ctx_explore",
215 "ctx_knowledge",
216 "ctx_callgraph",
217 "ctx_graph",
218 "ctx_delta",
219 "ctx_execute",
220 "ctx_expand",
221 "ctx_overview",
222 "ctx_url_read",
223 "ctx_patch",
224];
225
226pub const PROFILE_NAMES: &[&str] = &["minimal", "standard", "power"];
228
229pub struct ProfileInfo {
230 pub name: &'static str,
231 pub tool_count: &'static str,
232 pub description: &'static str,
233}
234
235pub fn list_profiles() -> Vec<ProfileInfo> {
236 vec![
237 ProfileInfo {
238 name: "minimal",
239 tool_count: "5",
240 description: "Surgical core — each tool irreplaceable (recommended)",
241 },
242 ProfileInfo {
243 name: "standard",
244 tool_count: "16",
245 description: "Balanced set — adds compose, explore, callgraph, patch, execute, more",
246 },
247 ProfileInfo {
248 name: "power",
249 tool_count: "all",
250 description: "Every tool exposed (backward compatible)",
251 },
252 ]
253}
254
255pub fn set_profile_in_config(profile_name: &str) -> Result<(), String> {
258 let config_path = crate::core::config::Config::path()
262 .ok_or_else(|| "Cannot determine config dir".to_string())?;
263
264 let mut doc = crate::config_io::load_toml_document(&config_path);
265 doc["tool_profile"] = toml_edit::value(profile_name);
266 crate::config_io::write_toml_document(&config_path, &doc)?;
267 Ok(())
268}
269
270pub fn clear_profile_in_config() -> Result<(), String> {
275 let config_path = crate::core::config::Config::path()
276 .ok_or_else(|| "Cannot determine config dir".to_string())?;
277 if !config_path.exists() {
278 return Ok(());
279 }
280
281 let mut doc = crate::config_io::load_toml_document(&config_path);
282 if doc.remove("tool_profile").is_none() {
283 return Ok(());
284 }
285 crate::config_io::write_toml_document(&config_path, &doc)?;
286 Ok(())
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn parse_known_profiles() {
295 assert_eq!(ToolProfile::parse("minimal"), Some(ToolProfile::Minimal));
296 assert_eq!(ToolProfile::parse("min"), Some(ToolProfile::Minimal));
297 assert_eq!(ToolProfile::parse("standard"), Some(ToolProfile::Standard));
298 assert_eq!(ToolProfile::parse("std"), Some(ToolProfile::Standard));
299 assert_eq!(ToolProfile::parse("default"), Some(ToolProfile::Standard));
300 assert_eq!(ToolProfile::parse("power"), Some(ToolProfile::Power));
301 assert_eq!(ToolProfile::parse("full"), Some(ToolProfile::Power));
302 assert_eq!(ToolProfile::parse("all"), Some(ToolProfile::Power));
303 }
304
305 #[test]
306 fn parse_case_insensitive() {
307 assert_eq!(ToolProfile::parse("MINIMAL"), Some(ToolProfile::Minimal));
308 assert_eq!(ToolProfile::parse("Standard"), Some(ToolProfile::Standard));
309 assert_eq!(ToolProfile::parse("POWER"), Some(ToolProfile::Power));
310 }
311
312 #[test]
313 fn parse_unknown_returns_none() {
314 assert_eq!(ToolProfile::parse("unknown"), None);
315 assert_eq!(ToolProfile::parse(""), None);
316 }
317
318 #[test]
319 fn minimal_profile_schema_budget() {
320 const MINIMAL_SCHEMA_BUDGET_TOKENS: usize = 2800;
324 let defs = crate::server::registry::build_registry().tool_defs();
325 let total: usize = defs
326 .iter()
327 .filter(|t| MINIMAL_TOOLS.contains(&t.name.as_ref()))
328 .map(crate::core::context_overhead::tool_tokens)
329 .sum();
330 assert!(total > 0, "minimal tools must exist in the registry");
331 assert!(
332 total <= MINIMAL_SCHEMA_BUDGET_TOKENS,
333 "minimal-profile tool schemas = {total} tok, budget {MINIMAL_SCHEMA_BUDGET_TOKENS}"
334 );
335 }
336
337 #[test]
338 fn minimal_is_subset_of_standard() {
339 for tool in MINIMAL_TOOLS {
340 assert!(
341 STANDARD_TOOLS.contains(tool),
342 "minimal tool {tool} missing from standard"
343 );
344 }
345 }
346
347 #[test]
348 fn power_enables_everything() {
349 let profile = ToolProfile::Power;
350 assert!(profile.is_tool_enabled("ctx_read"));
351 assert!(profile.is_tool_enabled("ctx_anything"));
352 assert!(profile.is_tool_enabled("nonexistent_tool"));
353 }
354
355 #[test]
356 fn minimal_filters_correctly() {
357 let profile = ToolProfile::Minimal;
358 assert!(profile.is_tool_enabled("ctx_read"));
359 assert!(profile.is_tool_enabled("ctx_shell"));
360 assert!(profile.is_tool_enabled("ctx_search"));
361 assert!(profile.is_tool_enabled("ctx_glob"));
362 assert!(profile.is_tool_enabled("ctx_tree"));
363 assert!(!profile.is_tool_enabled("ctx_symbol"));
366 assert!(!profile.is_tool_enabled("ctx_semantic_search"));
367 assert!(!profile.is_tool_enabled("ctx_callgraph"));
368 assert!(!profile.is_tool_enabled("ctx_benchmark"));
369 }
370
371 #[test]
372 fn standard_filters_correctly() {
373 let profile = ToolProfile::Standard;
374 assert!(profile.is_tool_enabled("ctx_read"));
375 assert!(profile.is_tool_enabled("ctx_compose"));
376 assert!(profile.is_tool_enabled("ctx_explore"));
377 assert!(profile.is_tool_enabled("ctx_glob"));
378 assert!(profile.is_tool_enabled("ctx_callgraph"));
379 assert!(profile.is_tool_enabled("ctx_graph"));
380 assert!(profile.is_tool_enabled("ctx_delta"));
381 assert!(profile.is_tool_enabled("ctx_expand"));
382 assert!(profile.is_tool_enabled("ctx_execute"));
383 assert!(profile.is_tool_enabled("ctx_overview"));
384 assert!(!profile.is_tool_enabled("ctx_symbol"));
387 assert!(!profile.is_tool_enabled("ctx_semantic_search"));
388 assert!(!profile.is_tool_enabled("ctx_multi_read"));
389 assert!(profile.is_tool_enabled("ctx_url_read"));
390 assert!(profile.is_tool_enabled("ctx_patch"));
392 assert!(!profile.is_tool_enabled("ctx_benchmark"));
393 assert!(!profile.is_tool_enabled("ctx_analyze"));
394 assert!(!profile.is_tool_enabled("ctx_refactor"));
395 assert!(!profile.is_tool_enabled("ctx_edit"));
396 }
397
398 #[test]
399 fn custom_profile_uses_provided_list() {
400 let profile = ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()]);
401 assert!(profile.is_tool_enabled("ctx_read"));
402 assert!(profile.is_tool_enabled("ctx_shell"));
403 assert!(!profile.is_tool_enabled("ctx_search"));
404 }
405
406 #[test]
407 fn profile_display_counts_match_tool_arrays() {
408 let profiles = list_profiles();
412 assert_eq!(
413 profiles[0].tool_count.parse::<usize>().unwrap(),
414 MINIMAL_TOOLS.len(),
415 "minimal count must match MINIMAL_TOOLS length",
416 );
417 assert_eq!(
418 profiles[1].tool_count.parse::<usize>().unwrap(),
419 STANDARD_TOOLS.len(),
420 "standard count must match STANDARD_TOOLS length",
421 );
422 assert_eq!(profiles[2].tool_count, "all");
423 }
424
425 #[test]
426 fn custom_empty_enables_nothing() {
427 let profile = ToolProfile::Custom(vec![]);
428 assert!(!profile.is_tool_enabled("ctx_read"));
429 }
430
431 #[test]
432 fn display_matches_as_str() {
433 assert_eq!(format!("{}", ToolProfile::Minimal), "minimal");
434 assert_eq!(format!("{}", ToolProfile::Standard), "standard");
435 assert_eq!(format!("{}", ToolProfile::Power), "power");
436 assert_eq!(
437 format!("{}", ToolProfile::Custom(vec!["ctx_read".into()])),
438 "custom"
439 );
440 }
441
442 #[test]
443 fn tool_count_matches_list_length() {
444 assert_eq!(ToolProfile::Minimal.tool_count(), MINIMAL_TOOLS.len());
445 assert_eq!(ToolProfile::Standard.tool_count(), STANDARD_TOOLS.len());
446 assert_eq!(ToolProfile::Power.tool_count(), 0);
447 }
448
449 #[test]
450 fn from_config_defaults_to_power_for_backward_compat() {
451 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
452 return;
453 }
454 let cfg = crate::core::config::Config {
455 tool_profile: None,
456 tools_enabled: vec![],
457 ..Default::default()
458 };
459 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Power);
460 }
461
462 #[test]
463 fn from_config_respects_tool_profile_field() {
464 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
465 return;
466 }
467 let cfg = crate::core::config::Config {
468 tool_profile: Some("minimal".to_string()),
469 tools_enabled: vec![],
470 ..Default::default()
471 };
472 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Minimal);
473 }
474
475 #[test]
476 fn from_config_tools_enabled_creates_custom() {
477 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
478 return;
479 }
480 let cfg = crate::core::config::Config {
481 tool_profile: None,
482 tools_enabled: vec!["ctx_read".to_string(), "ctx_shell".to_string()],
483 ..Default::default()
484 };
485 let profile = ToolProfile::from_config(&cfg);
486 assert_eq!(
487 profile,
488 ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()])
489 );
490 }
491
492 #[test]
493 fn tool_profile_takes_precedence_over_tools_enabled() {
494 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
495 return;
496 }
497 let cfg = crate::core::config::Config {
498 tool_profile: Some("standard".to_string()),
499 tools_enabled: vec!["ctx_read".to_string()],
500 ..Default::default()
501 };
502 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Standard);
503 }
504
505 #[test]
506 fn empty_tool_profile_is_unpinned_so_tools_enabled_applies() {
507 assert!(is_unpinned_alias(""));
512 assert!(is_unpinned_alias(" "));
513
514 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
515 return;
516 }
517 let cfg = crate::core::config::Config {
518 tool_profile: Some(String::new()),
519 tools_enabled: vec!["ctx_read".to_string()],
520 ..Default::default()
521 };
522 assert_eq!(
523 ToolProfile::from_config(&cfg),
524 ToolProfile::Custom(vec!["ctx_read".to_string()]),
525 "empty tool_profile must unpin so tools_enabled takes effect"
526 );
527 }
528
529 #[test]
530 fn all_profile_names_are_parseable() {
531 for name in PROFILE_NAMES {
532 assert!(
533 ToolProfile::parse(name).is_some(),
534 "profile name '{name}' should be parseable"
535 );
536 }
537 }
538
539 #[test]
540 fn list_profiles_returns_three_entries() {
541 let profiles = list_profiles();
542 assert_eq!(profiles.len(), 3);
543 }
544
545 #[test]
546 fn standard_includes_all_core_tools() {
547 let profile = ToolProfile::Standard;
548 assert!(
549 profile.is_tool_enabled("ctx_graph"),
550 "ctx_graph must be in standard"
551 );
552 assert!(
553 profile.is_tool_enabled("ctx_delta"),
554 "ctx_delta must be in standard"
555 );
556 assert!(
557 profile.is_tool_enabled("ctx_expand"),
558 "ctx_expand must be in standard"
559 );
560 assert!(
561 profile.is_tool_enabled("ctx_execute"),
562 "ctx_execute must be in standard (sandboxed code execution)"
563 );
564 assert!(
567 !profile.is_tool_enabled("ctx_edit"),
568 "ctx_edit must NOT be in standard (ctx_patch is the standard editor)"
569 );
570 assert!(
571 profile.is_tool_enabled("ctx_patch"),
572 "ctx_patch must be in standard (#1008 anchored editing)"
573 );
574 }
575
576 #[test]
577 fn standard_includes_url_read() {
578 let profile = ToolProfile::Standard;
579 assert!(
580 profile.is_tool_enabled("ctx_url_read"),
581 "ctx_url_read must be in standard (web/research context)"
582 );
583 }
584
585 #[test]
586 fn clear_profile_removes_key_and_is_idempotent() {
587 let iso = crate::core::data_dir::isolated_data_dir();
588 set_profile_in_config("power").unwrap();
589 let config_path = iso.path().join("config.toml");
590 assert!(
591 std::fs::read_to_string(&config_path)
592 .unwrap()
593 .contains("tool_profile"),
594 "set_profile_in_config must write the key"
595 );
596
597 clear_profile_in_config().unwrap();
598 assert!(
599 !std::fs::read_to_string(&config_path)
600 .unwrap()
601 .contains("tool_profile"),
602 "clear_profile_in_config must remove the key (lean default, #575)"
603 );
604
605 clear_profile_in_config().unwrap();
607 }
608
609 #[test]
610 fn clear_profile_on_missing_config_is_ok() {
611 let _iso = crate::core::data_dir::isolated_data_dir();
612 clear_profile_in_config().unwrap();
613 }
614
615 #[test]
616 fn without_tool_removes_from_power() {
617 let filtered = ToolProfile::Power.without_tool("ctx_patch");
618 assert!(!filtered.is_tool_enabled("ctx_patch"));
619 assert!(filtered.is_tool_enabled("ctx_read"));
620 assert!(filtered.is_tool_enabled("ctx_search"));
621 }
622
623 #[test]
624 fn without_tool_removes_from_standard() {
625 let filtered = ToolProfile::Standard.without_tool("ctx_patch");
626 assert!(!filtered.is_tool_enabled("ctx_patch"));
627 assert!(filtered.is_tool_enabled("ctx_read"));
628 assert!(filtered.is_tool_enabled("ctx_compose"));
629 }
630
631 #[test]
632 fn without_tool_noop_for_already_missing() {
633 let filtered = ToolProfile::Minimal.without_tool("ctx_patch");
634 assert!(!filtered.is_tool_enabled("ctx_patch"));
635 assert!(filtered.is_tool_enabled("ctx_read"));
636 }
637}