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
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 toml::from_str(&content).ok()
82}
83
84pub(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
363fn 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
379static ACTIVE_PROFILE_OVERRIDE: RwLock<Option<String>> = RwLock::new(None);
387
388pub 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
412pub 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
428pub 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
449pub 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#[derive(Debug, Clone)]
500pub struct ProfileInfo {
501 pub name: String,
502 pub description: String,
503 pub source: ProfileSource,
504}
505
506#[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
524pub fn format_as_toml(profile: &Profile) -> String {
526 toml::to_string_pretty(profile).unwrap_or_else(|_| "[error serializing profile]".to_string())
527}