Skip to main content

lean_ctx/core/profiles/
loading.rs

1use super::builtins::{builtin_coder, builtin_profile, builtin_profiles};
2use super::types::{
3    BudgetConfig, CompressionConfig, DegradationConfig, LayoutConfig, OutputHints, PipelineConfig,
4    Profile, ProfileAutonomy, ProfileMeta, ReadConfig, RoutingConfig, TranslationConfig,
5};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::RwLock;
9
10// ── Loading ────────────────────────────────────────────────
11
12fn profiles_dir_global() -> Option<PathBuf> {
13    crate::core::data_dir::lean_ctx_data_dir()
14        .ok()
15        .map(|d| d.join("profiles"))
16}
17
18fn profiles_dir_project() -> Option<PathBuf> {
19    let mut current = std::env::current_dir().ok()?;
20    for _ in 0..12 {
21        let candidate = current.join(".lean-ctx").join("profiles");
22        if candidate.is_dir() {
23            return Some(candidate);
24        }
25        if !current.pop() {
26            break;
27        }
28    }
29    None
30}
31
32/// Loads a profile by name with full resolution:
33/// 1. Project-local `.lean-ctx/profiles/<name>.toml`
34/// 2. Global `~/.lean-ctx/profiles/<name>.toml`
35/// 3. Built-in defaults
36///
37/// Applies inheritance chain (max depth 5 to prevent cycles).
38pub fn load_profile(name: &str) -> Option<Profile> {
39    load_profile_recursive(name, 0)
40}
41
42fn load_profile_recursive(name: &str, depth: usize) -> Option<Profile> {
43    if depth > 5 {
44        return None;
45    }
46
47    let mut profile = load_profile_from_disk(name).or_else(|| builtin_profile(name))?;
48    profile.profile.name = name.to_string();
49
50    if let Some(ref parent_name) = profile.profile.inherits.clone()
51        && let Some(parent) = load_profile_recursive(parent_name, depth + 1)
52    {
53        profile = merge_profiles(parent, profile);
54    }
55
56    Some(profile)
57}
58
59fn load_profile_from_disk(name: &str) -> Option<Profile> {
60    let filename = format!("{name}.toml");
61
62    if let Some(project_dir) = profiles_dir_project() {
63        let path = project_dir.join(&filename);
64        if let Some(p) = try_load_toml(&path) {
65            return Some(p);
66        }
67    }
68
69    if let Some(global_dir) = profiles_dir_global() {
70        let path = global_dir.join(&filename);
71        if let Some(p) = try_load_toml(&path) {
72            return Some(p);
73        }
74    }
75
76    None
77}
78
79fn try_load_toml(path: &Path) -> Option<Profile> {
80    let content = std::fs::read_to_string(path).ok()?;
81    toml::from_str(&content).ok()
82}
83
84/// Merges parent into child: child values take precedence,
85/// parent provides defaults for unspecified fields.
86///
87/// ALL sections are merged field-by-field using `Option::or()`.
88/// A child profile only needs to set the fields it wants to override.
89pub(super) fn merge_profiles(parent: Profile, child: Profile) -> Profile {
90    let read = ReadConfig {
91        default_mode: child.read.default_mode.or(parent.read.default_mode),
92        max_tokens_per_file: child
93            .read
94            .max_tokens_per_file
95            .or(parent.read.max_tokens_per_file),
96        prefer_cache: child.read.prefer_cache.or(parent.read.prefer_cache),
97    };
98    let compression = CompressionConfig {
99        crp_mode: child.compression.crp_mode.or(parent.compression.crp_mode),
100        output_density: child
101            .compression
102            .output_density
103            .or(parent.compression.output_density),
104        entropy_threshold: child
105            .compression
106            .entropy_threshold
107            .or(parent.compression.entropy_threshold),
108        terse_mode: child
109            .compression
110            .terse_mode
111            .or(parent.compression.terse_mode),
112        adaptive: child.compression.adaptive.or(parent.compression.adaptive),
113    };
114    let translation = TranslationConfig {
115        enabled: child.translation.enabled.or(parent.translation.enabled),
116        ruleset: child.translation.ruleset.or(parent.translation.ruleset),
117    };
118    let layout = LayoutConfig {
119        enabled: child.layout.enabled.or(parent.layout.enabled),
120        min_lines: child.layout.min_lines.or(parent.layout.min_lines),
121    };
122    let memory = crate::core::memory_policy::MemoryPolicyOverrides {
123        knowledge: crate::core::memory_policy::KnowledgePolicyOverrides {
124            max_facts: child
125                .memory
126                .knowledge
127                .max_facts
128                .or(parent.memory.knowledge.max_facts),
129            max_patterns: child
130                .memory
131                .knowledge
132                .max_patterns
133                .or(parent.memory.knowledge.max_patterns),
134            max_history: child
135                .memory
136                .knowledge
137                .max_history
138                .or(parent.memory.knowledge.max_history),
139            contradiction_threshold: child
140                .memory
141                .knowledge
142                .contradiction_threshold
143                .or(parent.memory.knowledge.contradiction_threshold),
144            recall_facts_limit: child
145                .memory
146                .knowledge
147                .recall_facts_limit
148                .or(parent.memory.knowledge.recall_facts_limit),
149            rooms_limit: child
150                .memory
151                .knowledge
152                .rooms_limit
153                .or(parent.memory.knowledge.rooms_limit),
154            timeline_limit: child
155                .memory
156                .knowledge
157                .timeline_limit
158                .or(parent.memory.knowledge.timeline_limit),
159            relations_limit: child
160                .memory
161                .knowledge
162                .relations_limit
163                .or(parent.memory.knowledge.relations_limit),
164        },
165        lifecycle: crate::core::memory_policy::LifecyclePolicyOverrides {
166            decay_rate: child
167                .memory
168                .lifecycle
169                .decay_rate
170                .or(parent.memory.lifecycle.decay_rate),
171            low_confidence_threshold: child
172                .memory
173                .lifecycle
174                .low_confidence_threshold
175                .or(parent.memory.lifecycle.low_confidence_threshold),
176            stale_days: child
177                .memory
178                .lifecycle
179                .stale_days
180                .or(parent.memory.lifecycle.stale_days),
181            similarity_threshold: child
182                .memory
183                .lifecycle
184                .similarity_threshold
185                .or(parent.memory.lifecycle.similarity_threshold),
186            forgetting_model: child
187                .memory
188                .lifecycle
189                .forgetting_model
190                .clone()
191                .or_else(|| parent.memory.lifecycle.forgetting_model.clone()),
192            base_stability_days: child
193                .memory
194                .lifecycle
195                .base_stability_days
196                .or(parent.memory.lifecycle.base_stability_days),
197            archetype_aware_decay: child
198                .memory
199                .lifecycle
200                .archetype_aware_decay
201                .or(parent.memory.lifecycle.archetype_aware_decay),
202        },
203    };
204    let verification = crate::core::output_verification::VerificationConfig {
205        enabled: child.verification.enabled.or(parent.verification.enabled),
206        mode: child.verification.mode.or(parent.verification.mode),
207        strict_mode: child
208            .verification
209            .strict_mode
210            .or(parent.verification.strict_mode),
211        check_paths: child
212            .verification
213            .check_paths
214            .or(parent.verification.check_paths),
215        check_identifiers: child
216            .verification
217            .check_identifiers
218            .or(parent.verification.check_identifiers),
219        check_line_numbers: child
220            .verification
221            .check_line_numbers
222            .or(parent.verification.check_line_numbers),
223        check_structure: child
224            .verification
225            .check_structure
226            .or(parent.verification.check_structure),
227    };
228    let budget = BudgetConfig {
229        max_context_tokens: child
230            .budget
231            .max_context_tokens
232            .or(parent.budget.max_context_tokens),
233        max_shell_invocations: child
234            .budget
235            .max_shell_invocations
236            .or(parent.budget.max_shell_invocations),
237        max_cost_usd: child.budget.max_cost_usd.or(parent.budget.max_cost_usd),
238    };
239    let pipeline = PipelineConfig {
240        intent: child.pipeline.intent.or(parent.pipeline.intent),
241        relevance: child.pipeline.relevance.or(parent.pipeline.relevance),
242        compression: child.pipeline.compression.or(parent.pipeline.compression),
243        translation: child.pipeline.translation.or(parent.pipeline.translation),
244    };
245    let routing = RoutingConfig {
246        max_model_tier: child
247            .routing
248            .max_model_tier
249            .or(parent.routing.max_model_tier),
250        degrade_under_pressure: child
251            .routing
252            .degrade_under_pressure
253            .or(parent.routing.degrade_under_pressure),
254    };
255    let degradation = DegradationConfig {
256        enforce: child.degradation.enforce.or(parent.degradation.enforce),
257        throttle_ms: child
258            .degradation
259            .throttle_ms
260            .or(parent.degradation.throttle_ms),
261    };
262    let autonomy = ProfileAutonomy {
263        enabled: child.autonomy.enabled.or(parent.autonomy.enabled),
264        auto_preload: child.autonomy.auto_preload.or(parent.autonomy.auto_preload),
265        auto_dedup: child.autonomy.auto_dedup.or(parent.autonomy.auto_dedup),
266        auto_related: child.autonomy.auto_related.or(parent.autonomy.auto_related),
267        silent_preload: child
268            .autonomy
269            .silent_preload
270            .or(parent.autonomy.silent_preload),
271        auto_prefetch: child
272            .autonomy
273            .auto_prefetch
274            .or(parent.autonomy.auto_prefetch),
275        auto_response: child
276            .autonomy
277            .auto_response
278            .or(parent.autonomy.auto_response),
279        dedup_threshold: child
280            .autonomy
281            .dedup_threshold
282            .or(parent.autonomy.dedup_threshold),
283        prefetch_max_files: child
284            .autonomy
285            .prefetch_max_files
286            .or(parent.autonomy.prefetch_max_files),
287        prefetch_budget_tokens: child
288            .autonomy
289            .prefetch_budget_tokens
290            .or(parent.autonomy.prefetch_budget_tokens),
291        response_min_tokens: child
292            .autonomy
293            .response_min_tokens
294            .or(parent.autonomy.response_min_tokens),
295        checkpoint_interval: child
296            .autonomy
297            .checkpoint_interval
298            .or(parent.autonomy.checkpoint_interval),
299    };
300    let output_hints = OutputHints {
301        compressed_hint: child
302            .output_hints
303            .compressed_hint
304            .or(parent.output_hints.compressed_hint),
305        archive_hint: child
306            .output_hints
307            .archive_hint
308            .or(parent.output_hints.archive_hint),
309        verify_footer: child
310            .output_hints
311            .verify_footer
312            .or(parent.output_hints.verify_footer),
313        related_hint: child
314            .output_hints
315            .related_hint
316            .or(parent.output_hints.related_hint),
317        semantic_hint: child
318            .output_hints
319            .semantic_hint
320            .or(parent.output_hints.semantic_hint),
321        elicitation_hint: child
322            .output_hints
323            .elicitation_hint
324            .or(parent.output_hints.elicitation_hint),
325        checkpoint_in_output: child
326            .output_hints
327            .checkpoint_in_output
328            .or(parent.output_hints.checkpoint_in_output),
329        graph_context_block: child
330            .output_hints
331            .graph_context_block
332            .or(parent.output_hints.graph_context_block),
333        efficiency_hint: child
334            .output_hints
335            .efficiency_hint
336            .or(parent.output_hints.efficiency_hint),
337    };
338    Profile {
339        profile: ProfileMeta {
340            name: child.profile.name,
341            inherits: child.profile.inherits,
342            description: if child.profile.description.is_empty() {
343                parent.profile.description
344            } else {
345                child.profile.description
346            },
347        },
348        read,
349        compression,
350        translation,
351        layout,
352        memory,
353        verification,
354        budget,
355        pipeline,
356        routing,
357        degradation,
358        autonomy,
359        output_hints,
360    }
361}
362
363/// Reads the `profile` key directly from `config.toml` without going through
364/// `Config::load()`. This avoids a reentrancy deadlock: `Config::load()` →
365/// `find_project_root()` (OnceLock) → `SessionState::load_latest()` →
366/// `normalize_loaded_session()` → `active_profile()` → here → `Config::load()`.
367fn profile_name_from_config_file() -> Option<String> {
368    let path = crate::core::config::Config::path()?;
369    let content = std::fs::read_to_string(path).ok()?;
370    let table: toml::Table = toml::from_str(&content).ok()?;
371    table
372        .get("profile")?
373        .as_str()
374        .map(str::trim)
375        .filter(|s| !s.is_empty())
376        .map(String::from)
377}
378
379/// Process-wide active-profile override set by [`set_active_profile`].
380///
381/// Takes precedence over `LEAN_CTX_PROFILE`. Storing the runtime selection in an
382/// in-process cell (rather than mutating the environment) keeps profile
383/// switching thread-safe inside the multi-threaded MCP server, where
384/// `set_active_profile` may run on a blocking-pool worker while other workers
385/// resolve the active profile concurrently.
386static ACTIVE_PROFILE_OVERRIDE: RwLock<Option<String>> = RwLock::new(None);
387
388/// Returns the currently active profile name.
389///
390/// Resolution order: in-process override (see [`set_active_profile`]) →
391/// `LEAN_CTX_PROFILE` env var → config.toml `profile` field → "coder".
392pub fn active_profile_name() -> String {
393    if let Some(name) = ACTIVE_PROFILE_OVERRIDE
394        .read()
395        .unwrap_or_else(std::sync::PoisonError::into_inner)
396        .clone()
397    {
398        return name;
399    }
400    if let Ok(v) = std::env::var("LEAN_CTX_PROFILE") {
401        let v = v.trim().to_string();
402        if !v.is_empty() {
403            return v;
404        }
405    }
406    if let Some(name) = profile_name_from_config_file() {
407        return name;
408    }
409    "coder".to_string()
410}
411
412/// Loads the currently active profile.
413pub fn active_profile() -> Profile {
414    let name = active_profile_name();
415    if let Some(p) = load_profile(&name) {
416        p
417    } else {
418        if name != "coder" {
419            tracing::warn!(
420                "Profile '{name}' not found (no built-in or disk file). \
421                 Falling back to 'coder'. Create it with: lean-ctx profile create {name}"
422            );
423        }
424        builtin_coder()
425    }
426}
427
428/// Sets the active profile for the current process.
429///
430/// Records the selection in a thread-safe in-process override (see
431/// [`active_profile_name`]) and returns the resolved profile after applying
432/// inheritance.
433pub fn set_active_profile(name: &str) -> Result<Profile, String> {
434    let name = name.trim();
435    if name.is_empty() {
436        return Err("profile name is empty".to_string());
437    }
438    let prev = active_profile_name();
439    let profile = load_profile(name).ok_or_else(|| format!("profile '{name}' not found"))?;
440    *ACTIVE_PROFILE_OVERRIDE
441        .write()
442        .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(name.to_string());
443    if prev != name {
444        crate::core::events::emit_profile_changed(&prev, name);
445    }
446    Ok(profile)
447}
448
449/// Lists all available profile names (built-in + on-disk).
450pub fn list_profiles() -> Vec<ProfileInfo> {
451    let mut profiles: HashMap<String, ProfileInfo> = HashMap::new();
452
453    for (name, p) in builtin_profiles() {
454        profiles.insert(
455            name.clone(),
456            ProfileInfo {
457                name,
458                description: p.profile.description,
459                source: ProfileSource::Builtin,
460            },
461        );
462    }
463
464    for (source, dir) in [
465        (ProfileSource::Global, profiles_dir_global()),
466        (ProfileSource::Project, profiles_dir_project()),
467    ] {
468        if let Some(dir) = dir
469            && let Ok(entries) = std::fs::read_dir(&dir)
470        {
471            for entry in entries.flatten() {
472                let path = entry.path();
473                if path.extension().and_then(|e| e.to_str()) == Some("toml")
474                    && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
475                {
476                    let name = stem.to_string();
477                    let desc = try_load_toml(&path)
478                        .map(|p| p.profile.description)
479                        .unwrap_or_default();
480                    profiles.insert(
481                        name.clone(),
482                        ProfileInfo {
483                            name,
484                            description: desc,
485                            source,
486                        },
487                    );
488                }
489            }
490        }
491    }
492
493    let mut result: Vec<ProfileInfo> = profiles.into_values().collect();
494    result.sort_by_key(|p| p.name.clone());
495    result
496}
497
498/// Information about an available profile.
499#[derive(Debug, Clone)]
500pub struct ProfileInfo {
501    pub name: String,
502    pub description: String,
503    pub source: ProfileSource,
504}
505
506/// Where a profile was loaded from.
507#[derive(Debug, Clone, Copy, PartialEq, Eq)]
508pub enum ProfileSource {
509    Builtin,
510    Global,
511    Project,
512}
513
514impl std::fmt::Display for ProfileSource {
515    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516        match self {
517            Self::Builtin => write!(f, "built-in"),
518            Self::Global => write!(f, "global"),
519            Self::Project => write!(f, "project"),
520        }
521    }
522}
523
524/// Formats a profile as TOML for display or file creation.
525pub fn format_as_toml(profile: &Profile) -> String {
526    toml::to_string_pretty(profile).unwrap_or_else(|_| "[error serializing profile]".to_string())
527}