1use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Profile {
27 #[serde(default)]
28 pub profile: ProfileMeta,
29 #[serde(default)]
30 pub read: ReadConfig,
31 #[serde(default)]
32 pub compression: CompressionConfig,
33 #[serde(default)]
34 pub budget: BudgetConfig,
35 #[serde(default)]
36 pub pipeline: PipelineConfig,
37 #[serde(default)]
38 pub autonomy: ProfileAutonomy,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43pub struct ProfileMeta {
44 #[serde(default)]
45 pub name: String,
46 pub inherits: Option<String>,
47 #[serde(default)]
48 pub description: String,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ReadConfig {
54 #[serde(default = "default_read_mode")]
55 pub default_mode: String,
56 #[serde(default = "default_max_tokens")]
57 pub max_tokens_per_file: usize,
58 #[serde(default)]
59 pub prefer_cache: bool,
60}
61
62fn default_read_mode() -> String {
63 "auto".to_string()
64}
65fn default_max_tokens() -> usize {
66 50_000
67}
68
69impl Default for ReadConfig {
70 fn default() -> Self {
71 Self {
72 default_mode: default_read_mode(),
73 max_tokens_per_file: default_max_tokens(),
74 prefer_cache: false,
75 }
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct CompressionConfig {
82 #[serde(default = "default_crp_mode")]
83 pub crp_mode: String,
84 #[serde(default = "default_output_density")]
85 pub output_density: String,
86 #[serde(default = "default_entropy_threshold")]
87 pub entropy_threshold: f64,
88}
89
90fn default_crp_mode() -> String {
91 "tdd".to_string()
92}
93fn default_output_density() -> String {
94 "normal".to_string()
95}
96fn default_entropy_threshold() -> f64 {
97 0.3
98}
99
100impl Default for CompressionConfig {
101 fn default() -> Self {
102 Self {
103 crp_mode: default_crp_mode(),
104 output_density: default_output_density(),
105 entropy_threshold: default_entropy_threshold(),
106 }
107 }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct BudgetConfig {
113 #[serde(default = "default_context_tokens")]
114 pub max_context_tokens: usize,
115 #[serde(default = "default_shell_invocations")]
116 pub max_shell_invocations: usize,
117 #[serde(default = "default_cost_usd")]
118 pub max_cost_usd: f64,
119}
120
121fn default_context_tokens() -> usize {
122 200_000
123}
124fn default_shell_invocations() -> usize {
125 100
126}
127fn default_cost_usd() -> f64 {
128 5.0
129}
130
131impl Default for BudgetConfig {
132 fn default() -> Self {
133 Self {
134 max_context_tokens: default_context_tokens(),
135 max_shell_invocations: default_shell_invocations(),
136 max_cost_usd: default_cost_usd(),
137 }
138 }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct PipelineConfig {
144 #[serde(default = "default_true")]
145 pub intent: bool,
146 #[serde(default = "default_true")]
147 pub relevance: bool,
148 #[serde(default = "default_true")]
149 pub compression: bool,
150 #[serde(default = "default_true")]
151 pub translation: bool,
152}
153
154fn default_true() -> bool {
155 true
156}
157
158impl Default for PipelineConfig {
159 fn default() -> Self {
160 Self {
161 intent: true,
162 relevance: true,
163 compression: true,
164 translation: true,
165 }
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct ProfileAutonomy {
172 #[serde(default = "default_true")]
173 pub auto_dedup: bool,
174 #[serde(default = "default_checkpoint")]
175 pub checkpoint_interval: u32,
176}
177
178fn default_checkpoint() -> u32 {
179 15
180}
181
182impl Default for ProfileAutonomy {
183 fn default() -> Self {
184 Self {
185 auto_dedup: true,
186 checkpoint_interval: default_checkpoint(),
187 }
188 }
189}
190
191fn builtin_exploration() -> Profile {
194 Profile {
195 profile: ProfileMeta {
196 name: "exploration".to_string(),
197 inherits: None,
198 description: "Broad context for understanding codebases".to_string(),
199 },
200 read: ReadConfig {
201 default_mode: "map".to_string(),
202 max_tokens_per_file: 80_000,
203 prefer_cache: true,
204 },
205 compression: CompressionConfig::default(),
206 budget: BudgetConfig {
207 max_context_tokens: 200_000,
208 ..BudgetConfig::default()
209 },
210 pipeline: PipelineConfig::default(),
211 autonomy: ProfileAutonomy::default(),
212 }
213}
214
215fn builtin_bugfix() -> Profile {
216 Profile {
217 profile: ProfileMeta {
218 name: "bugfix".to_string(),
219 inherits: None,
220 description: "Focused context for debugging specific issues".to_string(),
221 },
222 read: ReadConfig {
223 default_mode: "auto".to_string(),
224 max_tokens_per_file: 30_000,
225 prefer_cache: false,
226 },
227 compression: CompressionConfig {
228 crp_mode: "tdd".to_string(),
229 output_density: "terse".to_string(),
230 ..CompressionConfig::default()
231 },
232 budget: BudgetConfig {
233 max_context_tokens: 100_000,
234 max_shell_invocations: 50,
235 ..BudgetConfig::default()
236 },
237 pipeline: PipelineConfig::default(),
238 autonomy: ProfileAutonomy {
239 checkpoint_interval: 10,
240 ..ProfileAutonomy::default()
241 },
242 }
243}
244
245fn builtin_hotfix() -> Profile {
246 Profile {
247 profile: ProfileMeta {
248 name: "hotfix".to_string(),
249 inherits: None,
250 description: "Minimal context, fast iteration for urgent fixes".to_string(),
251 },
252 read: ReadConfig {
253 default_mode: "signatures".to_string(),
254 max_tokens_per_file: 2_000,
255 prefer_cache: true,
256 },
257 compression: CompressionConfig {
258 crp_mode: "tdd".to_string(),
259 output_density: "ultra".to_string(),
260 ..CompressionConfig::default()
261 },
262 budget: BudgetConfig {
263 max_context_tokens: 30_000,
264 max_shell_invocations: 20,
265 max_cost_usd: 1.0,
266 },
267 pipeline: PipelineConfig::default(),
268 autonomy: ProfileAutonomy {
269 checkpoint_interval: 5,
270 ..ProfileAutonomy::default()
271 },
272 }
273}
274
275fn builtin_ci_debug() -> Profile {
276 Profile {
277 profile: ProfileMeta {
278 name: "ci-debug".to_string(),
279 inherits: None,
280 description: "CI/CD debugging with shell-heavy workflows".to_string(),
281 },
282 read: ReadConfig {
283 default_mode: "auto".to_string(),
284 max_tokens_per_file: 50_000,
285 prefer_cache: false,
286 },
287 compression: CompressionConfig {
288 output_density: "terse".to_string(),
289 ..CompressionConfig::default()
290 },
291 budget: BudgetConfig {
292 max_context_tokens: 150_000,
293 max_shell_invocations: 200,
294 ..BudgetConfig::default()
295 },
296 pipeline: PipelineConfig::default(),
297 autonomy: ProfileAutonomy::default(),
298 }
299}
300
301fn builtin_review() -> Profile {
302 Profile {
303 profile: ProfileMeta {
304 name: "review".to_string(),
305 inherits: None,
306 description: "Code review with broad read-only context".to_string(),
307 },
308 read: ReadConfig {
309 default_mode: "map".to_string(),
310 max_tokens_per_file: 60_000,
311 prefer_cache: true,
312 },
313 compression: CompressionConfig {
314 crp_mode: "compact".to_string(),
315 ..CompressionConfig::default()
316 },
317 budget: BudgetConfig {
318 max_context_tokens: 150_000,
319 max_shell_invocations: 30,
320 ..BudgetConfig::default()
321 },
322 pipeline: PipelineConfig::default(),
323 autonomy: ProfileAutonomy::default(),
324 }
325}
326
327pub fn builtin_profiles() -> HashMap<String, Profile> {
329 let mut map = HashMap::new();
330 for p in [
331 builtin_exploration(),
332 builtin_bugfix(),
333 builtin_hotfix(),
334 builtin_ci_debug(),
335 builtin_review(),
336 ] {
337 map.insert(p.profile.name.clone(), p);
338 }
339 map
340}
341
342fn profiles_dir_global() -> Option<PathBuf> {
345 crate::core::data_dir::lean_ctx_data_dir()
346 .ok()
347 .map(|d| d.join("profiles"))
348}
349
350fn profiles_dir_project() -> Option<PathBuf> {
351 let mut current = std::env::current_dir().ok()?;
352 for _ in 0..12 {
353 let candidate = current.join(".lean-ctx").join("profiles");
354 if candidate.is_dir() {
355 return Some(candidate);
356 }
357 if !current.pop() {
358 break;
359 }
360 }
361 None
362}
363
364pub fn load_profile(name: &str) -> Option<Profile> {
371 load_profile_recursive(name, 0)
372}
373
374fn load_profile_recursive(name: &str, depth: usize) -> Option<Profile> {
375 if depth > 5 {
376 return None;
377 }
378
379 let mut profile = load_profile_from_disk(name).or_else(|| builtin_profiles().remove(name))?;
380 profile.profile.name = name.to_string();
381
382 if let Some(ref parent_name) = profile.profile.inherits.clone() {
383 if let Some(parent) = load_profile_recursive(parent_name, depth + 1) {
384 profile = merge_profiles(parent, profile);
385 }
386 }
387
388 Some(profile)
389}
390
391fn load_profile_from_disk(name: &str) -> Option<Profile> {
392 let filename = format!("{name}.toml");
393
394 if let Some(project_dir) = profiles_dir_project() {
395 let path = project_dir.join(&filename);
396 if let Some(p) = try_load_toml(&path) {
397 return Some(p);
398 }
399 }
400
401 if let Some(global_dir) = profiles_dir_global() {
402 let path = global_dir.join(&filename);
403 if let Some(p) = try_load_toml(&path) {
404 return Some(p);
405 }
406 }
407
408 None
409}
410
411fn try_load_toml(path: &Path) -> Option<Profile> {
412 let content = std::fs::read_to_string(path).ok()?;
413 toml::from_str(&content).ok()
414}
415
416fn merge_profiles(parent: Profile, child: Profile) -> Profile {
419 Profile {
420 profile: ProfileMeta {
421 name: child.profile.name,
422 inherits: child.profile.inherits,
423 description: if child.profile.description.is_empty() {
424 parent.profile.description
425 } else {
426 child.profile.description
427 },
428 },
429 read: child.read,
430 compression: child.compression,
431 budget: child.budget,
432 pipeline: child.pipeline,
433 autonomy: child.autonomy,
434 }
435}
436
437pub fn active_profile_name() -> String {
439 std::env::var("LEAN_CTX_PROFILE")
440 .ok()
441 .filter(|s| !s.trim().is_empty())
442 .unwrap_or_else(|| "exploration".to_string())
443}
444
445pub fn active_profile() -> Profile {
447 let name = active_profile_name();
448 load_profile(&name).unwrap_or_else(builtin_exploration)
449}
450
451pub fn set_active_profile(name: &str) -> Result<Profile, String> {
455 let name = name.trim();
456 if name.is_empty() {
457 return Err("profile name is empty".to_string());
458 }
459 let profile = load_profile(name).ok_or_else(|| format!("profile '{name}' not found"))?;
460 std::env::set_var("LEAN_CTX_PROFILE", name);
461 Ok(profile)
462}
463
464pub fn list_profiles() -> Vec<ProfileInfo> {
466 let mut profiles: HashMap<String, ProfileInfo> = HashMap::new();
467
468 for (name, p) in builtin_profiles() {
469 profiles.insert(
470 name.clone(),
471 ProfileInfo {
472 name,
473 description: p.profile.description,
474 source: ProfileSource::Builtin,
475 },
476 );
477 }
478
479 for (source, dir) in [
480 (ProfileSource::Global, profiles_dir_global()),
481 (ProfileSource::Project, profiles_dir_project()),
482 ] {
483 if let Some(dir) = dir {
484 if let Ok(entries) = std::fs::read_dir(&dir) {
485 for entry in entries.flatten() {
486 let path = entry.path();
487 if path.extension().and_then(|e| e.to_str()) == Some("toml") {
488 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
489 let name = stem.to_string();
490 let desc = try_load_toml(&path)
491 .map(|p| p.profile.description)
492 .unwrap_or_default();
493 profiles.insert(
494 name.clone(),
495 ProfileInfo {
496 name,
497 description: desc,
498 source,
499 },
500 );
501 }
502 }
503 }
504 }
505 }
506 }
507
508 let mut result: Vec<ProfileInfo> = profiles.into_values().collect();
509 result.sort_by(|a, b| a.name.cmp(&b.name));
510 result
511}
512
513#[derive(Debug, Clone)]
515pub struct ProfileInfo {
516 pub name: String,
517 pub description: String,
518 pub source: ProfileSource,
519}
520
521#[derive(Debug, Clone, Copy, PartialEq, Eq)]
523pub enum ProfileSource {
524 Builtin,
525 Global,
526 Project,
527}
528
529impl std::fmt::Display for ProfileSource {
530 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
531 match self {
532 Self::Builtin => write!(f, "built-in"),
533 Self::Global => write!(f, "global"),
534 Self::Project => write!(f, "project"),
535 }
536 }
537}
538
539pub fn format_as_toml(profile: &Profile) -> String {
541 toml::to_string_pretty(profile).unwrap_or_else(|_| "[error serializing profile]".to_string())
542}
543
544#[cfg(test)]
547mod tests {
548 use super::*;
549
550 #[test]
551 fn builtin_profiles_has_five() {
552 let builtins = builtin_profiles();
553 assert_eq!(builtins.len(), 5);
554 assert!(builtins.contains_key("exploration"));
555 assert!(builtins.contains_key("bugfix"));
556 assert!(builtins.contains_key("hotfix"));
557 assert!(builtins.contains_key("ci-debug"));
558 assert!(builtins.contains_key("review"));
559 }
560
561 #[test]
562 fn hotfix_has_minimal_budget() {
563 let p = builtin_profiles().remove("hotfix").unwrap();
564 assert_eq!(p.budget.max_context_tokens, 30_000);
565 assert_eq!(p.budget.max_shell_invocations, 20);
566 assert_eq!(p.read.default_mode, "signatures");
567 assert_eq!(p.compression.output_density, "ultra");
568 }
569
570 #[test]
571 fn exploration_has_broad_context() {
572 let p = builtin_profiles().remove("exploration").unwrap();
573 assert_eq!(p.budget.max_context_tokens, 200_000);
574 assert_eq!(p.read.default_mode, "map");
575 assert!(p.read.prefer_cache);
576 }
577
578 #[test]
579 fn profile_roundtrip_toml() {
580 let original = builtin_exploration();
581 let toml_str = format_as_toml(&original);
582 let parsed: Profile = toml::from_str(&toml_str).unwrap();
583 assert_eq!(parsed.profile.name, "exploration");
584 assert_eq!(parsed.read.default_mode, "map");
585 assert_eq!(parsed.budget.max_context_tokens, 200_000);
586 }
587
588 #[test]
589 fn merge_child_overrides_parent() {
590 let parent = builtin_exploration();
591 let child = Profile {
592 profile: ProfileMeta {
593 name: "custom".to_string(),
594 inherits: Some("exploration".to_string()),
595 description: String::new(),
596 },
597 read: ReadConfig {
598 default_mode: "signatures".to_string(),
599 ..ReadConfig::default()
600 },
601 compression: CompressionConfig::default(),
602 budget: BudgetConfig {
603 max_context_tokens: 10_000,
604 ..BudgetConfig::default()
605 },
606 pipeline: PipelineConfig::default(),
607 autonomy: ProfileAutonomy::default(),
608 };
609
610 let merged = merge_profiles(parent, child);
611 assert_eq!(merged.read.default_mode, "signatures");
612 assert_eq!(merged.budget.max_context_tokens, 10_000);
613 assert_eq!(
614 merged.profile.description,
615 "Broad context for understanding codebases"
616 );
617 }
618
619 #[test]
620 fn load_builtin_by_name() {
621 let p = load_profile("hotfix").unwrap();
622 assert_eq!(p.profile.name, "hotfix");
623 assert_eq!(p.read.default_mode, "signatures");
624 }
625
626 #[test]
627 fn load_nonexistent_returns_none() {
628 assert!(load_profile("does-not-exist-xyz").is_none());
629 }
630
631 #[test]
632 fn list_profiles_includes_builtins() {
633 let list = list_profiles();
634 assert!(list.len() >= 5);
635 let names: Vec<&str> = list.iter().map(|p| p.name.as_str()).collect();
636 assert!(names.contains(&"exploration"));
637 assert!(names.contains(&"hotfix"));
638 assert!(names.contains(&"review"));
639 }
640
641 #[test]
642 fn active_profile_defaults_to_exploration() {
643 std::env::remove_var("LEAN_CTX_PROFILE");
644 let p = active_profile();
645 assert_eq!(p.profile.name, "exploration");
646 }
647
648 #[test]
649 fn active_profile_from_env() {
650 std::env::set_var("LEAN_CTX_PROFILE", "hotfix");
651 let name = active_profile_name();
652 assert_eq!(name, "hotfix");
653 std::env::remove_var("LEAN_CTX_PROFILE");
654 }
655
656 #[test]
657 fn profile_source_display() {
658 assert_eq!(ProfileSource::Builtin.to_string(), "built-in");
659 assert_eq!(ProfileSource::Global.to_string(), "global");
660 assert_eq!(ProfileSource::Project.to_string(), "project");
661 }
662
663 #[test]
664 fn default_profile_has_sane_values() {
665 let p = Profile {
666 profile: ProfileMeta::default(),
667 read: ReadConfig::default(),
668 compression: CompressionConfig::default(),
669 budget: BudgetConfig::default(),
670 pipeline: PipelineConfig::default(),
671 autonomy: ProfileAutonomy::default(),
672 };
673 assert_eq!(p.read.default_mode, "auto");
674 assert_eq!(p.compression.crp_mode, "tdd");
675 assert_eq!(p.budget.max_context_tokens, 200_000);
676 assert!(p.pipeline.compression);
677 assert!(p.pipeline.intent);
678 }
679
680 #[test]
681 fn pipeline_layers_configurable() {
682 let toml_str = r#"
683[profile]
684name = "no-intent"
685
686[pipeline]
687intent = false
688relevance = false
689"#;
690 let p: Profile = toml::from_str(toml_str).unwrap();
691 assert!(!p.pipeline.intent);
692 assert!(!p.pipeline.relevance);
693 assert!(p.pipeline.compression);
694 assert!(p.pipeline.translation);
695 }
696
697 #[test]
698 fn partial_toml_fills_defaults() {
699 let toml_str = r#"
700[profile]
701name = "minimal"
702
703[read]
704default_mode = "entropy"
705"#;
706 let p: Profile = toml::from_str(toml_str).unwrap();
707 assert_eq!(p.read.default_mode, "entropy");
708 assert_eq!(p.read.max_tokens_per_file, 50_000);
709 assert_eq!(p.budget.max_context_tokens, 200_000);
710 assert_eq!(p.compression.crp_mode, "tdd");
711 }
712}