1use std::fmt;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum ToolProfile {
17 Minimal,
18 Standard,
19 Power,
20 Auto,
21 Custom(Vec<String>),
22}
23
24impl ToolProfile {
25 pub fn parse(s: &str) -> Option<Self> {
26 match s.to_lowercase().as_str() {
27 "minimal" | "min" => Some(Self::Minimal),
28 "standard" | "std" | "default" => Some(Self::Standard),
29 "power" | "full" | "all" => Some(Self::Power),
30 "auto" => Some(Self::Auto),
31 _ => None,
32 }
33 }
34
35 pub fn as_str(&self) -> &str {
36 match self {
37 Self::Minimal => "minimal",
38 Self::Standard => "standard",
39 Self::Power => "power",
40 Self::Auto => "auto",
41 Self::Custom(_) => "custom",
42 }
43 }
44
45 pub fn description(&self) -> &str {
46 match self {
47 Self::Minimal => "5 surgical tools — each irreplaceable (recommended)",
48 Self::Standard => "16 balanced tools (adds compose, explore, callgraph, patch, more)",
49 Self::Power => "All tools exposed",
50 Self::Auto => "Starts minimal, escalates based on session complexity",
51 Self::Custom(v) => {
52 if v.is_empty() {
53 "Custom tool list (empty)"
54 } else {
55 "Custom tool list"
56 }
57 }
58 }
59 }
60
61 #[must_use]
65 pub fn without_tool(&self, tool_name: &str) -> Self {
66 if !self.is_tool_enabled(tool_name) {
67 return self.clone();
68 }
69 match self {
70 Self::Power => Self::Custom(
71 crate::tool_defs::CORE_TOOL_NAMES
72 .iter()
73 .chain(STANDARD_TOOLS.iter())
74 .copied()
75 .filter(|t| *t != tool_name)
76 .collect::<std::collections::BTreeSet<_>>()
77 .into_iter()
78 .map(String::from)
79 .collect(),
80 ),
81 Self::Standard => Self::Custom(
82 STANDARD_TOOLS
83 .iter()
84 .filter(|t| **t != tool_name)
85 .map(|t| String::from(*t))
86 .collect(),
87 ),
88 Self::Minimal => Self::Custom(
89 MINIMAL_TOOLS
90 .iter()
91 .filter(|t| **t != tool_name)
92 .map(|t| String::from(*t))
93 .collect(),
94 ),
95 Self::Auto => Self::Auto,
96 Self::Custom(list) => Self::Custom(
97 list.iter()
98 .filter(|t| t.as_str() != tool_name)
99 .cloned()
100 .collect(),
101 ),
102 }
103 }
104
105 pub fn is_tool_enabled(&self, tool_name: &str) -> bool {
106 match self {
107 Self::Power => true,
108 Self::Minimal | Self::Auto => MINIMAL_TOOLS.contains(&tool_name),
109 Self::Standard => STANDARD_TOOLS.contains(&tool_name),
110 Self::Custom(list) => list.iter().any(|t| t == tool_name),
111 }
112 }
113
114 pub fn tool_count(&self) -> usize {
115 match self {
116 Self::Minimal | Self::Auto => MINIMAL_TOOLS.len(),
117 Self::Standard => STANDARD_TOOLS.len(),
118 Self::Power => 0, Self::Custom(list) => list.len(),
120 }
121 }
122
123 pub fn tool_names(&self) -> Vec<&str> {
124 match self {
125 Self::Minimal | Self::Auto => MINIMAL_TOOLS.to_vec(),
126 Self::Standard => STANDARD_TOOLS.to_vec(),
127 Self::Power | Self::Custom(_) => vec![],
128 }
129 }
130
131 pub fn resolve_auto(
137 turn_count: u64,
138 has_used_ctx_tools: bool,
139 system_prompt_tokens: usize,
140 ) -> ToolProfile {
141 if has_used_ctx_tools {
142 return ToolProfile::Standard;
143 }
144 if turn_count >= 10 {
145 return ToolProfile::Power;
146 }
147 if turn_count < 2 && system_prompt_tokens < 2000 {
148 return ToolProfile::Minimal;
149 }
150 ToolProfile::Standard
151 }
152
153 pub fn from_config(cfg: &super::config::Config) -> Self {
159 if let Ok(val) = std::env::var("LEAN_CTX_TOOL_PROFILE") {
160 let trimmed = val.trim();
161 if let Some(profile) = Self::parse(trimmed) {
162 return profile;
163 }
164 if !trimmed.is_empty() && !is_unpinned_alias(trimmed) {
166 tracing::warn!("Unknown LEAN_CTX_TOOL_PROFILE value '{trimmed}', using config");
167 }
168 }
169
170 if let Some(ref profile_name) = cfg.tool_profile {
171 if let Some(profile) = Self::parse(profile_name) {
172 return profile;
173 }
174 if !is_unpinned_alias(profile_name) {
180 tracing::warn!("Unknown tool_profile '{profile_name}' in config, using default");
181 }
182 }
183
184 if !cfg.tools_enabled.is_empty() {
185 return Self::Custom(cfg.tools_enabled.clone());
186 }
187
188 Self::Power
189 }
190}
191
192impl fmt::Display for ToolProfile {
193 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194 write!(f, "{}", self.as_str())
195 }
196}
197
198pub fn is_unpinned_alias(name: &str) -> bool {
206 matches!(
207 name.trim().to_ascii_lowercase().as_str(),
208 "" | "lean" | "lazy" | "reset"
209 )
210}
211
212const MINIMAL_TOOLS: &[&str] = &[
218 "ctx_read",
219 "ctx_shell",
220 "ctx_search",
221 "ctx_glob",
222 "ctx_tree",
223];
224
225const STANDARD_TOOLS: &[&str] = &[
235 "ctx_read",
236 "ctx_shell",
237 "ctx_search",
238 "ctx_glob",
239 "ctx_tree",
240 "ctx_compose",
241 "ctx_explore",
242 "ctx_knowledge",
243 "ctx_session",
244 "ctx_callgraph",
245 "ctx_graph",
246 "ctx_delta",
247 "ctx_execute",
248 "ctx_expand",
249 "ctx_overview",
250 "ctx_url_read",
251 "ctx_patch",
252];
253
254pub const PROFILE_NAMES: &[&str] = &["minimal", "standard", "power", "auto"];
256
257pub struct ProfileInfo {
258 pub name: &'static str,
259 pub tool_count: &'static str,
260 pub description: &'static str,
261}
262
263pub fn list_profiles() -> Vec<ProfileInfo> {
264 vec![
265 ProfileInfo {
266 name: "minimal",
267 tool_count: "5",
268 description: "Surgical core — each tool irreplaceable (recommended)",
269 },
270 ProfileInfo {
271 name: "standard",
272 tool_count: "17",
273 description: "Balanced set — adds compose, knowledge, session, callgraph, patch, more",
274 },
275 ProfileInfo {
276 name: "power",
277 tool_count: "all",
278 description: "Every tool exposed (backward compatible)",
279 },
280 ProfileInfo {
281 name: "auto",
282 tool_count: "5→all",
283 description: "Starts minimal, escalates based on session complexity",
284 },
285 ]
286}
287
288pub fn set_profile_in_config(profile_name: &str) -> Result<(), String> {
291 let config_path = crate::core::config::Config::path()
295 .ok_or_else(|| "Cannot determine config dir".to_string())?;
296
297 let mut doc = crate::config_io::load_toml_document(&config_path);
298 doc["tool_profile"] = toml_edit::value(profile_name);
299 crate::config_io::write_toml_document(&config_path, &doc)?;
300 Ok(())
301}
302
303pub fn clear_profile_in_config() -> Result<(), String> {
308 let config_path = crate::core::config::Config::path()
309 .ok_or_else(|| "Cannot determine config dir".to_string())?;
310 if !config_path.exists() {
311 return Ok(());
312 }
313
314 let mut doc = crate::config_io::load_toml_document(&config_path);
315 if doc.remove("tool_profile").is_none() {
316 return Ok(());
317 }
318 crate::config_io::write_toml_document(&config_path, &doc)?;
319 Ok(())
320}
321
322#[cfg(test)]
323mod tests {
324 use super::{
325 MINIMAL_TOOLS, PROFILE_NAMES, STANDARD_TOOLS, ToolProfile, clear_profile_in_config,
326 is_unpinned_alias, list_profiles, set_profile_in_config,
327 };
328
329 #[test]
330 fn parse_known_profiles() {
331 assert_eq!(ToolProfile::parse("minimal"), Some(ToolProfile::Minimal));
332 assert_eq!(ToolProfile::parse("min"), Some(ToolProfile::Minimal));
333 assert_eq!(ToolProfile::parse("standard"), Some(ToolProfile::Standard));
334 assert_eq!(ToolProfile::parse("std"), Some(ToolProfile::Standard));
335 assert_eq!(ToolProfile::parse("default"), Some(ToolProfile::Standard));
336 assert_eq!(ToolProfile::parse("power"), Some(ToolProfile::Power));
337 assert_eq!(ToolProfile::parse("full"), Some(ToolProfile::Power));
338 assert_eq!(ToolProfile::parse("all"), Some(ToolProfile::Power));
339 assert_eq!(ToolProfile::parse("auto"), Some(ToolProfile::Auto));
340 }
341
342 #[test]
343 fn parse_case_insensitive() {
344 assert_eq!(ToolProfile::parse("MINIMAL"), Some(ToolProfile::Minimal));
345 assert_eq!(ToolProfile::parse("Standard"), Some(ToolProfile::Standard));
346 assert_eq!(ToolProfile::parse("POWER"), Some(ToolProfile::Power));
347 }
348
349 #[test]
350 fn parse_unknown_returns_none() {
351 assert_eq!(ToolProfile::parse("unknown"), None);
352 assert_eq!(ToolProfile::parse(""), None);
353 }
354
355 #[test]
356 fn minimal_profile_schema_budget() {
357 const MINIMAL_SCHEMA_BUDGET_TOKENS: usize = 2800;
361 let defs = crate::server::registry::build_registry().tool_defs();
362 let total: usize = defs
363 .iter()
364 .filter(|t| MINIMAL_TOOLS.contains(&t.name.as_ref()))
365 .map(crate::core::context_overhead::tool_tokens)
366 .sum();
367 assert!(total > 0, "minimal tools must exist in the registry");
368 assert!(
369 total <= MINIMAL_SCHEMA_BUDGET_TOKENS,
370 "minimal-profile tool schemas = {total} tok, budget {MINIMAL_SCHEMA_BUDGET_TOKENS}"
371 );
372 }
373
374 #[test]
375 fn minimal_is_subset_of_standard() {
376 for tool in MINIMAL_TOOLS {
377 assert!(
378 STANDARD_TOOLS.contains(tool),
379 "minimal tool {tool} missing from standard"
380 );
381 }
382 }
383
384 #[test]
385 fn power_enables_everything() {
386 let profile = ToolProfile::Power;
387 assert!(profile.is_tool_enabled("ctx_read"));
388 assert!(profile.is_tool_enabled("ctx_anything"));
389 assert!(profile.is_tool_enabled("nonexistent_tool"));
390 }
391
392 #[test]
393 fn minimal_filters_correctly() {
394 let profile = ToolProfile::Minimal;
395 assert!(profile.is_tool_enabled("ctx_read"));
396 assert!(profile.is_tool_enabled("ctx_shell"));
397 assert!(profile.is_tool_enabled("ctx_search"));
398 assert!(profile.is_tool_enabled("ctx_glob"));
399 assert!(profile.is_tool_enabled("ctx_tree"));
400 assert!(!profile.is_tool_enabled("ctx_symbol"));
403 assert!(!profile.is_tool_enabled("ctx_semantic_search"));
404 assert!(!profile.is_tool_enabled("ctx_callgraph"));
405 assert!(!profile.is_tool_enabled("ctx_benchmark"));
406 }
407
408 #[test]
409 fn standard_filters_correctly() {
410 let profile = ToolProfile::Standard;
411 assert!(profile.is_tool_enabled("ctx_read"));
412 assert!(profile.is_tool_enabled("ctx_compose"));
413 assert!(profile.is_tool_enabled("ctx_explore"));
414 assert!(profile.is_tool_enabled("ctx_glob"));
415 assert!(profile.is_tool_enabled("ctx_callgraph"));
416 assert!(profile.is_tool_enabled("ctx_graph"));
417 assert!(profile.is_tool_enabled("ctx_session"));
418 assert!(profile.is_tool_enabled("ctx_delta"));
419 assert!(profile.is_tool_enabled("ctx_expand"));
420 assert!(profile.is_tool_enabled("ctx_execute"));
421 assert!(profile.is_tool_enabled("ctx_overview"));
422 assert!(!profile.is_tool_enabled("ctx_symbol"));
425 assert!(!profile.is_tool_enabled("ctx_semantic_search"));
426 assert!(!profile.is_tool_enabled("ctx_multi_read"));
427 assert!(profile.is_tool_enabled("ctx_url_read"));
428 assert!(profile.is_tool_enabled("ctx_patch"));
430 assert!(!profile.is_tool_enabled("ctx_benchmark"));
431 assert!(!profile.is_tool_enabled("ctx_analyze"));
432 assert!(!profile.is_tool_enabled("ctx_refactor"));
433 assert!(!profile.is_tool_enabled("ctx_edit"));
434 }
435
436 #[test]
437 fn custom_profile_uses_provided_list() {
438 let profile = ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()]);
439 assert!(profile.is_tool_enabled("ctx_read"));
440 assert!(profile.is_tool_enabled("ctx_shell"));
441 assert!(!profile.is_tool_enabled("ctx_search"));
442 }
443
444 #[test]
445 fn profile_display_counts_match_tool_arrays() {
446 let profiles = list_profiles();
450 assert_eq!(
451 profiles[0].tool_count.parse::<usize>().unwrap(),
452 MINIMAL_TOOLS.len(),
453 "minimal count must match MINIMAL_TOOLS length",
454 );
455 assert_eq!(
456 profiles[1].tool_count.parse::<usize>().unwrap(),
457 STANDARD_TOOLS.len(),
458 "standard count must match STANDARD_TOOLS length",
459 );
460 assert_eq!(profiles[2].tool_count, "all");
461 }
462
463 #[test]
464 fn custom_empty_enables_nothing() {
465 let profile = ToolProfile::Custom(vec![]);
466 assert!(!profile.is_tool_enabled("ctx_read"));
467 }
468
469 #[test]
470 fn display_matches_as_str() {
471 assert_eq!(format!("{}", ToolProfile::Minimal), "minimal");
472 assert_eq!(format!("{}", ToolProfile::Standard), "standard");
473 assert_eq!(format!("{}", ToolProfile::Power), "power");
474 assert_eq!(
475 format!("{}", ToolProfile::Custom(vec!["ctx_read".into()])),
476 "custom"
477 );
478 }
479
480 #[test]
481 fn tool_count_matches_list_length() {
482 assert_eq!(ToolProfile::Minimal.tool_count(), MINIMAL_TOOLS.len());
483 assert_eq!(ToolProfile::Standard.tool_count(), STANDARD_TOOLS.len());
484 assert_eq!(ToolProfile::Power.tool_count(), 0);
485 assert_eq!(ToolProfile::Auto.tool_count(), MINIMAL_TOOLS.len());
486 }
487
488 #[test]
489 fn from_config_defaults_to_power_for_backward_compat() {
490 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
491 return;
492 }
493 let cfg = crate::core::config::Config {
494 tool_profile: None,
495 tools_enabled: vec![],
496 ..Default::default()
497 };
498 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Power);
499 }
500
501 #[test]
502 fn from_config_respects_tool_profile_field() {
503 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
504 return;
505 }
506 let cfg = crate::core::config::Config {
507 tool_profile: Some("minimal".to_string()),
508 tools_enabled: vec![],
509 ..Default::default()
510 };
511 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Minimal);
512 }
513
514 #[test]
515 fn from_config_tools_enabled_creates_custom() {
516 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
517 return;
518 }
519 let cfg = crate::core::config::Config {
520 tool_profile: None,
521 tools_enabled: vec!["ctx_read".to_string(), "ctx_shell".to_string()],
522 ..Default::default()
523 };
524 let profile = ToolProfile::from_config(&cfg);
525 assert_eq!(
526 profile,
527 ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()])
528 );
529 }
530
531 #[test]
532 fn tool_profile_takes_precedence_over_tools_enabled() {
533 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
534 return;
535 }
536 let cfg = crate::core::config::Config {
537 tool_profile: Some("standard".to_string()),
538 tools_enabled: vec!["ctx_read".to_string()],
539 ..Default::default()
540 };
541 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Standard);
542 }
543
544 #[test]
545 fn empty_tool_profile_is_unpinned_so_tools_enabled_applies() {
546 assert!(is_unpinned_alias(""));
551 assert!(is_unpinned_alias(" "));
552
553 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
554 return;
555 }
556 let cfg = crate::core::config::Config {
557 tool_profile: Some(String::new()),
558 tools_enabled: vec!["ctx_read".to_string()],
559 ..Default::default()
560 };
561 assert_eq!(
562 ToolProfile::from_config(&cfg),
563 ToolProfile::Custom(vec!["ctx_read".to_string()]),
564 "empty tool_profile must unpin so tools_enabled takes effect"
565 );
566 }
567
568 #[test]
569 fn all_profile_names_are_parseable() {
570 for name in PROFILE_NAMES {
571 assert!(
572 ToolProfile::parse(name).is_some(),
573 "profile name '{name}' should be parseable"
574 );
575 }
576 }
577
578 #[test]
579 fn list_profiles_returns_four_entries() {
580 let profiles = list_profiles();
581 assert_eq!(profiles.len(), 4);
582 }
583
584 #[test]
585 fn resolve_auto_short_session_returns_minimal() {
586 assert_eq!(
587 ToolProfile::resolve_auto(0, false, 500),
588 ToolProfile::Minimal
589 );
590 assert_eq!(
591 ToolProfile::resolve_auto(1, false, 1500),
592 ToolProfile::Minimal
593 );
594 }
595
596 #[test]
597 fn resolve_auto_ctx_tools_used_returns_standard() {
598 assert_eq!(
599 ToolProfile::resolve_auto(1, true, 500),
600 ToolProfile::Standard
601 );
602 assert_eq!(ToolProfile::resolve_auto(0, true, 0), ToolProfile::Standard);
603 }
604
605 #[test]
606 fn resolve_auto_long_session_returns_power() {
607 assert_eq!(
608 ToolProfile::resolve_auto(10, false, 500),
609 ToolProfile::Power
610 );
611 assert_eq!(
612 ToolProfile::resolve_auto(20, false, 3000),
613 ToolProfile::Power
614 );
615 }
616
617 #[test]
618 fn resolve_auto_medium_session_returns_standard() {
619 assert_eq!(
620 ToolProfile::resolve_auto(5, false, 3000),
621 ToolProfile::Standard
622 );
623 }
624
625 #[test]
626 fn auto_profile_display() {
627 assert_eq!(ToolProfile::Auto.as_str(), "auto");
628 assert_eq!(format!("{}", ToolProfile::Auto), "auto");
629 }
630
631 #[test]
632 fn standard_includes_all_core_tools() {
633 let profile = ToolProfile::Standard;
634 assert!(
635 profile.is_tool_enabled("ctx_graph"),
636 "ctx_graph must be in standard"
637 );
638 assert!(
639 profile.is_tool_enabled("ctx_delta"),
640 "ctx_delta must be in standard"
641 );
642 assert!(
643 profile.is_tool_enabled("ctx_expand"),
644 "ctx_expand must be in standard"
645 );
646 assert!(
647 profile.is_tool_enabled("ctx_execute"),
648 "ctx_execute must be in standard (sandboxed code execution)"
649 );
650 assert!(
653 !profile.is_tool_enabled("ctx_edit"),
654 "ctx_edit must NOT be in standard (ctx_patch is the standard editor)"
655 );
656 assert!(
657 profile.is_tool_enabled("ctx_patch"),
658 "ctx_patch must be in standard (#1008 anchored editing)"
659 );
660 }
661
662 #[test]
663 fn standard_includes_url_read() {
664 let profile = ToolProfile::Standard;
665 assert!(
666 profile.is_tool_enabled("ctx_url_read"),
667 "ctx_url_read must be in standard (web/research context)"
668 );
669 }
670
671 #[test]
672 fn clear_profile_removes_key_and_is_idempotent() {
673 let iso = crate::core::data_dir::isolated_data_dir();
674 set_profile_in_config("power").unwrap();
675 let config_path = iso.path().join("config.toml");
676 assert!(
677 std::fs::read_to_string(&config_path)
678 .unwrap()
679 .contains("tool_profile"),
680 "set_profile_in_config must write the key"
681 );
682
683 clear_profile_in_config().unwrap();
684 assert!(
685 !std::fs::read_to_string(&config_path)
686 .unwrap()
687 .contains("tool_profile"),
688 "clear_profile_in_config must remove the key (lean default, #575)"
689 );
690
691 clear_profile_in_config().unwrap();
693 }
694
695 #[test]
696 fn clear_profile_on_missing_config_is_ok() {
697 let _iso = crate::core::data_dir::isolated_data_dir();
698 clear_profile_in_config().unwrap();
699 }
700
701 #[test]
702 fn without_tool_removes_from_power() {
703 let filtered = ToolProfile::Power.without_tool("ctx_patch");
704 assert!(!filtered.is_tool_enabled("ctx_patch"));
705 assert!(filtered.is_tool_enabled("ctx_read"));
706 assert!(filtered.is_tool_enabled("ctx_search"));
707 }
708
709 #[test]
710 fn without_tool_removes_from_standard() {
711 let filtered = ToolProfile::Standard.without_tool("ctx_patch");
712 assert!(!filtered.is_tool_enabled("ctx_patch"));
713 assert!(filtered.is_tool_enabled("ctx_read"));
714 assert!(filtered.is_tool_enabled("ctx_compose"));
715 }
716
717 #[test]
718 fn without_tool_noop_for_already_missing() {
719 let filtered = ToolProfile::Minimal.without_tool("ctx_patch");
720 assert!(!filtered.is_tool_enabled("ctx_patch"));
721 assert!(filtered.is_tool_enabled("ctx_read"));
722 }
723}