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