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
10fn 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
32pub 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
86fn 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
104pub(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
296fn 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
312static ACTIVE_PROFILE_OVERRIDE: RwLock<Option<String>> = RwLock::new(None);
320
321pub 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
345pub 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
361pub 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
382pub 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#[derive(Debug, Clone)]
433pub struct ProfileInfo {
434 pub name: String,
435 pub description: String,
436 pub source: ProfileSource,
437}
438
439#[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
457pub fn format_as_toml(profile: &Profile) -> String {
459 toml::to_string_pretty(profile).unwrap_or_else(|_| "[error serializing profile]".to_string())
460}