Skip to main content

lean_ctx/core/profiles/
loading.rs

1use super::builtins::{builtin_coder, builtin_profile, builtin_profiles};
2use super::types::{
3    CompressionConfig, OutputHints, Profile, ProfileAutonomy, ProfileMeta, ReadConfig,
4    RoutingConfig,
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    let table: toml::Table = toml::from_str(&content).ok()?;
82    warn_retired_autonomy_keys(path, &table);
83    table.try_into().ok()
84}
85
86/// A profile `[autonomy]` key retired with its driver is ignored on load;
87/// name it instead of dropping it silently.
88fn warn_retired_autonomy_keys(path: &Path, table: &toml::Table) {
89    let Some(autonomy) = table.get("autonomy").and_then(toml::Value::as_table) else {
90        return;
91    };
92    for key in autonomy.keys() {
93        let dotted = format!("autonomy.{key}");
94        if let Some(retired) = crate::core::config::retired::retired_key(&dotted) {
95            tracing::warn!(
96                "profile {}: `{dotted}` is retired and has no effect ({})",
97                path.display(),
98                retired.note
99            );
100        }
101    }
102}
103
104/// Merges parent into child: child values take precedence,
105/// parent provides defaults for unspecified fields.
106///
107/// ALL sections are merged field-by-field using `Option::or()`.
108/// A child profile only needs to set the fields it wants to override.
109pub(super) fn merge_profiles(parent: Profile, child: Profile) -> Profile {
110    let read = ReadConfig {
111        default_mode: child.read.default_mode.or(parent.read.default_mode),
112    };
113    let compression = CompressionConfig {
114        crp_mode: child.compression.crp_mode.or(parent.compression.crp_mode),
115    };
116    let memory = crate::core::memory_policy::MemoryPolicyOverrides {
117        knowledge: crate::core::memory_policy::KnowledgePolicyOverrides {
118            max_patterns: child
119                .memory
120                .knowledge
121                .max_patterns
122                .or(parent.memory.knowledge.max_patterns),
123            max_history: child
124                .memory
125                .knowledge
126                .max_history
127                .or(parent.memory.knowledge.max_history),
128            contradiction_threshold: child
129                .memory
130                .knowledge
131                .contradiction_threshold
132                .or(parent.memory.knowledge.contradiction_threshold),
133            recall_facts_limit: child
134                .memory
135                .knowledge
136                .recall_facts_limit
137                .or(parent.memory.knowledge.recall_facts_limit),
138            rooms_limit: child
139                .memory
140                .knowledge
141                .rooms_limit
142                .or(parent.memory.knowledge.rooms_limit),
143            timeline_limit: child
144                .memory
145                .knowledge
146                .timeline_limit
147                .or(parent.memory.knowledge.timeline_limit),
148            relations_limit: child
149                .memory
150                .knowledge
151                .relations_limit
152                .or(parent.memory.knowledge.relations_limit),
153        },
154        lifecycle: crate::core::memory_policy::LifecyclePolicyOverrides {
155            decay_rate: child
156                .memory
157                .lifecycle
158                .decay_rate
159                .or(parent.memory.lifecycle.decay_rate),
160            low_confidence_threshold: child
161                .memory
162                .lifecycle
163                .low_confidence_threshold
164                .or(parent.memory.lifecycle.low_confidence_threshold),
165            stale_days: child
166                .memory
167                .lifecycle
168                .stale_days
169                .or(parent.memory.lifecycle.stale_days),
170            similarity_threshold: child
171                .memory
172                .lifecycle
173                .similarity_threshold
174                .or(parent.memory.lifecycle.similarity_threshold),
175            forgetting_model: child
176                .memory
177                .lifecycle
178                .forgetting_model
179                .clone()
180                .or_else(|| parent.memory.lifecycle.forgetting_model.clone()),
181            base_stability_days: child
182                .memory
183                .lifecycle
184                .base_stability_days
185                .or(parent.memory.lifecycle.base_stability_days),
186            archetype_aware_decay: child
187                .memory
188                .lifecycle
189                .archetype_aware_decay
190                .or(parent.memory.lifecycle.archetype_aware_decay),
191        },
192    };
193    let verification = crate::core::output_verification::VerificationConfig {
194        enabled: child.verification.enabled.or(parent.verification.enabled),
195        mode: child.verification.mode.or(parent.verification.mode),
196        strict_mode: child
197            .verification
198            .strict_mode
199            .or(parent.verification.strict_mode),
200        check_paths: child
201            .verification
202            .check_paths
203            .or(parent.verification.check_paths),
204        check_identifiers: child
205            .verification
206            .check_identifiers
207            .or(parent.verification.check_identifiers),
208        check_line_numbers: child
209            .verification
210            .check_line_numbers
211            .or(parent.verification.check_line_numbers),
212        check_structure: child
213            .verification
214            .check_structure
215            .or(parent.verification.check_structure),
216    };
217    let routing = RoutingConfig {
218        degrade_under_pressure: child
219            .routing
220            .degrade_under_pressure
221            .or(parent.routing.degrade_under_pressure),
222    };
223    let autonomy = ProfileAutonomy {
224        enabled: child.autonomy.enabled.or(parent.autonomy.enabled),
225        checkpoint_interval: child
226            .autonomy
227            .checkpoint_interval
228            .or(parent.autonomy.checkpoint_interval),
229    };
230    let output_hints = OutputHints {
231        compressed_hint: child
232            .output_hints
233            .compressed_hint
234            .or(parent.output_hints.compressed_hint),
235        archive_hint: child
236            .output_hints
237            .archive_hint
238            .or(parent.output_hints.archive_hint),
239        verify_footer: child
240            .output_hints
241            .verify_footer
242            .or(parent.output_hints.verify_footer),
243        related_hint: child
244            .output_hints
245            .related_hint
246            .or(parent.output_hints.related_hint),
247        semantic_hint: child
248            .output_hints
249            .semantic_hint
250            .or(parent.output_hints.semantic_hint),
251        elicitation_hint: child
252            .output_hints
253            .elicitation_hint
254            .or(parent.output_hints.elicitation_hint),
255        checkpoint_in_output: child
256            .output_hints
257            .checkpoint_in_output
258            .or(parent.output_hints.checkpoint_in_output),
259        graph_context_block: child
260            .output_hints
261            .graph_context_block
262            .or(parent.output_hints.graph_context_block),
263        efficiency_hint: child
264            .output_hints
265            .efficiency_hint
266            .or(parent.output_hints.efficiency_hint),
267        cross_source_hint: child
268            .output_hints
269            .cross_source_hint
270            .or(parent.output_hints.cross_source_hint),
271        proactive_context: child
272            .output_hints
273            .proactive_context
274            .or(parent.output_hints.proactive_context),
275    };
276    Profile {
277        profile: ProfileMeta {
278            name: child.profile.name,
279            inherits: child.profile.inherits,
280            description: if child.profile.description.is_empty() {
281                parent.profile.description
282            } else {
283                child.profile.description
284            },
285        },
286        read,
287        compression,
288        memory,
289        verification,
290        routing,
291        autonomy,
292        output_hints,
293    }
294}
295
296/// Reads the `profile` key directly from `config.toml` without going through
297/// `Config::load()`. This avoids a reentrancy deadlock: `Config::load()` →
298/// `find_project_root()` (OnceLock) → `SessionState::load_latest()` →
299/// `normalize_loaded_session()` → `active_profile()` → here → `Config::load()`.
300fn profile_name_from_config_file() -> Option<String> {
301    let path = crate::core::config::Config::path()?;
302    let content = std::fs::read_to_string(path).ok()?;
303    let table: toml::Table = toml::from_str(&content).ok()?;
304    table
305        .get("profile")?
306        .as_str()
307        .map(str::trim)
308        .filter(|s| !s.is_empty())
309        .map(String::from)
310}
311
312/// Process-wide active-profile override set by [`set_active_profile`].
313///
314/// Takes precedence over `LEAN_CTX_PROFILE`. Storing the runtime selection in an
315/// in-process cell (rather than mutating the environment) keeps profile
316/// switching thread-safe inside the multi-threaded MCP server, where
317/// `set_active_profile` may run on a blocking-pool worker while other workers
318/// resolve the active profile concurrently.
319static ACTIVE_PROFILE_OVERRIDE: RwLock<Option<String>> = RwLock::new(None);
320
321/// Returns the currently active profile name.
322///
323/// Resolution order: in-process override (see `set_active_profile`) →
324/// `LEAN_CTX_PROFILE` env var → config.toml `profile` field → "coder".
325pub fn active_profile_name() -> String {
326    if let Some(name) = ACTIVE_PROFILE_OVERRIDE
327        .read()
328        .unwrap_or_else(std::sync::PoisonError::into_inner)
329        .clone()
330    {
331        return name;
332    }
333    if let Ok(v) = std::env::var("LEAN_CTX_PROFILE") {
334        let v = v.trim().to_string();
335        if !v.is_empty() {
336            return v;
337        }
338    }
339    if let Some(name) = profile_name_from_config_file() {
340        return name;
341    }
342    "coder".to_string()
343}
344
345/// Loads the currently active profile.
346pub fn active_profile() -> Profile {
347    let name = active_profile_name();
348    if let Some(p) = load_profile(&name) {
349        p
350    } else {
351        if name != "coder" {
352            tracing::warn!(
353                "Profile '{name}' not found (no built-in or disk file). \
354                 Falling back to 'coder'. Create it with: lean-ctx profile create {name}"
355            );
356        }
357        builtin_coder()
358    }
359}
360
361/// Sets the active profile for the current process.
362///
363/// Records the selection in a thread-safe in-process override (see
364/// [`active_profile_name`]) and returns the resolved profile after applying
365/// inheritance.
366pub fn set_active_profile(name: &str) -> Result<Profile, String> {
367    let name = name.trim();
368    if name.is_empty() {
369        return Err("profile name is empty".to_string());
370    }
371    let prev = active_profile_name();
372    let profile = load_profile(name).ok_or_else(|| format!("profile '{name}' not found"))?;
373    *ACTIVE_PROFILE_OVERRIDE
374        .write()
375        .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(name.to_string());
376    if prev != name {
377        crate::core::events::emit_profile_changed(&prev, name);
378    }
379    Ok(profile)
380}
381
382/// Lists all available profile names (built-in + on-disk).
383pub fn list_profiles() -> Vec<ProfileInfo> {
384    let mut profiles: HashMap<String, ProfileInfo> = HashMap::new();
385
386    for (name, p) in builtin_profiles() {
387        profiles.insert(
388            name.clone(),
389            ProfileInfo {
390                name,
391                description: p.profile.description,
392                source: ProfileSource::Builtin,
393            },
394        );
395    }
396
397    for (source, dir) in [
398        (ProfileSource::Global, profiles_dir_global()),
399        (ProfileSource::Project, profiles_dir_project()),
400    ] {
401        if let Some(dir) = dir
402            && let Ok(entries) = std::fs::read_dir(&dir)
403        {
404            for entry in entries.flatten() {
405                let path = entry.path();
406                if path.extension().and_then(|e| e.to_str()) == Some("toml")
407                    && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
408                {
409                    let name = stem.to_string();
410                    let desc = try_load_toml(&path)
411                        .map(|p| p.profile.description)
412                        .unwrap_or_default();
413                    profiles.insert(
414                        name.clone(),
415                        ProfileInfo {
416                            name,
417                            description: desc,
418                            source,
419                        },
420                    );
421                }
422            }
423        }
424    }
425
426    let mut result: Vec<ProfileInfo> = profiles.into_values().collect();
427    result.sort_by_key(|p| p.name.clone());
428    result
429}
430
431/// Information about an available profile.
432#[derive(Debug, Clone)]
433pub struct ProfileInfo {
434    pub name: String,
435    pub description: String,
436    pub source: ProfileSource,
437}
438
439/// Where a profile was loaded from.
440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
441pub enum ProfileSource {
442    Builtin,
443    Global,
444    Project,
445}
446
447impl std::fmt::Display for ProfileSource {
448    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449        match self {
450            Self::Builtin => write!(f, "built-in"),
451            Self::Global => write!(f, "global"),
452            Self::Project => write!(f, "project"),
453        }
454    }
455}
456
457/// Formats a profile as TOML for display or file creation.
458pub fn format_as_toml(profile: &Profile) -> String {
459    toml::to_string_pretty(profile).unwrap_or_else(|_| "[error serializing profile]".to_string())
460}