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_session",
217 "ctx_callgraph",
218 "ctx_graph",
219 "ctx_delta",
220 "ctx_execute",
221 "ctx_expand",
222 "ctx_overview",
223 "ctx_url_read",
224 "ctx_patch",
225];
226
227pub const PROFILE_NAMES: &[&str] = &["minimal", "standard", "power"];
229
230pub struct ProfileInfo {
231 pub name: &'static str,
232 pub tool_count: &'static str,
233 pub description: &'static str,
234}
235
236pub fn list_profiles() -> Vec<ProfileInfo> {
237 vec![
238 ProfileInfo {
239 name: "minimal",
240 tool_count: "5",
241 description: "Surgical core — each tool irreplaceable (recommended)",
242 },
243 ProfileInfo {
244 name: "standard",
245 tool_count: "17",
246 description: "Balanced set — adds compose, knowledge, session, callgraph, patch, more",
247 },
248 ProfileInfo {
249 name: "power",
250 tool_count: "all",
251 description: "Every tool exposed (backward compatible)",
252 },
253 ]
254}
255
256pub fn set_profile_in_config(profile_name: &str) -> Result<(), String> {
259 let config_path = crate::core::config::Config::path()
263 .ok_or_else(|| "Cannot determine config dir".to_string())?;
264
265 let mut doc = crate::config_io::load_toml_document(&config_path);
266 doc["tool_profile"] = toml_edit::value(profile_name);
267 crate::config_io::write_toml_document(&config_path, &doc)?;
268 Ok(())
269}
270
271pub fn clear_profile_in_config() -> Result<(), String> {
276 let config_path = crate::core::config::Config::path()
277 .ok_or_else(|| "Cannot determine config dir".to_string())?;
278 if !config_path.exists() {
279 return Ok(());
280 }
281
282 let mut doc = crate::config_io::load_toml_document(&config_path);
283 if doc.remove("tool_profile").is_none() {
284 return Ok(());
285 }
286 crate::config_io::write_toml_document(&config_path, &doc)?;
287 Ok(())
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn parse_known_profiles() {
296 assert_eq!(ToolProfile::parse("minimal"), Some(ToolProfile::Minimal));
297 assert_eq!(ToolProfile::parse("min"), Some(ToolProfile::Minimal));
298 assert_eq!(ToolProfile::parse("standard"), Some(ToolProfile::Standard));
299 assert_eq!(ToolProfile::parse("std"), Some(ToolProfile::Standard));
300 assert_eq!(ToolProfile::parse("default"), Some(ToolProfile::Standard));
301 assert_eq!(ToolProfile::parse("power"), Some(ToolProfile::Power));
302 assert_eq!(ToolProfile::parse("full"), Some(ToolProfile::Power));
303 assert_eq!(ToolProfile::parse("all"), Some(ToolProfile::Power));
304 }
305
306 #[test]
307 fn parse_case_insensitive() {
308 assert_eq!(ToolProfile::parse("MINIMAL"), Some(ToolProfile::Minimal));
309 assert_eq!(ToolProfile::parse("Standard"), Some(ToolProfile::Standard));
310 assert_eq!(ToolProfile::parse("POWER"), Some(ToolProfile::Power));
311 }
312
313 #[test]
314 fn parse_unknown_returns_none() {
315 assert_eq!(ToolProfile::parse("unknown"), None);
316 assert_eq!(ToolProfile::parse(""), None);
317 }
318
319 #[test]
320 fn minimal_profile_schema_budget() {
321 const MINIMAL_SCHEMA_BUDGET_TOKENS: usize = 2800;
325 let defs = crate::server::registry::build_registry().tool_defs();
326 let total: usize = defs
327 .iter()
328 .filter(|t| MINIMAL_TOOLS.contains(&t.name.as_ref()))
329 .map(crate::core::context_overhead::tool_tokens)
330 .sum();
331 assert!(total > 0, "minimal tools must exist in the registry");
332 assert!(
333 total <= MINIMAL_SCHEMA_BUDGET_TOKENS,
334 "minimal-profile tool schemas = {total} tok, budget {MINIMAL_SCHEMA_BUDGET_TOKENS}"
335 );
336 }
337
338 #[test]
339 fn minimal_is_subset_of_standard() {
340 for tool in MINIMAL_TOOLS {
341 assert!(
342 STANDARD_TOOLS.contains(tool),
343 "minimal tool {tool} missing from standard"
344 );
345 }
346 }
347
348 #[test]
349 fn power_enables_everything() {
350 let profile = ToolProfile::Power;
351 assert!(profile.is_tool_enabled("ctx_read"));
352 assert!(profile.is_tool_enabled("ctx_anything"));
353 assert!(profile.is_tool_enabled("nonexistent_tool"));
354 }
355
356 #[test]
357 fn minimal_filters_correctly() {
358 let profile = ToolProfile::Minimal;
359 assert!(profile.is_tool_enabled("ctx_read"));
360 assert!(profile.is_tool_enabled("ctx_shell"));
361 assert!(profile.is_tool_enabled("ctx_search"));
362 assert!(profile.is_tool_enabled("ctx_glob"));
363 assert!(profile.is_tool_enabled("ctx_tree"));
364 assert!(!profile.is_tool_enabled("ctx_symbol"));
367 assert!(!profile.is_tool_enabled("ctx_semantic_search"));
368 assert!(!profile.is_tool_enabled("ctx_callgraph"));
369 assert!(!profile.is_tool_enabled("ctx_benchmark"));
370 }
371
372 #[test]
373 fn standard_filters_correctly() {
374 let profile = ToolProfile::Standard;
375 assert!(profile.is_tool_enabled("ctx_read"));
376 assert!(profile.is_tool_enabled("ctx_compose"));
377 assert!(profile.is_tool_enabled("ctx_explore"));
378 assert!(profile.is_tool_enabled("ctx_glob"));
379 assert!(profile.is_tool_enabled("ctx_callgraph"));
380 assert!(profile.is_tool_enabled("ctx_graph"));
381 assert!(profile.is_tool_enabled("ctx_session"));
382 assert!(profile.is_tool_enabled("ctx_delta"));
383 assert!(profile.is_tool_enabled("ctx_expand"));
384 assert!(profile.is_tool_enabled("ctx_execute"));
385 assert!(profile.is_tool_enabled("ctx_overview"));
386 assert!(!profile.is_tool_enabled("ctx_symbol"));
389 assert!(!profile.is_tool_enabled("ctx_semantic_search"));
390 assert!(!profile.is_tool_enabled("ctx_multi_read"));
391 assert!(profile.is_tool_enabled("ctx_url_read"));
392 assert!(profile.is_tool_enabled("ctx_patch"));
394 assert!(!profile.is_tool_enabled("ctx_benchmark"));
395 assert!(!profile.is_tool_enabled("ctx_analyze"));
396 assert!(!profile.is_tool_enabled("ctx_refactor"));
397 assert!(!profile.is_tool_enabled("ctx_edit"));
398 }
399
400 #[test]
401 fn custom_profile_uses_provided_list() {
402 let profile = ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()]);
403 assert!(profile.is_tool_enabled("ctx_read"));
404 assert!(profile.is_tool_enabled("ctx_shell"));
405 assert!(!profile.is_tool_enabled("ctx_search"));
406 }
407
408 #[test]
409 fn profile_display_counts_match_tool_arrays() {
410 let profiles = list_profiles();
414 assert_eq!(
415 profiles[0].tool_count.parse::<usize>().unwrap(),
416 MINIMAL_TOOLS.len(),
417 "minimal count must match MINIMAL_TOOLS length",
418 );
419 assert_eq!(
420 profiles[1].tool_count.parse::<usize>().unwrap(),
421 STANDARD_TOOLS.len(),
422 "standard count must match STANDARD_TOOLS length",
423 );
424 assert_eq!(profiles[2].tool_count, "all");
425 }
426
427 #[test]
428 fn custom_empty_enables_nothing() {
429 let profile = ToolProfile::Custom(vec![]);
430 assert!(!profile.is_tool_enabled("ctx_read"));
431 }
432
433 #[test]
434 fn display_matches_as_str() {
435 assert_eq!(format!("{}", ToolProfile::Minimal), "minimal");
436 assert_eq!(format!("{}", ToolProfile::Standard), "standard");
437 assert_eq!(format!("{}", ToolProfile::Power), "power");
438 assert_eq!(
439 format!("{}", ToolProfile::Custom(vec!["ctx_read".into()])),
440 "custom"
441 );
442 }
443
444 #[test]
445 fn tool_count_matches_list_length() {
446 assert_eq!(ToolProfile::Minimal.tool_count(), MINIMAL_TOOLS.len());
447 assert_eq!(ToolProfile::Standard.tool_count(), STANDARD_TOOLS.len());
448 assert_eq!(ToolProfile::Power.tool_count(), 0);
449 }
450
451 #[test]
452 fn from_config_defaults_to_power_for_backward_compat() {
453 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
454 return;
455 }
456 let cfg = crate::core::config::Config {
457 tool_profile: None,
458 tools_enabled: vec![],
459 ..Default::default()
460 };
461 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Power);
462 }
463
464 #[test]
465 fn from_config_respects_tool_profile_field() {
466 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
467 return;
468 }
469 let cfg = crate::core::config::Config {
470 tool_profile: Some("minimal".to_string()),
471 tools_enabled: vec![],
472 ..Default::default()
473 };
474 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Minimal);
475 }
476
477 #[test]
478 fn from_config_tools_enabled_creates_custom() {
479 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
480 return;
481 }
482 let cfg = crate::core::config::Config {
483 tool_profile: None,
484 tools_enabled: vec!["ctx_read".to_string(), "ctx_shell".to_string()],
485 ..Default::default()
486 };
487 let profile = ToolProfile::from_config(&cfg);
488 assert_eq!(
489 profile,
490 ToolProfile::Custom(vec!["ctx_read".to_string(), "ctx_shell".to_string()])
491 );
492 }
493
494 #[test]
495 fn tool_profile_takes_precedence_over_tools_enabled() {
496 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
497 return;
498 }
499 let cfg = crate::core::config::Config {
500 tool_profile: Some("standard".to_string()),
501 tools_enabled: vec!["ctx_read".to_string()],
502 ..Default::default()
503 };
504 assert_eq!(ToolProfile::from_config(&cfg), ToolProfile::Standard);
505 }
506
507 #[test]
508 fn empty_tool_profile_is_unpinned_so_tools_enabled_applies() {
509 assert!(is_unpinned_alias(""));
514 assert!(is_unpinned_alias(" "));
515
516 if std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok() {
517 return;
518 }
519 let cfg = crate::core::config::Config {
520 tool_profile: Some(String::new()),
521 tools_enabled: vec!["ctx_read".to_string()],
522 ..Default::default()
523 };
524 assert_eq!(
525 ToolProfile::from_config(&cfg),
526 ToolProfile::Custom(vec!["ctx_read".to_string()]),
527 "empty tool_profile must unpin so tools_enabled takes effect"
528 );
529 }
530
531 #[test]
532 fn all_profile_names_are_parseable() {
533 for name in PROFILE_NAMES {
534 assert!(
535 ToolProfile::parse(name).is_some(),
536 "profile name '{name}' should be parseable"
537 );
538 }
539 }
540
541 #[test]
542 fn list_profiles_returns_three_entries() {
543 let profiles = list_profiles();
544 assert_eq!(profiles.len(), 3);
545 }
546
547 #[test]
548 fn standard_includes_all_core_tools() {
549 let profile = ToolProfile::Standard;
550 assert!(
551 profile.is_tool_enabled("ctx_graph"),
552 "ctx_graph must be in standard"
553 );
554 assert!(
555 profile.is_tool_enabled("ctx_delta"),
556 "ctx_delta must be in standard"
557 );
558 assert!(
559 profile.is_tool_enabled("ctx_expand"),
560 "ctx_expand must be in standard"
561 );
562 assert!(
563 profile.is_tool_enabled("ctx_execute"),
564 "ctx_execute must be in standard (sandboxed code execution)"
565 );
566 assert!(
569 !profile.is_tool_enabled("ctx_edit"),
570 "ctx_edit must NOT be in standard (ctx_patch is the standard editor)"
571 );
572 assert!(
573 profile.is_tool_enabled("ctx_patch"),
574 "ctx_patch must be in standard (#1008 anchored editing)"
575 );
576 }
577
578 #[test]
579 fn standard_includes_url_read() {
580 let profile = ToolProfile::Standard;
581 assert!(
582 profile.is_tool_enabled("ctx_url_read"),
583 "ctx_url_read must be in standard (web/research context)"
584 );
585 }
586
587 #[test]
588 fn clear_profile_removes_key_and_is_idempotent() {
589 let iso = crate::core::data_dir::isolated_data_dir();
590 set_profile_in_config("power").unwrap();
591 let config_path = iso.path().join("config.toml");
592 assert!(
593 std::fs::read_to_string(&config_path)
594 .unwrap()
595 .contains("tool_profile"),
596 "set_profile_in_config must write the key"
597 );
598
599 clear_profile_in_config().unwrap();
600 assert!(
601 !std::fs::read_to_string(&config_path)
602 .unwrap()
603 .contains("tool_profile"),
604 "clear_profile_in_config must remove the key (lean default, #575)"
605 );
606
607 clear_profile_in_config().unwrap();
609 }
610
611 #[test]
612 fn clear_profile_on_missing_config_is_ok() {
613 let _iso = crate::core::data_dir::isolated_data_dir();
614 clear_profile_in_config().unwrap();
615 }
616
617 #[test]
618 fn without_tool_removes_from_power() {
619 let filtered = ToolProfile::Power.without_tool("ctx_patch");
620 assert!(!filtered.is_tool_enabled("ctx_patch"));
621 assert!(filtered.is_tool_enabled("ctx_read"));
622 assert!(filtered.is_tool_enabled("ctx_search"));
623 }
624
625 #[test]
626 fn without_tool_removes_from_standard() {
627 let filtered = ToolProfile::Standard.without_tool("ctx_patch");
628 assert!(!filtered.is_tool_enabled("ctx_patch"));
629 assert!(filtered.is_tool_enabled("ctx_read"));
630 assert!(filtered.is_tool_enabled("ctx_compose"));
631 }
632
633 #[test]
634 fn without_tool_noop_for_already_missing() {
635 let filtered = ToolProfile::Minimal.without_tool("ctx_patch");
636 assert!(!filtered.is_tool_enabled("ctx_patch"));
637 assert!(filtered.is_tool_enabled("ctx_read"));
638 }
639}