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