Skip to main content

lean_ctx/core/
profiles.rs

1//! # Context Profiles
2//!
3//! Declarative, version-controlled context strategies ("Context as Code").
4//!
5//! Profiles configure how lean-ctx processes content for different scenarios:
6//! exploration, bugfixing, hotfixes, CI debugging, code review, etc.
7//!
8//! ## Resolution Order
9//!
10//! 1. `LEAN_CTX_PROFILE` env var
11//! 2. `.lean-ctx/profiles/<name>.toml` (project-local)
12//! 3. `~/.lean-ctx/profiles/<name>.toml` (global)
13//! 4. Built-in defaults (compiled into the binary)
14//!
15//! ## Inheritance
16//!
17//! Profiles can inherit from other profiles via `inherits = "parent"`.
18//! Child values override parent values; unset fields fall through.
19
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23
24/// A complete context profile definition.
25#[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/// Profile identity and inheritance.
42#[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/// Read behavior configuration.
52#[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/// Compression strategy configuration.
80#[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/// Token and cost budget limits.
111#[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/// Pipeline layer activation per profile.
142#[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/// Autonomy overrides per profile.
170#[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
191// ── Built-in Profiles ──────────────────────────────────────
192
193fn 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
327/// Returns all built-in profile definitions.
328pub 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
342// ── Loading ────────────────────────────────────────────────
343
344fn 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
364/// Loads a profile by name with full resolution:
365/// 1. Project-local `.lean-ctx/profiles/<name>.toml`
366/// 2. Global `~/.lean-ctx/profiles/<name>.toml`
367/// 3. Built-in defaults
368///
369/// Applies inheritance chain (max depth 5 to prevent cycles).
370pub 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
416/// Merges parent into child: child values take precedence,
417/// parent provides defaults for unspecified fields.
418fn 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
437/// Returns the currently active profile name from env or default.
438pub 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
445/// Loads the currently active profile.
446pub fn active_profile() -> Profile {
447    let name = active_profile_name();
448    load_profile(&name).unwrap_or_else(builtin_exploration)
449}
450
451/// Sets the active profile for the current process by updating `LEAN_CTX_PROFILE`.
452///
453/// Returns the resolved profile after applying inheritance.
454pub 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
464/// Lists all available profile names (built-in + on-disk).
465pub 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/// Information about an available profile.
514#[derive(Debug, Clone)]
515pub struct ProfileInfo {
516    pub name: String,
517    pub description: String,
518    pub source: ProfileSource,
519}
520
521/// Where a profile was loaded from.
522#[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
539/// Formats a profile as TOML for display or file creation.
540pub fn format_as_toml(profile: &Profile) -> String {
541    toml::to_string_pretty(profile).unwrap_or_else(|_| "[error serializing profile]".to_string())
542}
543
544// ── Tests ──────────────────────────────────────────────────
545
546#[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}