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