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