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
587/// Returns all built-in profile definitions.
588pub fn builtin_profiles() -> HashMap<String, Profile> {
589    let mut map = HashMap::new();
590    for p in [
591        builtin_coder(),
592        builtin_exploration(),
593        builtin_bugfix(),
594        builtin_hotfix(),
595        builtin_ci_debug(),
596        builtin_review(),
597    ] {
598        map.insert(p.profile.name.clone(), p);
599    }
600    map
601}
602
603// ── Loading ────────────────────────────────────────────────
604
605fn profiles_dir_global() -> Option<PathBuf> {
606    crate::core::data_dir::lean_ctx_data_dir()
607        .ok()
608        .map(|d| d.join("profiles"))
609}
610
611fn profiles_dir_project() -> Option<PathBuf> {
612    let mut current = std::env::current_dir().ok()?;
613    for _ in 0..12 {
614        let candidate = current.join(".lean-ctx").join("profiles");
615        if candidate.is_dir() {
616            return Some(candidate);
617        }
618        if !current.pop() {
619            break;
620        }
621    }
622    None
623}
624
625/// Loads a profile by name with full resolution:
626/// 1. Project-local `.lean-ctx/profiles/<name>.toml`
627/// 2. Global `~/.lean-ctx/profiles/<name>.toml`
628/// 3. Built-in defaults
629///
630/// Applies inheritance chain (max depth 5 to prevent cycles).
631pub fn load_profile(name: &str) -> Option<Profile> {
632    load_profile_recursive(name, 0)
633}
634
635fn load_profile_recursive(name: &str, depth: usize) -> Option<Profile> {
636    if depth > 5 {
637        return None;
638    }
639
640    let mut profile = load_profile_from_disk(name).or_else(|| builtin_profiles().remove(name))?;
641    profile.profile.name = name.to_string();
642
643    if let Some(ref parent_name) = profile.profile.inherits.clone() {
644        if let Some(parent) = load_profile_recursive(parent_name, depth + 1) {
645            profile = merge_profiles(parent, profile);
646        }
647    }
648
649    Some(profile)
650}
651
652fn load_profile_from_disk(name: &str) -> Option<Profile> {
653    let filename = format!("{name}.toml");
654
655    if let Some(project_dir) = profiles_dir_project() {
656        let path = project_dir.join(&filename);
657        if let Some(p) = try_load_toml(&path) {
658            return Some(p);
659        }
660    }
661
662    if let Some(global_dir) = profiles_dir_global() {
663        let path = global_dir.join(&filename);
664        if let Some(p) = try_load_toml(&path) {
665            return Some(p);
666        }
667    }
668
669    None
670}
671
672fn try_load_toml(path: &Path) -> Option<Profile> {
673    let content = std::fs::read_to_string(path).ok()?;
674    toml::from_str(&content).ok()
675}
676
677/// Merges parent into child: child values take precedence,
678/// parent provides defaults for unspecified fields.
679///
680/// ALL sections are merged field-by-field using `Option::or()`.
681/// A child profile only needs to set the fields it wants to override.
682fn merge_profiles(parent: Profile, child: Profile) -> Profile {
683    let read = ReadConfig {
684        default_mode: child.read.default_mode.or(parent.read.default_mode),
685        max_tokens_per_file: child
686            .read
687            .max_tokens_per_file
688            .or(parent.read.max_tokens_per_file),
689        prefer_cache: child.read.prefer_cache.or(parent.read.prefer_cache),
690    };
691    let compression = CompressionConfig {
692        crp_mode: child.compression.crp_mode.or(parent.compression.crp_mode),
693        output_density: child
694            .compression
695            .output_density
696            .or(parent.compression.output_density),
697        entropy_threshold: child
698            .compression
699            .entropy_threshold
700            .or(parent.compression.entropy_threshold),
701        terse_mode: child
702            .compression
703            .terse_mode
704            .or(parent.compression.terse_mode),
705    };
706    let translation = TranslationConfig {
707        enabled: child.translation.enabled.or(parent.translation.enabled),
708        ruleset: child.translation.ruleset.or(parent.translation.ruleset),
709    };
710    let layout = LayoutConfig {
711        enabled: child.layout.enabled.or(parent.layout.enabled),
712        min_lines: child.layout.min_lines.or(parent.layout.min_lines),
713    };
714    let memory = crate::core::memory_policy::MemoryPolicyOverrides {
715        knowledge: crate::core::memory_policy::KnowledgePolicyOverrides {
716            max_facts: child
717                .memory
718                .knowledge
719                .max_facts
720                .or(parent.memory.knowledge.max_facts),
721            max_patterns: child
722                .memory
723                .knowledge
724                .max_patterns
725                .or(parent.memory.knowledge.max_patterns),
726            max_history: child
727                .memory
728                .knowledge
729                .max_history
730                .or(parent.memory.knowledge.max_history),
731            contradiction_threshold: child
732                .memory
733                .knowledge
734                .contradiction_threshold
735                .or(parent.memory.knowledge.contradiction_threshold),
736            recall_facts_limit: child
737                .memory
738                .knowledge
739                .recall_facts_limit
740                .or(parent.memory.knowledge.recall_facts_limit),
741            rooms_limit: child
742                .memory
743                .knowledge
744                .rooms_limit
745                .or(parent.memory.knowledge.rooms_limit),
746            timeline_limit: child
747                .memory
748                .knowledge
749                .timeline_limit
750                .or(parent.memory.knowledge.timeline_limit),
751            relations_limit: child
752                .memory
753                .knowledge
754                .relations_limit
755                .or(parent.memory.knowledge.relations_limit),
756        },
757        lifecycle: crate::core::memory_policy::LifecyclePolicyOverrides {
758            decay_rate: child
759                .memory
760                .lifecycle
761                .decay_rate
762                .or(parent.memory.lifecycle.decay_rate),
763            low_confidence_threshold: child
764                .memory
765                .lifecycle
766                .low_confidence_threshold
767                .or(parent.memory.lifecycle.low_confidence_threshold),
768            stale_days: child
769                .memory
770                .lifecycle
771                .stale_days
772                .or(parent.memory.lifecycle.stale_days),
773            similarity_threshold: child
774                .memory
775                .lifecycle
776                .similarity_threshold
777                .or(parent.memory.lifecycle.similarity_threshold),
778        },
779    };
780    let verification = crate::core::output_verification::VerificationConfig {
781        enabled: child.verification.enabled.or(parent.verification.enabled),
782        mode: child.verification.mode.or(parent.verification.mode),
783        strict_mode: child
784            .verification
785            .strict_mode
786            .or(parent.verification.strict_mode),
787        check_paths: child
788            .verification
789            .check_paths
790            .or(parent.verification.check_paths),
791        check_identifiers: child
792            .verification
793            .check_identifiers
794            .or(parent.verification.check_identifiers),
795        check_line_numbers: child
796            .verification
797            .check_line_numbers
798            .or(parent.verification.check_line_numbers),
799        check_structure: child
800            .verification
801            .check_structure
802            .or(parent.verification.check_structure),
803    };
804    let budget = BudgetConfig {
805        max_context_tokens: child
806            .budget
807            .max_context_tokens
808            .or(parent.budget.max_context_tokens),
809        max_shell_invocations: child
810            .budget
811            .max_shell_invocations
812            .or(parent.budget.max_shell_invocations),
813        max_cost_usd: child.budget.max_cost_usd.or(parent.budget.max_cost_usd),
814    };
815    let pipeline = PipelineConfig {
816        intent: child.pipeline.intent.or(parent.pipeline.intent),
817        relevance: child.pipeline.relevance.or(parent.pipeline.relevance),
818        compression: child.pipeline.compression.or(parent.pipeline.compression),
819        translation: child.pipeline.translation.or(parent.pipeline.translation),
820    };
821    let routing = RoutingConfig {
822        max_model_tier: child
823            .routing
824            .max_model_tier
825            .or(parent.routing.max_model_tier),
826        degrade_under_pressure: child
827            .routing
828            .degrade_under_pressure
829            .or(parent.routing.degrade_under_pressure),
830    };
831    let degradation = DegradationConfig {
832        enforce: child.degradation.enforce.or(parent.degradation.enforce),
833        throttle_ms: child
834            .degradation
835            .throttle_ms
836            .or(parent.degradation.throttle_ms),
837    };
838    let autonomy = ProfileAutonomy {
839        enabled: child.autonomy.enabled.or(parent.autonomy.enabled),
840        auto_preload: child.autonomy.auto_preload.or(parent.autonomy.auto_preload),
841        auto_dedup: child.autonomy.auto_dedup.or(parent.autonomy.auto_dedup),
842        auto_related: child.autonomy.auto_related.or(parent.autonomy.auto_related),
843        silent_preload: child
844            .autonomy
845            .silent_preload
846            .or(parent.autonomy.silent_preload),
847        auto_prefetch: child
848            .autonomy
849            .auto_prefetch
850            .or(parent.autonomy.auto_prefetch),
851        auto_response: child
852            .autonomy
853            .auto_response
854            .or(parent.autonomy.auto_response),
855        dedup_threshold: child
856            .autonomy
857            .dedup_threshold
858            .or(parent.autonomy.dedup_threshold),
859        prefetch_max_files: child
860            .autonomy
861            .prefetch_max_files
862            .or(parent.autonomy.prefetch_max_files),
863        prefetch_budget_tokens: child
864            .autonomy
865            .prefetch_budget_tokens
866            .or(parent.autonomy.prefetch_budget_tokens),
867        response_min_tokens: child
868            .autonomy
869            .response_min_tokens
870            .or(parent.autonomy.response_min_tokens),
871        checkpoint_interval: child
872            .autonomy
873            .checkpoint_interval
874            .or(parent.autonomy.checkpoint_interval),
875    };
876    let output_hints = OutputHints {
877        compressed_hint: child
878            .output_hints
879            .compressed_hint
880            .or(parent.output_hints.compressed_hint),
881        archive_hint: child
882            .output_hints
883            .archive_hint
884            .or(parent.output_hints.archive_hint),
885        verify_footer: child
886            .output_hints
887            .verify_footer
888            .or(parent.output_hints.verify_footer),
889        related_hint: child
890            .output_hints
891            .related_hint
892            .or(parent.output_hints.related_hint),
893        semantic_hint: child
894            .output_hints
895            .semantic_hint
896            .or(parent.output_hints.semantic_hint),
897        elicitation_hint: child
898            .output_hints
899            .elicitation_hint
900            .or(parent.output_hints.elicitation_hint),
901        checkpoint_in_output: child
902            .output_hints
903            .checkpoint_in_output
904            .or(parent.output_hints.checkpoint_in_output),
905        graph_context_block: child
906            .output_hints
907            .graph_context_block
908            .or(parent.output_hints.graph_context_block),
909        efficiency_hint: child
910            .output_hints
911            .efficiency_hint
912            .or(parent.output_hints.efficiency_hint),
913    };
914    Profile {
915        profile: ProfileMeta {
916            name: child.profile.name,
917            inherits: child.profile.inherits,
918            description: if child.profile.description.is_empty() {
919                parent.profile.description
920            } else {
921                child.profile.description
922            },
923        },
924        read,
925        compression,
926        translation,
927        layout,
928        memory,
929        verification,
930        budget,
931        pipeline,
932        routing,
933        degradation,
934        autonomy,
935        output_hints,
936    }
937}
938
939/// Returns the currently active profile name from env or default.
940pub fn active_profile_name() -> String {
941    std::env::var("LEAN_CTX_PROFILE")
942        .ok()
943        .filter(|s| !s.trim().is_empty())
944        .unwrap_or_else(|| "coder".to_string())
945}
946
947/// Loads the currently active profile.
948pub fn active_profile() -> Profile {
949    let name = active_profile_name();
950    load_profile(&name).unwrap_or_else(builtin_coder)
951}
952
953/// Sets the active profile for the current process by updating `LEAN_CTX_PROFILE`.
954///
955/// Returns the resolved profile after applying inheritance.
956pub fn set_active_profile(name: &str) -> Result<Profile, String> {
957    let name = name.trim();
958    if name.is_empty() {
959        return Err("profile name is empty".to_string());
960    }
961    let prev = active_profile_name();
962    let profile = load_profile(name).ok_or_else(|| format!("profile '{name}' not found"))?;
963    std::env::set_var("LEAN_CTX_PROFILE", name);
964    if prev != name {
965        crate::core::events::emit_profile_changed(&prev, name);
966    }
967    Ok(profile)
968}
969
970/// Lists all available profile names (built-in + on-disk).
971pub fn list_profiles() -> Vec<ProfileInfo> {
972    let mut profiles: HashMap<String, ProfileInfo> = HashMap::new();
973
974    for (name, p) in builtin_profiles() {
975        profiles.insert(
976            name.clone(),
977            ProfileInfo {
978                name,
979                description: p.profile.description,
980                source: ProfileSource::Builtin,
981            },
982        );
983    }
984
985    for (source, dir) in [
986        (ProfileSource::Global, profiles_dir_global()),
987        (ProfileSource::Project, profiles_dir_project()),
988    ] {
989        if let Some(dir) = dir {
990            if let Ok(entries) = std::fs::read_dir(&dir) {
991                for entry in entries.flatten() {
992                    let path = entry.path();
993                    if path.extension().and_then(|e| e.to_str()) == Some("toml") {
994                        if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
995                            let name = stem.to_string();
996                            let desc = try_load_toml(&path)
997                                .map(|p| p.profile.description)
998                                .unwrap_or_default();
999                            profiles.insert(
1000                                name.clone(),
1001                                ProfileInfo {
1002                                    name,
1003                                    description: desc,
1004                                    source,
1005                                },
1006                            );
1007                        }
1008                    }
1009                }
1010            }
1011        }
1012    }
1013
1014    let mut result: Vec<ProfileInfo> = profiles.into_values().collect();
1015    result.sort_by_key(|p| p.name.clone());
1016    result
1017}
1018
1019/// Information about an available profile.
1020#[derive(Debug, Clone)]
1021pub struct ProfileInfo {
1022    pub name: String,
1023    pub description: String,
1024    pub source: ProfileSource,
1025}
1026
1027/// Where a profile was loaded from.
1028#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1029pub enum ProfileSource {
1030    Builtin,
1031    Global,
1032    Project,
1033}
1034
1035impl std::fmt::Display for ProfileSource {
1036    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1037        match self {
1038            Self::Builtin => write!(f, "built-in"),
1039            Self::Global => write!(f, "global"),
1040            Self::Project => write!(f, "project"),
1041        }
1042    }
1043}
1044
1045/// Formats a profile as TOML for display or file creation.
1046pub fn format_as_toml(profile: &Profile) -> String {
1047    toml::to_string_pretty(profile).unwrap_or_else(|_| "[error serializing profile]".to_string())
1048}
1049
1050// ── Tests ──────────────────────────────────────────────────
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055
1056    #[test]
1057    fn builtin_profiles_count() {
1058        let builtins = builtin_profiles();
1059        assert_eq!(builtins.len(), 6);
1060        assert!(builtins.contains_key("coder"));
1061        assert!(builtins.contains_key("exploration"));
1062        assert!(builtins.contains_key("bugfix"));
1063        assert!(builtins.contains_key("hotfix"));
1064        assert!(builtins.contains_key("ci-debug"));
1065        assert!(builtins.contains_key("review"));
1066    }
1067
1068    #[test]
1069    fn hotfix_has_minimal_budget() {
1070        let p = builtin_profiles().remove("hotfix").unwrap();
1071        assert_eq!(p.budget.max_context_tokens_effective(), 30_000);
1072        assert_eq!(p.budget.max_shell_invocations_effective(), 20);
1073        assert_eq!(p.read.default_mode_effective(), "signatures");
1074        assert_eq!(p.compression.output_density_effective(), "ultra");
1075    }
1076
1077    #[test]
1078    fn exploration_has_broad_context() {
1079        let p = builtin_profiles().remove("exploration").unwrap();
1080        assert_eq!(p.budget.max_context_tokens_effective(), 200_000);
1081        assert_eq!(p.read.default_mode_effective(), "map");
1082        assert!(p.read.prefer_cache_effective());
1083    }
1084
1085    #[test]
1086    fn profile_roundtrip_toml() {
1087        let original = builtin_exploration();
1088        let toml_str = format_as_toml(&original);
1089        let parsed: Profile = toml::from_str(&toml_str).unwrap();
1090        assert_eq!(parsed.profile.name, "exploration");
1091        assert_eq!(parsed.read.default_mode_effective(), "map");
1092        assert_eq!(parsed.budget.max_context_tokens_effective(), 200_000);
1093    }
1094
1095    #[test]
1096    fn merge_child_overrides_parent() {
1097        let parent = builtin_exploration();
1098        let child = Profile {
1099            profile: ProfileMeta {
1100                name: "custom".to_string(),
1101                inherits: Some("exploration".to_string()),
1102                description: String::new(),
1103            },
1104            read: ReadConfig {
1105                default_mode: Some("signatures".to_string()),
1106                ..ReadConfig::default()
1107            },
1108            compression: CompressionConfig::default(),
1109            translation: TranslationConfig::default(),
1110            layout: LayoutConfig::default(),
1111            memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
1112            verification: crate::core::output_verification::VerificationConfig::default(),
1113            budget: BudgetConfig {
1114                max_context_tokens: Some(10_000),
1115                ..BudgetConfig::default()
1116            },
1117            pipeline: PipelineConfig::default(),
1118            routing: RoutingConfig::default(),
1119            degradation: DegradationConfig::default(),
1120            autonomy: ProfileAutonomy::default(),
1121            output_hints: OutputHints::default(),
1122        };
1123
1124        let merged = merge_profiles(parent, child);
1125        assert_eq!(merged.read.default_mode_effective(), "signatures");
1126        assert_eq!(merged.budget.max_context_tokens_effective(), 10_000);
1127        assert_eq!(
1128            merged.profile.description,
1129            "Broad context for understanding codebases"
1130        );
1131    }
1132
1133    #[test]
1134    fn merge_partial_child_inherits_parent_fields() {
1135        let parent = builtin_exploration();
1136        let child = Profile {
1137            profile: ProfileMeta {
1138                name: "partial".to_string(),
1139                inherits: Some("exploration".to_string()),
1140                description: String::new(),
1141            },
1142            read: ReadConfig {
1143                default_mode: Some("map".to_string()),
1144                ..ReadConfig::default()
1145            },
1146            compression: CompressionConfig::default(),
1147            translation: TranslationConfig::default(),
1148            layout: LayoutConfig::default(),
1149            memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
1150            verification: crate::core::output_verification::VerificationConfig::default(),
1151            budget: BudgetConfig::default(),
1152            pipeline: PipelineConfig::default(),
1153            routing: RoutingConfig::default(),
1154            degradation: DegradationConfig::default(),
1155            autonomy: ProfileAutonomy::default(),
1156            output_hints: OutputHints::default(),
1157        };
1158
1159        let merged = merge_profiles(parent, child);
1160        assert_eq!(merged.read.default_mode_effective(), "map");
1161        assert_eq!(
1162            merged.read.max_tokens_per_file_effective(),
1163            80_000,
1164            "should inherit max_tokens_per_file from parent"
1165        );
1166        assert!(
1167            merged.read.prefer_cache_effective(),
1168            "should inherit prefer_cache from parent"
1169        );
1170        assert_eq!(
1171            merged.budget.max_context_tokens_effective(),
1172            200_000,
1173            "should inherit budget from parent"
1174        );
1175    }
1176
1177    #[test]
1178    fn load_builtin_by_name() {
1179        let p = load_profile("hotfix").unwrap();
1180        assert_eq!(p.profile.name, "hotfix");
1181        assert_eq!(p.read.default_mode_effective(), "signatures");
1182    }
1183
1184    #[test]
1185    fn load_nonexistent_returns_none() {
1186        assert!(load_profile("does-not-exist-xyz").is_none());
1187    }
1188
1189    #[test]
1190    fn list_profiles_includes_builtins() {
1191        let list = list_profiles();
1192        assert!(list.len() >= 5);
1193        let names: Vec<&str> = list.iter().map(|p| p.name.as_str()).collect();
1194        assert!(names.contains(&"exploration"));
1195        assert!(names.contains(&"hotfix"));
1196        assert!(names.contains(&"review"));
1197    }
1198
1199    #[test]
1200    fn active_profile_defaults_to_coder() {
1201        let _lock = crate::core::data_dir::test_env_lock();
1202        std::env::remove_var("LEAN_CTX_PROFILE");
1203        let p = active_profile();
1204        assert_eq!(p.profile.name, "coder");
1205    }
1206
1207    #[test]
1208    fn active_profile_from_env() {
1209        let _lock = crate::core::data_dir::test_env_lock();
1210        std::env::set_var("LEAN_CTX_PROFILE", "hotfix");
1211        let name = active_profile_name();
1212        assert_eq!(name, "hotfix");
1213        std::env::remove_var("LEAN_CTX_PROFILE");
1214    }
1215
1216    #[test]
1217    fn profile_source_display() {
1218        assert_eq!(ProfileSource::Builtin.to_string(), "built-in");
1219        assert_eq!(ProfileSource::Global.to_string(), "global");
1220        assert_eq!(ProfileSource::Project.to_string(), "project");
1221    }
1222
1223    #[test]
1224    fn default_profile_has_sane_values() {
1225        let p = Profile {
1226            profile: ProfileMeta::default(),
1227            read: ReadConfig::default(),
1228            compression: CompressionConfig::default(),
1229            translation: TranslationConfig::default(),
1230            layout: LayoutConfig::default(),
1231            memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
1232            verification: crate::core::output_verification::VerificationConfig::default(),
1233            budget: BudgetConfig::default(),
1234            pipeline: PipelineConfig::default(),
1235            routing: RoutingConfig::default(),
1236            degradation: DegradationConfig::default(),
1237            autonomy: ProfileAutonomy::default(),
1238            output_hints: OutputHints::default(),
1239        };
1240        assert_eq!(p.read.default_mode_effective(), "auto");
1241        assert_eq!(p.compression.crp_mode_effective(), "tdd");
1242        assert_eq!(p.budget.max_context_tokens_effective(), 200_000);
1243        assert!(p.pipeline.compression_effective());
1244        assert!(p.pipeline.intent_effective());
1245    }
1246
1247    #[test]
1248    fn pipeline_layers_configurable() {
1249        let toml_str = r#"
1250[profile]
1251name = "no-intent"
1252
1253[pipeline]
1254intent = false
1255relevance = false
1256"#;
1257        let p: Profile = toml::from_str(toml_str).unwrap();
1258        assert!(!p.pipeline.intent_effective());
1259        assert!(!p.pipeline.relevance_effective());
1260        assert!(p.pipeline.compression_effective());
1261        assert!(p.pipeline.translation_effective());
1262    }
1263
1264    #[test]
1265    fn partial_toml_fills_defaults() {
1266        let toml_str = r#"
1267[profile]
1268name = "minimal"
1269
1270[read]
1271default_mode = "entropy"
1272"#;
1273        let p: Profile = toml::from_str(toml_str).unwrap();
1274        assert_eq!(p.read.default_mode_effective(), "entropy");
1275        assert_eq!(p.read.max_tokens_per_file_effective(), 50_000);
1276        assert_eq!(p.budget.max_context_tokens_effective(), 200_000);
1277        assert_eq!(p.compression.crp_mode_effective(), "tdd");
1278    }
1279
1280    #[test]
1281    fn partial_toml_leaves_unset_as_none() {
1282        let toml_str = r#"
1283[profile]
1284name = "sparse"
1285
1286[read]
1287default_mode = "map"
1288"#;
1289        let p: Profile = toml::from_str(toml_str).unwrap();
1290        assert_eq!(p.read.default_mode, Some("map".to_string()));
1291        assert_eq!(p.read.max_tokens_per_file, None);
1292        assert_eq!(p.read.prefer_cache, None);
1293        assert_eq!(p.budget.max_context_tokens, None);
1294        assert_eq!(p.compression.crp_mode, None);
1295    }
1296}