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 => "15 balanced tools (adds compose, callgraph, graph, 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
212pub const 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_knowledge",
242 "ctx_session",
243 "ctx_callgraph",
244 "ctx_graph",
245 "ctx_delta",
246 "ctx_execute",
247 "ctx_expand",
248 "ctx_url_read",
249 "ctx_patch",
250];
251
252pub const PROFILE_NAMES: &[&str] = &["minimal", "standard", "power", "auto"];
254
255pub struct ProfileInfo {
256 pub name: &'static str,
257 pub tool_count: &'static str,
258 pub description: &'static str,
259}
260
261pub fn list_profiles() -> Vec<ProfileInfo> {
262 vec![
263 ProfileInfo {
264 name: "minimal",
265 tool_count: "5",
266 description: "Surgical core — each tool irreplaceable (recommended)",
267 },
268 ProfileInfo {
269 name: "standard",
270 tool_count: "15",
271 description: "Balanced set — adds compose, knowledge, session, callgraph, patch, more",
272 },
273 ProfileInfo {
274 name: "power",
275 tool_count: "all",
276 description: "Every tool exposed (backward compatible)",
277 },
278 ProfileInfo {
279 name: "auto",
280 tool_count: "5→all",
281 description: "Starts minimal, escalates based on session complexity",
282 },
283 ]
284}
285
286pub fn set_profile_in_config(profile_name: &str) -> Result<(), String> {
289 let config_path = crate::core::config::Config::path()
293 .ok_or_else(|| "Cannot determine config dir".to_string())?;
294
295 let mut doc = crate::config_io::load_toml_document(&config_path);
296 doc["tool_profile"] = toml_edit::value(profile_name);
297 crate::config_io::write_toml_document(&config_path, &doc)?;
298 Ok(())
299}
300
301pub fn clear_profile_in_config() -> Result<(), String> {
306 let config_path = crate::core::config::Config::path()
307 .ok_or_else(|| "Cannot determine config dir".to_string())?;
308 if !config_path.exists() {
309 return Ok(());
310 }
311
312 let mut doc = crate::config_io::load_toml_document(&config_path);
313 if doc.remove("tool_profile").is_none() {
314 return Ok(());
315 }
316 crate::config_io::write_toml_document(&config_path, &doc)?;
317 Ok(())
318}
319
320#[cfg(test)]
321mod tests {
322 use super::{
323 MINIMAL_TOOLS, PROFILE_NAMES, STANDARD_TOOLS, ToolProfile, clear_profile_in_config,
324 is_unpinned_alias, list_profiles, set_profile_in_config,
325 };
326
327 #[test]
328 fn parse_known_profiles() {
329 assert_eq!(ToolProfile::parse("minimal"), Some(ToolProfile::Minimal));
330 assert_eq!(ToolProfile::parse("min"), Some(ToolProfile::Minimal));
331 assert_eq!(ToolProfile::parse("standard"), Some(ToolProfile::Standard));
332 assert_eq!(ToolProfile::parse("std"), Some(ToolProfile::Standard));
333 assert_eq!(ToolProfile::parse("default"), Some(ToolProfile::Standard));
334 assert_eq!(ToolProfile::parse("power"), Some(ToolProfile::Power));
335 assert_eq!(ToolProfile::parse("full"), Some(ToolProfile::Power));
336 assert_eq!(ToolProfile::parse("all"), Some(ToolProfile::Power));
337 assert_eq!(ToolProfile::parse("auto"), Some(ToolProfile::Auto));
338 }
339
340 #[test]
341 fn parse_case_insensitive() {
342 assert_eq!(ToolProfile::parse("MINIMAL"), Some(ToolProfile::Minimal));
343 assert_eq!(ToolProfile::parse("Standard"), Some(ToolProfile::Standard));
344 assert_eq!(ToolProfile::parse("POWER"), Some(ToolProfile::Power));
345 }
346
347 #[test]
348 fn parse_unknown_returns_none() {
349 assert_eq!(ToolProfile::parse("unknown"), None);
350 assert_eq!(ToolProfile::parse(""), None);
351 }
352
353 #[test]
354 fn minimal_is_subset_of_standard() {
355 for tool in MINIMAL_TOOLS {
356 assert!(
357 STANDARD_TOOLS.contains(tool),
358 "minimal tool {tool} missing from standard"
359 );
360 }
361 }
362
363 #[test]
364 fn power_enables_everything() {
365 let profile = ToolProfile::Power;
366 assert!(profile.is_tool_enabled("ctx_read"));
367 assert!(profile.is_tool_enabled("ctx_anything"));
368 assert!(profile.is_tool_enabled("nonexistent_tool"));
369 }
370
371 #[test]
372 fn minimal_filters_correctly() {
373 let profile = ToolProfile::Minimal;
374 assert!(profile.is_tool_enabled("ctx_read"));
375 assert!(profile.is_tool_enabled("ctx_shell"));
376 assert!(profile.is_tool_enabled("ctx_search"));
377 assert!(profile.is_tool_enabled("ctx_glob"));
378 assert!(profile.is_tool_enabled("ctx_tree"));
379 assert!(!profile.is_tool_enabled("ctx_callgraph"));
382 }
383
384 #[test]
385 fn standard_filters_correctly() {
386 let profile = ToolProfile::Standard;
387 assert!(profile.is_tool_enabled("ctx_read"));
388 assert!(profile.is_tool_enabled("ctx_compose"));
389 assert!(profile.is_tool_enabled("ctx_glob"));
390 assert!(profile.is_tool_enabled("ctx_callgraph"));
391 assert!(profile.is_tool_enabled("ctx_graph"));
392 assert!(profile.is_tool_enabled("ctx_session"));
393 assert!(profile.is_tool_enabled("ctx_delta"));
394 assert!(profile.is_tool_enabled("ctx_expand"));
395 assert!(profile.is_tool_enabled("ctx_execute"));
396 assert!(profile.is_tool_enabled("ctx_url_read"));
400 assert!(profile.is_tool_enabled("ctx_patch"));
402 assert!(!profile.is_tool_enabled("ctx_edit"));
403 }
404
405 #[test]
406 fn custom_profile_uses_provided_list() {
407 let profile = ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()]);
408 assert!(profile.is_tool_enabled("ctx_read"));
409 assert!(profile.is_tool_enabled("ctx_shell"));
410 assert!(!profile.is_tool_enabled("ctx_search"));
411 }
412
413 #[test]
414 fn profile_display_counts_match_tool_arrays() {
415 let profiles = list_profiles();
419 assert_eq!(
420 profiles[0].tool_count.parse::<usize>().unwrap(),
421 MINIMAL_TOOLS.len(),
422 "minimal count must match MINIMAL_TOOLS length",
423 );
424 assert_eq!(
425 profiles[1].tool_count.parse::<usize>().unwrap(),
426 STANDARD_TOOLS.len(),
427 "standard count must match STANDARD_TOOLS length",
428 );
429 assert_eq!(profiles[2].tool_count, "all");
430 }
431
432 #[test]
433 fn custom_empty_enables_nothing() {
434 let profile = ToolProfile::Custom(vec![]);
435 assert!(!profile.is_tool_enabled("ctx_read"));
436 }
437
438 #[test]
439 fn display_matches_as_str() {
440 assert_eq!(format!("{}", ToolProfile::Minimal), "minimal");
441 assert_eq!(format!("{}", ToolProfile::Standard), "standard");
442 assert_eq!(format!("{}", ToolProfile::Power), "power");
443 assert_eq!(
444 format!("{}", ToolProfile::Custom(vec!["ctx_read".into()])),
445 "custom"
446 );
447 }
448
449 #[test]
450 fn tool_count_matches_list_length() {
451 assert_eq!(ToolProfile::Minimal.tool_count(), MINIMAL_TOOLS.len());
452 assert_eq!(ToolProfile::Standard.tool_count(), STANDARD_TOOLS.len());
453 assert_eq!(ToolProfile::Power.tool_count(), 0);
454 assert_eq!(ToolProfile::Auto.tool_count(), MINIMAL_TOOLS.len());
455 }
456
457 #[test]
458 fn from_config_defaults_to_power_for_backward_compat() {
459 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
460 return;
461 }
462 let cfg = crate::core::config::Config {
463 tool_profile: None,
464 tools_enabled: vec![],
465 ..Default::default()
466 };
467 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Power);
468 }
469
470 #[test]
471 fn from_config_respects_tool_profile_field() {
472 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
473 return;
474 }
475 let cfg = crate::core::config::Config {
476 tool_profile: Some("minimal".to_string()),
477 tools_enabled: vec![],
478 ..Default::default()
479 };
480 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Minimal);
481 }
482
483 #[test]
484 fn from_config_tools_enabled_creates_custom() {
485 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
486 return;
487 }
488 let cfg = crate::core::config::Config {
489 tool_profile: None,
490 tools_enabled: vec!["ctx_read".to_string(), "ctx_shell".to_string()],
491 ..Default::default()
492 };
493 let profile = ToolProfile::from_config(&cfg);
494 assert_eq!(
495 profile,
496 ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()])
497 );
498 }
499
500 #[test]
501 fn tool_profile_takes_precedence_over_tools_enabled() {
502 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
503 return;
504 }
505 let cfg = crate::core::config::Config {
506 tool_profile: Some("standard".to_string()),
507 tools_enabled: vec!["ctx_read".to_string()],
508 ..Default::default()
509 };
510 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Standard);
511 }
512
513 #[test]
514 fn empty_tool_profile_is_unpinned_so_tools_enabled_applies() {
515 assert!(is_unpinned_alias(""));
520 assert!(is_unpinned_alias(" "));
521
522 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
523 return;
524 }
525 let cfg = crate::core::config::Config {
526 tool_profile: Some(String::new()),
527 tools_enabled: vec!["ctx_read".to_string()],
528 ..Default::default()
529 };
530 assert_eq!(
531 ToolProfile::from_config(&cfg),
532 ToolProfile::Custom(vec!["ctx_read".to_string()]),
533 "empty tool_profile must unpin so tools_enabled takes effect"
534 );
535 }
536
537 #[test]
538 fn all_profile_names_are_parseable() {
539 for name in PROFILE_NAMES {
540 assert!(
541 ToolProfile::parse(name).is_some(),
542 "profile name '{name}' should be parseable"
543 );
544 }
545 }
546
547 #[test]
548 fn list_profiles_returns_four_entries() {
549 let profiles = list_profiles();
550 assert_eq!(profiles.len(), 4);
551 }
552
553 #[test]
554 fn resolve_auto_short_session_returns_minimal() {
555 assert_eq!(
556 ToolProfile::resolve_auto(0, false, 500),
557 ToolProfile::Minimal
558 );
559 assert_eq!(
560 ToolProfile::resolve_auto(1, false, 1500),
561 ToolProfile::Minimal
562 );
563 }
564
565 #[test]
566 fn resolve_auto_ctx_tools_used_returns_standard() {
567 assert_eq!(
568 ToolProfile::resolve_auto(1, true, 500),
569 ToolProfile::Standard
570 );
571 assert_eq!(ToolProfile::resolve_auto(0, true, 0), ToolProfile::Standard);
572 }
573
574 #[test]
575 fn resolve_auto_long_session_returns_power() {
576 assert_eq!(
577 ToolProfile::resolve_auto(10, false, 500),
578 ToolProfile::Power
579 );
580 assert_eq!(
581 ToolProfile::resolve_auto(20, false, 3000),
582 ToolProfile::Power
583 );
584 }
585
586 #[test]
587 fn resolve_auto_medium_session_returns_standard() {
588 assert_eq!(
589 ToolProfile::resolve_auto(5, false, 3000),
590 ToolProfile::Standard
591 );
592 }
593
594 #[test]
595 fn auto_profile_display() {
596 assert_eq!(ToolProfile::Auto.as_str(), "auto");
597 assert_eq!(format!("{}", ToolProfile::Auto), "auto");
598 }
599
600 #[test]
601 fn standard_includes_all_core_tools() {
602 let profile = ToolProfile::Standard;
603 assert!(
604 profile.is_tool_enabled("ctx_graph"),
605 "ctx_graph must be in standard"
606 );
607 assert!(
608 profile.is_tool_enabled("ctx_delta"),
609 "ctx_delta must be in standard"
610 );
611 assert!(
612 profile.is_tool_enabled("ctx_expand"),
613 "ctx_expand must be in standard"
614 );
615 assert!(
616 profile.is_tool_enabled("ctx_execute"),
617 "ctx_execute must be in standard (sandboxed code execution)"
618 );
619 assert!(
622 !profile.is_tool_enabled("ctx_edit"),
623 "ctx_edit must NOT be in standard (ctx_patch is the standard editor)"
624 );
625 assert!(
626 profile.is_tool_enabled("ctx_patch"),
627 "ctx_patch must be in standard (#1008 anchored editing)"
628 );
629 }
630
631 #[test]
632 fn standard_includes_url_read() {
633 let profile = ToolProfile::Standard;
634 assert!(
635 profile.is_tool_enabled("ctx_url_read"),
636 "ctx_url_read must be in standard (web/research context)"
637 );
638 }
639
640 #[test]
641 fn clear_profile_removes_key_and_is_idempotent() {
642 let iso = crate::core::data_dir::isolated_data_dir();
643 set_profile_in_config("power").unwrap();
644 let config_path = iso.path().join("config.toml");
645 assert!(
646 std::fs::read_to_string(&config_path)
647 .unwrap()
648 .contains("tool_profile"),
649 "set_profile_in_config must write the key"
650 );
651
652 clear_profile_in_config().unwrap();
653 assert!(
654 !std::fs::read_to_string(&config_path)
655 .unwrap()
656 .contains("tool_profile"),
657 "clear_profile_in_config must remove the key (lean default, #575)"
658 );
659
660 clear_profile_in_config().unwrap();
662 }
663
664 #[test]
665 fn clear_profile_on_missing_config_is_ok() {
666 let _iso = crate::core::data_dir::isolated_data_dir();
667 clear_profile_in_config().unwrap();
668 }
669
670 #[test]
671 fn without_tool_removes_from_power() {
672 let filtered = ToolProfile::Power.without_tool("ctx_patch");
673 assert!(!filtered.is_tool_enabled("ctx_patch"));
674 assert!(filtered.is_tool_enabled("ctx_read"));
675 assert!(filtered.is_tool_enabled("ctx_search"));
676 }
677
678 #[test]
679 fn without_tool_removes_from_standard() {
680 let filtered = ToolProfile::Standard.without_tool("ctx_patch");
681 assert!(!filtered.is_tool_enabled("ctx_patch"));
682 assert!(filtered.is_tool_enabled("ctx_read"));
683 assert!(filtered.is_tool_enabled("ctx_compose"));
684 }
685
686 #[test]
687 fn without_tool_noop_for_already_missing() {
688 let filtered = ToolProfile::Minimal.without_tool("ctx_patch");
689 assert!(!filtered.is_tool_enabled("ctx_patch"));
690 assert!(filtered.is_tool_enabled("ctx_read"));
691 }
692}