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 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/// Profile identity and inheritance.
44#[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/// Read behavior configuration.
54#[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/// Compression strategy configuration.
82#[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/// Token and cost budget limits.
113#[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/// Pipeline layer activation per profile.
144#[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/// Autonomy overrides per profile.
172#[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
193// ── Built-in Profiles ──────────────────────────────────────
194
195fn 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
334/// Returns all built-in profile definitions.
335pub 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
349// ── Loading ────────────────────────────────────────────────
350
351fn 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
371/// Loads a profile by name with full resolution:
372/// 1. Project-local `.lean-ctx/profiles/<name>.toml`
373/// 2. Global `~/.lean-ctx/profiles/<name>.toml`
374/// 3. Built-in defaults
375///
376/// Applies inheritance chain (max depth 5 to prevent cycles).
377pub 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
423/// Merges parent into child: child values take precedence,
424/// parent provides defaults for unspecified fields.
425fn 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
445/// Returns the currently active profile name from env or default.
446pub 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
453/// Loads the currently active profile.
454pub fn active_profile() -> Profile {
455    let name = active_profile_name();
456    load_profile(&name).unwrap_or_else(builtin_exploration)
457}
458
459/// Sets the active profile for the current process by updating `LEAN_CTX_PROFILE`.
460///
461/// Returns the resolved profile after applying inheritance.
462pub 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
476/// Lists all available profile names (built-in + on-disk).
477pub 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/// Information about an available profile.
526#[derive(Debug, Clone)]
527pub struct ProfileInfo {
528    pub name: String,
529    pub description: String,
530    pub source: ProfileSource,
531}
532
533/// Where a profile was loaded from.
534#[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
551/// Formats a profile as TOML for display or file creation.
552pub fn format_as_toml(profile: &Profile) -> String {
553    toml::to_string_pretty(profile).unwrap_or_else(|_| "[error serializing profile]".to_string())
554}
555
556// ── Tests ──────────────────────────────────────────────────
557
558#[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}