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