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