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 translation: TranslationConfig,
35    #[serde(default)]
36    pub layout: LayoutConfig,
37    #[serde(default)]
38    pub memory: crate::core::memory_policy::MemoryPolicyOverrides,
39    #[serde(default)]
40    pub verification: crate::core::output_verification::VerificationConfig,
41    #[serde(default)]
42    pub budget: BudgetConfig,
43    #[serde(default)]
44    pub pipeline: PipelineConfig,
45    #[serde(default)]
46    pub routing: RoutingConfig,
47    #[serde(default)]
48    pub degradation: DegradationConfig,
49    #[serde(default)]
50    pub autonomy: ProfileAutonomy,
51    #[serde(default)]
52    pub output_hints: OutputHints,
53}
54
55/// Profile identity and inheritance.
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57pub struct ProfileMeta {
58    #[serde(default)]
59    pub name: String,
60    pub inherits: Option<String>,
61    #[serde(default)]
62    pub description: String,
63}
64
65/// Read behavior configuration.
66///
67/// Fields are `Option<T>` for field-level profile inheritance.
68/// Use `_effective()` methods to get the resolved value with defaults.
69#[derive(Debug, Clone, Serialize, Deserialize, Default)]
70#[serde(default)]
71pub struct ReadConfig {
72    pub default_mode: Option<String>,
73    pub max_tokens_per_file: Option<usize>,
74    pub prefer_cache: Option<bool>,
75}
76
77impl ReadConfig {
78    pub fn default_mode_effective(&self) -> &str {
79        self.default_mode.as_deref().unwrap_or("auto")
80    }
81    pub fn max_tokens_per_file_effective(&self) -> usize {
82        self.max_tokens_per_file.unwrap_or(50_000)
83    }
84    pub fn prefer_cache_effective(&self) -> bool {
85        self.prefer_cache.unwrap_or(false)
86    }
87}
88
89/// Compression strategy configuration.
90#[derive(Debug, Clone, Serialize, Deserialize, Default)]
91#[serde(default)]
92pub struct CompressionConfig {
93    pub crp_mode: Option<String>,
94    pub output_density: Option<String>,
95    pub entropy_threshold: Option<f64>,
96    pub terse_mode: Option<bool>,
97}
98
99impl CompressionConfig {
100    pub fn crp_mode_effective(&self) -> &str {
101        self.crp_mode.as_deref().unwrap_or("tdd")
102    }
103    pub fn output_density_effective(&self) -> &str {
104        self.output_density.as_deref().unwrap_or("normal")
105    }
106    pub fn entropy_threshold_effective(&self) -> f64 {
107        self.entropy_threshold.unwrap_or(0.3)
108    }
109    pub fn terse_mode_effective(&self) -> bool {
110        self.terse_mode.unwrap_or(false)
111    }
112}
113
114/// Translation (tokenizer-aware) configuration.
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116#[serde(default)]
117pub struct TranslationConfig {
118    /// If false, preserve legacy CRP/TDD formats without post-translation.
119    pub enabled: Option<bool>,
120    /// legacy|ascii|auto
121    pub ruleset: Option<String>,
122}
123
124impl TranslationConfig {
125    pub fn enabled_effective(&self) -> bool {
126        self.enabled.unwrap_or(false)
127    }
128    pub fn ruleset_effective(&self) -> &str {
129        self.ruleset.as_deref().unwrap_or("legacy")
130    }
131}
132
133/// Layout (attention-aware reorder) configuration.
134#[derive(Debug, Clone, Serialize, Deserialize, Default)]
135#[serde(default)]
136pub struct LayoutConfig {
137    /// If false, preserve original order.
138    pub enabled: Option<bool>,
139    /// Minimum line count for enabling reorder.
140    pub min_lines: Option<usize>,
141}
142
143impl LayoutConfig {
144    pub fn enabled_effective(&self) -> bool {
145        self.enabled.unwrap_or(false)
146    }
147    pub fn min_lines_effective(&self) -> usize {
148        self.min_lines.unwrap_or(15)
149    }
150}
151
152/// Routing policy overrides (intent → model tier → read mode/budgets).
153#[derive(Debug, Clone, Serialize, Deserialize, Default)]
154pub struct RoutingConfig {
155    /// Hard cap for recommended model tier: fast|standard|premium.
156    #[serde(default)]
157    pub max_model_tier: Option<String>,
158    /// If true, apply deterministic routing degradation under budget/pressure.
159    #[serde(default)]
160    pub degrade_under_pressure: Option<bool>,
161}
162
163impl RoutingConfig {
164    pub fn max_model_tier_effective(&self) -> &str {
165        self.max_model_tier.as_deref().unwrap_or("premium")
166    }
167
168    pub fn degrade_under_pressure_effective(&self) -> bool {
169        self.degrade_under_pressure.unwrap_or(true)
170    }
171}
172
173/// Budget/SLO degradation policy configuration.
174#[derive(Debug, Clone, Serialize, Deserialize, Default)]
175pub struct DegradationConfig {
176    /// If true, enforce throttling/blocking decisions. Default is warn-only.
177    #[serde(default)]
178    pub enforce: Option<bool>,
179    /// Throttle duration (ms) when policy verdict is Throttle. Default: 250ms.
180    #[serde(default)]
181    pub throttle_ms: Option<u64>,
182}
183
184impl DegradationConfig {
185    pub fn enforce_effective(&self) -> bool {
186        self.enforce.unwrap_or(false)
187    }
188
189    pub fn throttle_ms_effective(&self) -> u64 {
190        self.throttle_ms.unwrap_or(250)
191    }
192}
193
194/// Controls which optional hints/footers are appended to tool output.
195/// All default to `false` for minimal output overhead.
196#[derive(Debug, Clone, Default, Serialize, Deserialize)]
197#[serde(default)]
198pub struct OutputHints {
199    pub compressed_hint: Option<bool>,
200    pub archive_hint: Option<bool>,
201    pub verify_footer: Option<bool>,
202    pub related_hint: Option<bool>,
203    pub semantic_hint: Option<bool>,
204    pub elicitation_hint: Option<bool>,
205    pub checkpoint_in_output: Option<bool>,
206    pub graph_context_block: Option<bool>,
207    pub efficiency_hint: Option<bool>,
208}
209
210impl OutputHints {
211    pub fn compressed_hint(&self) -> bool {
212        self.compressed_hint.unwrap_or(false)
213    }
214    pub fn archive_hint(&self) -> bool {
215        self.archive_hint.unwrap_or(false)
216    }
217    pub fn verify_footer(&self) -> bool {
218        self.verify_footer.unwrap_or(false)
219    }
220    pub fn related_hint(&self) -> bool {
221        self.related_hint.unwrap_or(false)
222    }
223    pub fn semantic_hint(&self) -> bool {
224        self.semantic_hint.unwrap_or(false)
225    }
226    pub fn elicitation_hint(&self) -> bool {
227        self.elicitation_hint.unwrap_or(false)
228    }
229    pub fn checkpoint_in_output(&self) -> bool {
230        self.checkpoint_in_output.unwrap_or(false)
231    }
232    pub fn graph_context_block(&self) -> bool {
233        self.graph_context_block.unwrap_or(false)
234    }
235    pub fn efficiency_hint(&self) -> bool {
236        self.efficiency_hint.unwrap_or(false)
237    }
238}
239
240/// Token and cost budget limits.
241#[derive(Debug, Clone, Serialize, Deserialize, Default)]
242#[serde(default)]
243pub struct BudgetConfig {
244    pub max_context_tokens: Option<usize>,
245    pub max_shell_invocations: Option<usize>,
246    pub max_cost_usd: Option<f64>,
247}
248
249impl BudgetConfig {
250    pub fn max_context_tokens_effective(&self) -> usize {
251        self.max_context_tokens.unwrap_or(200_000)
252    }
253    pub fn max_shell_invocations_effective(&self) -> usize {
254        self.max_shell_invocations.unwrap_or(100)
255    }
256    pub fn max_cost_usd_effective(&self) -> f64 {
257        self.max_cost_usd.unwrap_or(5.0)
258    }
259}
260
261/// Pipeline layer activation per profile.
262#[derive(Debug, Clone, Serialize, Deserialize, Default)]
263#[serde(default)]
264pub struct PipelineConfig {
265    pub intent: Option<bool>,
266    pub relevance: Option<bool>,
267    pub compression: Option<bool>,
268    pub translation: Option<bool>,
269}
270
271impl PipelineConfig {
272    pub fn intent_effective(&self) -> bool {
273        self.intent.unwrap_or(true)
274    }
275    pub fn relevance_effective(&self) -> bool {
276        self.relevance.unwrap_or(true)
277    }
278    pub fn compression_effective(&self) -> bool {
279        self.compression.unwrap_or(true)
280    }
281    pub fn translation_effective(&self) -> bool {
282        self.translation.unwrap_or(true)
283    }
284}
285
286/// Autonomy overrides per profile.
287#[derive(Debug, Clone, Serialize, Deserialize, Default)]
288#[serde(default)]
289pub struct ProfileAutonomy {
290    pub enabled: Option<bool>,
291    pub auto_preload: Option<bool>,
292    pub auto_dedup: Option<bool>,
293    pub auto_related: Option<bool>,
294    pub silent_preload: Option<bool>,
295    /// Enable bounded prefetch after reads (opt-in by default).
296    pub auto_prefetch: Option<bool>,
297    /// Enable response shaping for large outputs (opt-in by default).
298    pub auto_response: Option<bool>,
299    pub dedup_threshold: Option<usize>,
300    pub prefetch_max_files: Option<usize>,
301    pub prefetch_budget_tokens: Option<usize>,
302    pub response_min_tokens: Option<usize>,
303    pub checkpoint_interval: Option<u32>,
304}
305
306impl ProfileAutonomy {
307    pub fn enabled_effective(&self) -> bool {
308        self.enabled.unwrap_or(true)
309    }
310    pub fn auto_preload_effective(&self) -> bool {
311        self.auto_preload.unwrap_or(true)
312    }
313    pub fn auto_dedup_effective(&self) -> bool {
314        self.auto_dedup.unwrap_or(true)
315    }
316    pub fn auto_related_effective(&self) -> bool {
317        self.auto_related.unwrap_or(true)
318    }
319    pub fn silent_preload_effective(&self) -> bool {
320        self.silent_preload.unwrap_or(true)
321    }
322    pub fn auto_prefetch_effective(&self) -> bool {
323        self.auto_prefetch.unwrap_or(false)
324    }
325    pub fn auto_response_effective(&self) -> bool {
326        self.auto_response.unwrap_or(false)
327    }
328    pub fn dedup_threshold_effective(&self) -> usize {
329        self.dedup_threshold.unwrap_or(8)
330    }
331    pub fn prefetch_max_files_effective(&self) -> usize {
332        self.prefetch_max_files.unwrap_or(3)
333    }
334    pub fn prefetch_budget_tokens_effective(&self) -> usize {
335        self.prefetch_budget_tokens.unwrap_or(4000)
336    }
337    pub fn response_min_tokens_effective(&self) -> usize {
338        self.response_min_tokens.unwrap_or(600)
339    }
340    pub fn checkpoint_interval_effective(&self) -> u32 {
341        self.checkpoint_interval.unwrap_or(15)
342    }
343}
344
345// ── Built-in Profiles ──────────────────────────────────────
346
347fn builtin_coder() -> Profile {
348    Profile {
349        profile: ProfileMeta {
350            name: "coder".to_string(),
351            inherits: None,
352            description: "Default coding workflow with guarded autonomy drivers".to_string(),
353        },
354        read: ReadConfig {
355            default_mode: Some("auto".to_string()),
356            max_tokens_per_file: Some(50_000),
357            prefer_cache: Some(true),
358        },
359        compression: CompressionConfig {
360            crp_mode: Some("tdd".to_string()),
361            output_density: Some("terse".to_string()),
362            terse_mode: Some(true),
363            ..CompressionConfig::default()
364        },
365        translation: TranslationConfig {
366            enabled: Some(true),
367            ruleset: Some("auto".to_string()),
368        },
369        layout: LayoutConfig::default(),
370        memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
371        verification: crate::core::output_verification::VerificationConfig::default(),
372        budget: BudgetConfig {
373            max_context_tokens: Some(150_000),
374            max_shell_invocations: Some(100),
375            ..BudgetConfig::default()
376        },
377        pipeline: PipelineConfig::default(),
378        routing: RoutingConfig::default(),
379        degradation: DegradationConfig::default(),
380        autonomy: ProfileAutonomy {
381            auto_prefetch: Some(true),
382            auto_response: Some(true),
383            checkpoint_interval: Some(10),
384            ..ProfileAutonomy::default()
385        },
386        output_hints: OutputHints::default(),
387    }
388}
389
390fn builtin_exploration() -> Profile {
391    Profile {
392        profile: ProfileMeta {
393            name: "exploration".to_string(),
394            inherits: None,
395            description: "Broad context for understanding codebases".to_string(),
396        },
397        read: ReadConfig {
398            default_mode: Some("map".to_string()),
399            max_tokens_per_file: Some(80_000),
400            prefer_cache: Some(true),
401        },
402        compression: CompressionConfig {
403            terse_mode: Some(true),
404            output_density: Some("terse".to_string()),
405            ..CompressionConfig::default()
406        },
407        translation: TranslationConfig::default(),
408        layout: LayoutConfig::default(),
409        memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
410        verification: crate::core::output_verification::VerificationConfig::default(),
411        budget: BudgetConfig {
412            max_context_tokens: Some(200_000),
413            ..BudgetConfig::default()
414        },
415        pipeline: PipelineConfig::default(),
416        routing: RoutingConfig::default(),
417        degradation: DegradationConfig::default(),
418        autonomy: ProfileAutonomy::default(),
419        output_hints: OutputHints {
420            related_hint: Some(true),
421            compressed_hint: Some(true),
422            ..OutputHints::default()
423        },
424    }
425}
426
427fn builtin_bugfix() -> Profile {
428    Profile {
429        profile: ProfileMeta {
430            name: "bugfix".to_string(),
431            inherits: None,
432            description: "Focused context for debugging specific issues".to_string(),
433        },
434        read: ReadConfig {
435            default_mode: Some("auto".to_string()),
436            max_tokens_per_file: Some(30_000),
437            prefer_cache: Some(false),
438        },
439        compression: CompressionConfig {
440            crp_mode: Some("tdd".to_string()),
441            output_density: Some("terse".to_string()),
442            ..CompressionConfig::default()
443        },
444        translation: TranslationConfig::default(),
445        layout: LayoutConfig::default(),
446        memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
447        verification: crate::core::output_verification::VerificationConfig::default(),
448        budget: BudgetConfig {
449            max_context_tokens: Some(100_000),
450            max_shell_invocations: Some(50),
451            ..BudgetConfig::default()
452        },
453        pipeline: PipelineConfig::default(),
454        routing: RoutingConfig {
455            max_model_tier: Some("standard".to_string()),
456            ..RoutingConfig::default()
457        },
458        degradation: DegradationConfig::default(),
459        autonomy: ProfileAutonomy {
460            checkpoint_interval: Some(10),
461            ..ProfileAutonomy::default()
462        },
463        output_hints: OutputHints::default(),
464    }
465}
466
467fn builtin_hotfix() -> Profile {
468    Profile {
469        profile: ProfileMeta {
470            name: "hotfix".to_string(),
471            inherits: None,
472            description: "Minimal context, fast iteration for urgent fixes".to_string(),
473        },
474        read: ReadConfig {
475            default_mode: Some("signatures".to_string()),
476            max_tokens_per_file: Some(2_000),
477            prefer_cache: Some(true),
478        },
479        compression: CompressionConfig {
480            crp_mode: Some("tdd".to_string()),
481            output_density: Some("ultra".to_string()),
482            ..CompressionConfig::default()
483        },
484        translation: TranslationConfig::default(),
485        layout: LayoutConfig::default(),
486        memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
487        verification: crate::core::output_verification::VerificationConfig::default(),
488        budget: BudgetConfig {
489            max_context_tokens: Some(30_000),
490            max_shell_invocations: Some(20),
491            max_cost_usd: Some(1.0),
492        },
493        pipeline: PipelineConfig::default(),
494        routing: RoutingConfig {
495            max_model_tier: Some("fast".to_string()),
496            ..RoutingConfig::default()
497        },
498        degradation: DegradationConfig::default(),
499        autonomy: ProfileAutonomy {
500            checkpoint_interval: Some(5),
501            ..ProfileAutonomy::default()
502        },
503        output_hints: OutputHints::default(),
504    }
505}
506
507fn builtin_ci_debug() -> Profile {
508    Profile {
509        profile: ProfileMeta {
510            name: "ci-debug".to_string(),
511            inherits: None,
512            description: "CI/CD debugging with shell-heavy workflows".to_string(),
513        },
514        read: ReadConfig {
515            default_mode: Some("auto".to_string()),
516            max_tokens_per_file: Some(50_000),
517            prefer_cache: Some(false),
518        },
519        compression: CompressionConfig {
520            output_density: Some("terse".to_string()),
521            ..CompressionConfig::default()
522        },
523        translation: TranslationConfig::default(),
524        layout: LayoutConfig::default(),
525        memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
526        verification: crate::core::output_verification::VerificationConfig::default(),
527        budget: BudgetConfig {
528            max_context_tokens: Some(150_000),
529            max_shell_invocations: Some(200),
530            ..BudgetConfig::default()
531        },
532        pipeline: PipelineConfig::default(),
533        routing: RoutingConfig {
534            max_model_tier: Some("standard".to_string()),
535            ..RoutingConfig::default()
536        },
537        degradation: DegradationConfig::default(),
538        autonomy: ProfileAutonomy::default(),
539        output_hints: OutputHints::default(),
540    }
541}
542
543fn builtin_review() -> Profile {
544    Profile {
545        profile: ProfileMeta {
546            name: "review".to_string(),
547            inherits: None,
548            description: "Code review with broad read-only context".to_string(),
549        },
550        read: ReadConfig {
551            default_mode: Some("map".to_string()),
552            max_tokens_per_file: Some(60_000),
553            prefer_cache: Some(true),
554        },
555        compression: CompressionConfig {
556            crp_mode: Some("compact".to_string()),
557            ..CompressionConfig::default()
558        },
559        translation: TranslationConfig::default(),
560        layout: LayoutConfig {
561            enabled: Some(true),
562            ..LayoutConfig::default()
563        },
564        memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
565        verification: crate::core::output_verification::VerificationConfig::default(),
566        budget: BudgetConfig {
567            max_context_tokens: Some(150_000),
568            max_shell_invocations: Some(30),
569            ..BudgetConfig::default()
570        },
571        pipeline: PipelineConfig::default(),
572        routing: RoutingConfig {
573            max_model_tier: Some("standard".to_string()),
574            ..RoutingConfig::default()
575        },
576        degradation: DegradationConfig::default(),
577        autonomy: ProfileAutonomy::default(),
578        output_hints: OutputHints {
579            verify_footer: Some(true),
580            related_hint: Some(true),
581            compressed_hint: Some(true),
582            ..OutputHints::default()
583        },
584    }
585}
586
587fn builtin_passthrough() -> Profile {
588    Profile {
589        profile: ProfileMeta {
590            name: "passthrough".to_string(),
591            inherits: None,
592            description: "No output modification — always full content, no compression".to_string(),
593        },
594        read: ReadConfig {
595            default_mode: Some("full".to_string()),
596            max_tokens_per_file: Some(10_000_000),
597            prefer_cache: Some(false),
598        },
599        compression: CompressionConfig {
600            crp_mode: Some("off".to_string()),
601            output_density: Some("normal".to_string()),
602            entropy_threshold: None,
603            terse_mode: Some(false),
604        },
605        translation: TranslationConfig {
606            enabled: Some(false),
607            ..TranslationConfig::default()
608        },
609        layout: LayoutConfig::default(),
610        memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
611        verification: crate::core::output_verification::VerificationConfig::default(),
612        budget: BudgetConfig {
613            max_context_tokens: Some(1_000_000),
614            ..BudgetConfig::default()
615        },
616        pipeline: PipelineConfig {
617            intent: Some(false),
618            relevance: Some(false),
619            compression: Some(false),
620            translation: Some(false),
621        },
622        routing: RoutingConfig::default(),
623        degradation: DegradationConfig {
624            enforce: Some(false),
625            ..DegradationConfig::default()
626        },
627        autonomy: ProfileAutonomy::default(),
628        output_hints: OutputHints::default(),
629    }
630}
631
632/// Returns all built-in profile definitions.
633pub fn builtin_profiles() -> HashMap<String, Profile> {
634    let mut map = HashMap::new();
635    for p in [
636        builtin_coder(),
637        builtin_exploration(),
638        builtin_bugfix(),
639        builtin_hotfix(),
640        builtin_ci_debug(),
641        builtin_review(),
642        builtin_passthrough(),
643    ] {
644        map.insert(p.profile.name.clone(), p);
645    }
646    map
647}
648
649// ── Loading ────────────────────────────────────────────────
650
651fn profiles_dir_global() -> Option<PathBuf> {
652    crate::core::data_dir::lean_ctx_data_dir()
653        .ok()
654        .map(|d| d.join("profiles"))
655}
656
657fn profiles_dir_project() -> Option<PathBuf> {
658    let mut current = std::env::current_dir().ok()?;
659    for _ in 0..12 {
660        let candidate = current.join(".lean-ctx").join("profiles");
661        if candidate.is_dir() {
662            return Some(candidate);
663        }
664        if !current.pop() {
665            break;
666        }
667    }
668    None
669}
670
671/// Loads a profile by name with full resolution:
672/// 1. Project-local `.lean-ctx/profiles/<name>.toml`
673/// 2. Global `~/.lean-ctx/profiles/<name>.toml`
674/// 3. Built-in defaults
675///
676/// Applies inheritance chain (max depth 5 to prevent cycles).
677pub fn load_profile(name: &str) -> Option<Profile> {
678    load_profile_recursive(name, 0)
679}
680
681fn load_profile_recursive(name: &str, depth: usize) -> Option<Profile> {
682    if depth > 5 {
683        return None;
684    }
685
686    let mut profile = load_profile_from_disk(name).or_else(|| builtin_profiles().remove(name))?;
687    profile.profile.name = name.to_string();
688
689    if let Some(ref parent_name) = profile.profile.inherits.clone() {
690        if let Some(parent) = load_profile_recursive(parent_name, depth + 1) {
691            profile = merge_profiles(parent, profile);
692        }
693    }
694
695    Some(profile)
696}
697
698fn load_profile_from_disk(name: &str) -> Option<Profile> {
699    let filename = format!("{name}.toml");
700
701    if let Some(project_dir) = profiles_dir_project() {
702        let path = project_dir.join(&filename);
703        if let Some(p) = try_load_toml(&path) {
704            return Some(p);
705        }
706    }
707
708    if let Some(global_dir) = profiles_dir_global() {
709        let path = global_dir.join(&filename);
710        if let Some(p) = try_load_toml(&path) {
711            return Some(p);
712        }
713    }
714
715    None
716}
717
718fn try_load_toml(path: &Path) -> Option<Profile> {
719    let content = std::fs::read_to_string(path).ok()?;
720    toml::from_str(&content).ok()
721}
722
723/// Merges parent into child: child values take precedence,
724/// parent provides defaults for unspecified fields.
725///
726/// ALL sections are merged field-by-field using `Option::or()`.
727/// A child profile only needs to set the fields it wants to override.
728fn merge_profiles(parent: Profile, child: Profile) -> Profile {
729    let read = ReadConfig {
730        default_mode: child.read.default_mode.or(parent.read.default_mode),
731        max_tokens_per_file: child
732            .read
733            .max_tokens_per_file
734            .or(parent.read.max_tokens_per_file),
735        prefer_cache: child.read.prefer_cache.or(parent.read.prefer_cache),
736    };
737    let compression = CompressionConfig {
738        crp_mode: child.compression.crp_mode.or(parent.compression.crp_mode),
739        output_density: child
740            .compression
741            .output_density
742            .or(parent.compression.output_density),
743        entropy_threshold: child
744            .compression
745            .entropy_threshold
746            .or(parent.compression.entropy_threshold),
747        terse_mode: child
748            .compression
749            .terse_mode
750            .or(parent.compression.terse_mode),
751    };
752    let translation = TranslationConfig {
753        enabled: child.translation.enabled.or(parent.translation.enabled),
754        ruleset: child.translation.ruleset.or(parent.translation.ruleset),
755    };
756    let layout = LayoutConfig {
757        enabled: child.layout.enabled.or(parent.layout.enabled),
758        min_lines: child.layout.min_lines.or(parent.layout.min_lines),
759    };
760    let memory = crate::core::memory_policy::MemoryPolicyOverrides {
761        knowledge: crate::core::memory_policy::KnowledgePolicyOverrides {
762            max_facts: child
763                .memory
764                .knowledge
765                .max_facts
766                .or(parent.memory.knowledge.max_facts),
767            max_patterns: child
768                .memory
769                .knowledge
770                .max_patterns
771                .or(parent.memory.knowledge.max_patterns),
772            max_history: child
773                .memory
774                .knowledge
775                .max_history
776                .or(parent.memory.knowledge.max_history),
777            contradiction_threshold: child
778                .memory
779                .knowledge
780                .contradiction_threshold
781                .or(parent.memory.knowledge.contradiction_threshold),
782            recall_facts_limit: child
783                .memory
784                .knowledge
785                .recall_facts_limit
786                .or(parent.memory.knowledge.recall_facts_limit),
787            rooms_limit: child
788                .memory
789                .knowledge
790                .rooms_limit
791                .or(parent.memory.knowledge.rooms_limit),
792            timeline_limit: child
793                .memory
794                .knowledge
795                .timeline_limit
796                .or(parent.memory.knowledge.timeline_limit),
797            relations_limit: child
798                .memory
799                .knowledge
800                .relations_limit
801                .or(parent.memory.knowledge.relations_limit),
802        },
803        lifecycle: crate::core::memory_policy::LifecyclePolicyOverrides {
804            decay_rate: child
805                .memory
806                .lifecycle
807                .decay_rate
808                .or(parent.memory.lifecycle.decay_rate),
809            low_confidence_threshold: child
810                .memory
811                .lifecycle
812                .low_confidence_threshold
813                .or(parent.memory.lifecycle.low_confidence_threshold),
814            stale_days: child
815                .memory
816                .lifecycle
817                .stale_days
818                .or(parent.memory.lifecycle.stale_days),
819            similarity_threshold: child
820                .memory
821                .lifecycle
822                .similarity_threshold
823                .or(parent.memory.lifecycle.similarity_threshold),
824        },
825    };
826    let verification = crate::core::output_verification::VerificationConfig {
827        enabled: child.verification.enabled.or(parent.verification.enabled),
828        mode: child.verification.mode.or(parent.verification.mode),
829        strict_mode: child
830            .verification
831            .strict_mode
832            .or(parent.verification.strict_mode),
833        check_paths: child
834            .verification
835            .check_paths
836            .or(parent.verification.check_paths),
837        check_identifiers: child
838            .verification
839            .check_identifiers
840            .or(parent.verification.check_identifiers),
841        check_line_numbers: child
842            .verification
843            .check_line_numbers
844            .or(parent.verification.check_line_numbers),
845        check_structure: child
846            .verification
847            .check_structure
848            .or(parent.verification.check_structure),
849    };
850    let budget = BudgetConfig {
851        max_context_tokens: child
852            .budget
853            .max_context_tokens
854            .or(parent.budget.max_context_tokens),
855        max_shell_invocations: child
856            .budget
857            .max_shell_invocations
858            .or(parent.budget.max_shell_invocations),
859        max_cost_usd: child.budget.max_cost_usd.or(parent.budget.max_cost_usd),
860    };
861    let pipeline = PipelineConfig {
862        intent: child.pipeline.intent.or(parent.pipeline.intent),
863        relevance: child.pipeline.relevance.or(parent.pipeline.relevance),
864        compression: child.pipeline.compression.or(parent.pipeline.compression),
865        translation: child.pipeline.translation.or(parent.pipeline.translation),
866    };
867    let routing = RoutingConfig {
868        max_model_tier: child
869            .routing
870            .max_model_tier
871            .or(parent.routing.max_model_tier),
872        degrade_under_pressure: child
873            .routing
874            .degrade_under_pressure
875            .or(parent.routing.degrade_under_pressure),
876    };
877    let degradation = DegradationConfig {
878        enforce: child.degradation.enforce.or(parent.degradation.enforce),
879        throttle_ms: child
880            .degradation
881            .throttle_ms
882            .or(parent.degradation.throttle_ms),
883    };
884    let autonomy = ProfileAutonomy {
885        enabled: child.autonomy.enabled.or(parent.autonomy.enabled),
886        auto_preload: child.autonomy.auto_preload.or(parent.autonomy.auto_preload),
887        auto_dedup: child.autonomy.auto_dedup.or(parent.autonomy.auto_dedup),
888        auto_related: child.autonomy.auto_related.or(parent.autonomy.auto_related),
889        silent_preload: child
890            .autonomy
891            .silent_preload
892            .or(parent.autonomy.silent_preload),
893        auto_prefetch: child
894            .autonomy
895            .auto_prefetch
896            .or(parent.autonomy.auto_prefetch),
897        auto_response: child
898            .autonomy
899            .auto_response
900            .or(parent.autonomy.auto_response),
901        dedup_threshold: child
902            .autonomy
903            .dedup_threshold
904            .or(parent.autonomy.dedup_threshold),
905        prefetch_max_files: child
906            .autonomy
907            .prefetch_max_files
908            .or(parent.autonomy.prefetch_max_files),
909        prefetch_budget_tokens: child
910            .autonomy
911            .prefetch_budget_tokens
912            .or(parent.autonomy.prefetch_budget_tokens),
913        response_min_tokens: child
914            .autonomy
915            .response_min_tokens
916            .or(parent.autonomy.response_min_tokens),
917        checkpoint_interval: child
918            .autonomy
919            .checkpoint_interval
920            .or(parent.autonomy.checkpoint_interval),
921    };
922    let output_hints = OutputHints {
923        compressed_hint: child
924            .output_hints
925            .compressed_hint
926            .or(parent.output_hints.compressed_hint),
927        archive_hint: child
928            .output_hints
929            .archive_hint
930            .or(parent.output_hints.archive_hint),
931        verify_footer: child
932            .output_hints
933            .verify_footer
934            .or(parent.output_hints.verify_footer),
935        related_hint: child
936            .output_hints
937            .related_hint
938            .or(parent.output_hints.related_hint),
939        semantic_hint: child
940            .output_hints
941            .semantic_hint
942            .or(parent.output_hints.semantic_hint),
943        elicitation_hint: child
944            .output_hints
945            .elicitation_hint
946            .or(parent.output_hints.elicitation_hint),
947        checkpoint_in_output: child
948            .output_hints
949            .checkpoint_in_output
950            .or(parent.output_hints.checkpoint_in_output),
951        graph_context_block: child
952            .output_hints
953            .graph_context_block
954            .or(parent.output_hints.graph_context_block),
955        efficiency_hint: child
956            .output_hints
957            .efficiency_hint
958            .or(parent.output_hints.efficiency_hint),
959    };
960    Profile {
961        profile: ProfileMeta {
962            name: child.profile.name,
963            inherits: child.profile.inherits,
964            description: if child.profile.description.is_empty() {
965                parent.profile.description
966            } else {
967                child.profile.description
968            },
969        },
970        read,
971        compression,
972        translation,
973        layout,
974        memory,
975        verification,
976        budget,
977        pipeline,
978        routing,
979        degradation,
980        autonomy,
981        output_hints,
982    }
983}
984
985/// Reads the `profile` key directly from `config.toml` without going through
986/// `Config::load()`. This avoids a reentrancy deadlock: `Config::load()` →
987/// `find_project_root()` (OnceLock) → `SessionState::load_latest()` →
988/// `normalize_loaded_session()` → `active_profile()` → here → `Config::load()`.
989fn profile_name_from_config_file() -> Option<String> {
990    let path = crate::core::config::Config::path()?;
991    let content = std::fs::read_to_string(path).ok()?;
992    let table: toml::Table = toml::from_str(&content).ok()?;
993    table
994        .get("profile")?
995        .as_str()
996        .map(str::trim)
997        .filter(|s| !s.is_empty())
998        .map(String::from)
999}
1000
1001/// Returns the currently active profile name.
1002/// Resolution order: LEAN_CTX_PROFILE env var → config.toml `profile` field → "coder".
1003pub fn active_profile_name() -> String {
1004    if let Ok(v) = std::env::var("LEAN_CTX_PROFILE") {
1005        let v = v.trim().to_string();
1006        if !v.is_empty() {
1007            return v;
1008        }
1009    }
1010    if let Some(name) = profile_name_from_config_file() {
1011        return name;
1012    }
1013    "coder".to_string()
1014}
1015
1016/// Loads the currently active profile.
1017pub fn active_profile() -> Profile {
1018    let name = active_profile_name();
1019    if let Some(p) = load_profile(&name) {
1020        p
1021    } else {
1022        if name != "coder" {
1023            tracing::warn!(
1024                "Profile '{name}' not found (no built-in or disk file). \
1025                 Falling back to 'coder'. Create it with: lean-ctx profile create {name}"
1026            );
1027        }
1028        builtin_coder()
1029    }
1030}
1031
1032/// Sets the active profile for the current process by updating `LEAN_CTX_PROFILE`.
1033///
1034/// Returns the resolved profile after applying inheritance.
1035pub fn set_active_profile(name: &str) -> Result<Profile, String> {
1036    let name = name.trim();
1037    if name.is_empty() {
1038        return Err("profile name is empty".to_string());
1039    }
1040    let prev = active_profile_name();
1041    let profile = load_profile(name).ok_or_else(|| format!("profile '{name}' not found"))?;
1042    std::env::set_var("LEAN_CTX_PROFILE", name);
1043    if prev != name {
1044        crate::core::events::emit_profile_changed(&prev, name);
1045    }
1046    Ok(profile)
1047}
1048
1049/// Lists all available profile names (built-in + on-disk).
1050pub fn list_profiles() -> Vec<ProfileInfo> {
1051    let mut profiles: HashMap<String, ProfileInfo> = HashMap::new();
1052
1053    for (name, p) in builtin_profiles() {
1054        profiles.insert(
1055            name.clone(),
1056            ProfileInfo {
1057                name,
1058                description: p.profile.description,
1059                source: ProfileSource::Builtin,
1060            },
1061        );
1062    }
1063
1064    for (source, dir) in [
1065        (ProfileSource::Global, profiles_dir_global()),
1066        (ProfileSource::Project, profiles_dir_project()),
1067    ] {
1068        if let Some(dir) = dir {
1069            if let Ok(entries) = std::fs::read_dir(&dir) {
1070                for entry in entries.flatten() {
1071                    let path = entry.path();
1072                    if path.extension().and_then(|e| e.to_str()) == Some("toml") {
1073                        if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
1074                            let name = stem.to_string();
1075                            let desc = try_load_toml(&path)
1076                                .map(|p| p.profile.description)
1077                                .unwrap_or_default();
1078                            profiles.insert(
1079                                name.clone(),
1080                                ProfileInfo {
1081                                    name,
1082                                    description: desc,
1083                                    source,
1084                                },
1085                            );
1086                        }
1087                    }
1088                }
1089            }
1090        }
1091    }
1092
1093    let mut result: Vec<ProfileInfo> = profiles.into_values().collect();
1094    result.sort_by_key(|p| p.name.clone());
1095    result
1096}
1097
1098/// Information about an available profile.
1099#[derive(Debug, Clone)]
1100pub struct ProfileInfo {
1101    pub name: String,
1102    pub description: String,
1103    pub source: ProfileSource,
1104}
1105
1106/// Where a profile was loaded from.
1107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1108pub enum ProfileSource {
1109    Builtin,
1110    Global,
1111    Project,
1112}
1113
1114impl std::fmt::Display for ProfileSource {
1115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1116        match self {
1117            Self::Builtin => write!(f, "built-in"),
1118            Self::Global => write!(f, "global"),
1119            Self::Project => write!(f, "project"),
1120        }
1121    }
1122}
1123
1124/// Formats a profile as TOML for display or file creation.
1125pub fn format_as_toml(profile: &Profile) -> String {
1126    toml::to_string_pretty(profile).unwrap_or_else(|_| "[error serializing profile]".to_string())
1127}
1128
1129// ── Tests ──────────────────────────────────────────────────
1130
1131#[cfg(test)]
1132mod tests {
1133    use super::*;
1134
1135    #[test]
1136    fn builtin_profiles_count() {
1137        let builtins = builtin_profiles();
1138        assert_eq!(builtins.len(), 7);
1139        assert!(builtins.contains_key("coder"));
1140        assert!(builtins.contains_key("exploration"));
1141        assert!(builtins.contains_key("bugfix"));
1142        assert!(builtins.contains_key("hotfix"));
1143        assert!(builtins.contains_key("ci-debug"));
1144        assert!(builtins.contains_key("review"));
1145        assert!(builtins.contains_key("passthrough"));
1146    }
1147
1148    #[test]
1149    fn hotfix_has_minimal_budget() {
1150        let p = builtin_profiles().remove("hotfix").unwrap();
1151        assert_eq!(p.budget.max_context_tokens_effective(), 30_000);
1152        assert_eq!(p.budget.max_shell_invocations_effective(), 20);
1153        assert_eq!(p.read.default_mode_effective(), "signatures");
1154        assert_eq!(p.compression.output_density_effective(), "ultra");
1155    }
1156
1157    #[test]
1158    fn exploration_has_broad_context() {
1159        let p = builtin_profiles().remove("exploration").unwrap();
1160        assert_eq!(p.budget.max_context_tokens_effective(), 200_000);
1161        assert_eq!(p.read.default_mode_effective(), "map");
1162        assert!(p.read.prefer_cache_effective());
1163    }
1164
1165    #[test]
1166    fn profile_roundtrip_toml() {
1167        let original = builtin_exploration();
1168        let toml_str = format_as_toml(&original);
1169        let parsed: Profile = toml::from_str(&toml_str).unwrap();
1170        assert_eq!(parsed.profile.name, "exploration");
1171        assert_eq!(parsed.read.default_mode_effective(), "map");
1172        assert_eq!(parsed.budget.max_context_tokens_effective(), 200_000);
1173    }
1174
1175    #[test]
1176    fn merge_child_overrides_parent() {
1177        let parent = builtin_exploration();
1178        let child = Profile {
1179            profile: ProfileMeta {
1180                name: "custom".to_string(),
1181                inherits: Some("exploration".to_string()),
1182                description: String::new(),
1183            },
1184            read: ReadConfig {
1185                default_mode: Some("signatures".to_string()),
1186                ..ReadConfig::default()
1187            },
1188            compression: CompressionConfig::default(),
1189            translation: TranslationConfig::default(),
1190            layout: LayoutConfig::default(),
1191            memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
1192            verification: crate::core::output_verification::VerificationConfig::default(),
1193            budget: BudgetConfig {
1194                max_context_tokens: Some(10_000),
1195                ..BudgetConfig::default()
1196            },
1197            pipeline: PipelineConfig::default(),
1198            routing: RoutingConfig::default(),
1199            degradation: DegradationConfig::default(),
1200            autonomy: ProfileAutonomy::default(),
1201            output_hints: OutputHints::default(),
1202        };
1203
1204        let merged = merge_profiles(parent, child);
1205        assert_eq!(merged.read.default_mode_effective(), "signatures");
1206        assert_eq!(merged.budget.max_context_tokens_effective(), 10_000);
1207        assert_eq!(
1208            merged.profile.description,
1209            "Broad context for understanding codebases"
1210        );
1211    }
1212
1213    #[test]
1214    fn merge_partial_child_inherits_parent_fields() {
1215        let parent = builtin_exploration();
1216        let child = Profile {
1217            profile: ProfileMeta {
1218                name: "partial".to_string(),
1219                inherits: Some("exploration".to_string()),
1220                description: String::new(),
1221            },
1222            read: ReadConfig {
1223                default_mode: Some("map".to_string()),
1224                ..ReadConfig::default()
1225            },
1226            compression: CompressionConfig::default(),
1227            translation: TranslationConfig::default(),
1228            layout: LayoutConfig::default(),
1229            memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
1230            verification: crate::core::output_verification::VerificationConfig::default(),
1231            budget: BudgetConfig::default(),
1232            pipeline: PipelineConfig::default(),
1233            routing: RoutingConfig::default(),
1234            degradation: DegradationConfig::default(),
1235            autonomy: ProfileAutonomy::default(),
1236            output_hints: OutputHints::default(),
1237        };
1238
1239        let merged = merge_profiles(parent, child);
1240        assert_eq!(merged.read.default_mode_effective(), "map");
1241        assert_eq!(
1242            merged.read.max_tokens_per_file_effective(),
1243            80_000,
1244            "should inherit max_tokens_per_file from parent"
1245        );
1246        assert!(
1247            merged.read.prefer_cache_effective(),
1248            "should inherit prefer_cache from parent"
1249        );
1250        assert_eq!(
1251            merged.budget.max_context_tokens_effective(),
1252            200_000,
1253            "should inherit budget from parent"
1254        );
1255    }
1256
1257    #[test]
1258    fn load_builtin_by_name() {
1259        let p = load_profile("hotfix").unwrap();
1260        assert_eq!(p.profile.name, "hotfix");
1261        assert_eq!(p.read.default_mode_effective(), "signatures");
1262    }
1263
1264    #[test]
1265    fn load_nonexistent_returns_none() {
1266        assert!(load_profile("does-not-exist-xyz").is_none());
1267    }
1268
1269    #[test]
1270    fn list_profiles_includes_builtins() {
1271        let list = list_profiles();
1272        assert!(list.len() >= 5);
1273        let names: Vec<&str> = list.iter().map(|p| p.name.as_str()).collect();
1274        assert!(names.contains(&"exploration"));
1275        assert!(names.contains(&"hotfix"));
1276        assert!(names.contains(&"review"));
1277    }
1278
1279    #[test]
1280    fn active_profile_defaults_to_coder() {
1281        let _lock = crate::core::data_dir::test_env_lock();
1282        std::env::remove_var("LEAN_CTX_PROFILE");
1283        let p = active_profile();
1284        assert_eq!(p.profile.name, "coder");
1285    }
1286
1287    #[test]
1288    fn active_profile_from_env() {
1289        let _lock = crate::core::data_dir::test_env_lock();
1290        std::env::set_var("LEAN_CTX_PROFILE", "hotfix");
1291        let name = active_profile_name();
1292        assert_eq!(name, "hotfix");
1293        std::env::remove_var("LEAN_CTX_PROFILE");
1294    }
1295
1296    #[test]
1297    fn profile_source_display() {
1298        assert_eq!(ProfileSource::Builtin.to_string(), "built-in");
1299        assert_eq!(ProfileSource::Global.to_string(), "global");
1300        assert_eq!(ProfileSource::Project.to_string(), "project");
1301    }
1302
1303    #[test]
1304    fn default_profile_has_sane_values() {
1305        let p = Profile {
1306            profile: ProfileMeta::default(),
1307            read: ReadConfig::default(),
1308            compression: CompressionConfig::default(),
1309            translation: TranslationConfig::default(),
1310            layout: LayoutConfig::default(),
1311            memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
1312            verification: crate::core::output_verification::VerificationConfig::default(),
1313            budget: BudgetConfig::default(),
1314            pipeline: PipelineConfig::default(),
1315            routing: RoutingConfig::default(),
1316            degradation: DegradationConfig::default(),
1317            autonomy: ProfileAutonomy::default(),
1318            output_hints: OutputHints::default(),
1319        };
1320        assert_eq!(p.read.default_mode_effective(), "auto");
1321        assert_eq!(p.compression.crp_mode_effective(), "tdd");
1322        assert_eq!(p.budget.max_context_tokens_effective(), 200_000);
1323        assert!(p.pipeline.compression_effective());
1324        assert!(p.pipeline.intent_effective());
1325    }
1326
1327    #[test]
1328    fn pipeline_layers_configurable() {
1329        let toml_str = r#"
1330[profile]
1331name = "no-intent"
1332
1333[pipeline]
1334intent = false
1335relevance = false
1336"#;
1337        let p: Profile = toml::from_str(toml_str).unwrap();
1338        assert!(!p.pipeline.intent_effective());
1339        assert!(!p.pipeline.relevance_effective());
1340        assert!(p.pipeline.compression_effective());
1341        assert!(p.pipeline.translation_effective());
1342    }
1343
1344    #[test]
1345    fn partial_toml_fills_defaults() {
1346        let toml_str = r#"
1347[profile]
1348name = "minimal"
1349
1350[read]
1351default_mode = "entropy"
1352"#;
1353        let p: Profile = toml::from_str(toml_str).unwrap();
1354        assert_eq!(p.read.default_mode_effective(), "entropy");
1355        assert_eq!(p.read.max_tokens_per_file_effective(), 50_000);
1356        assert_eq!(p.budget.max_context_tokens_effective(), 200_000);
1357        assert_eq!(p.compression.crp_mode_effective(), "tdd");
1358    }
1359
1360    #[test]
1361    fn partial_toml_leaves_unset_as_none() {
1362        let toml_str = r#"
1363[profile]
1364name = "sparse"
1365
1366[read]
1367default_mode = "map"
1368"#;
1369        let p: Profile = toml::from_str(toml_str).unwrap();
1370        assert_eq!(p.read.default_mode, Some("map".to_string()));
1371        assert_eq!(p.read.max_tokens_per_file, None);
1372        assert_eq!(p.read.prefer_cache, None);
1373        assert_eq!(p.budget.max_context_tokens, None);
1374        assert_eq!(p.compression.crp_mode, None);
1375    }
1376}