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
587pub fn builtin_profiles() -> HashMap<String, Profile> {
589 let mut map = HashMap::new();
590 for p in [
591 builtin_coder(),
592 builtin_exploration(),
593 builtin_bugfix(),
594 builtin_hotfix(),
595 builtin_ci_debug(),
596 builtin_review(),
597 ] {
598 map.insert(p.profile.name.clone(), p);
599 }
600 map
601}
602
603fn profiles_dir_global() -> Option<PathBuf> {
606 crate::core::data_dir::lean_ctx_data_dir()
607 .ok()
608 .map(|d| d.join("profiles"))
609}
610
611fn profiles_dir_project() -> Option<PathBuf> {
612 let mut current = std::env::current_dir().ok()?;
613 for _ in 0..12 {
614 let candidate = current.join(".lean-ctx").join("profiles");
615 if candidate.is_dir() {
616 return Some(candidate);
617 }
618 if !current.pop() {
619 break;
620 }
621 }
622 None
623}
624
625pub fn load_profile(name: &str) -> Option<Profile> {
632 load_profile_recursive(name, 0)
633}
634
635fn load_profile_recursive(name: &str, depth: usize) -> Option<Profile> {
636 if depth > 5 {
637 return None;
638 }
639
640 let mut profile = load_profile_from_disk(name).or_else(|| builtin_profiles().remove(name))?;
641 profile.profile.name = name.to_string();
642
643 if let Some(ref parent_name) = profile.profile.inherits.clone() {
644 if let Some(parent) = load_profile_recursive(parent_name, depth + 1) {
645 profile = merge_profiles(parent, profile);
646 }
647 }
648
649 Some(profile)
650}
651
652fn load_profile_from_disk(name: &str) -> Option<Profile> {
653 let filename = format!("{name}.toml");
654
655 if let Some(project_dir) = profiles_dir_project() {
656 let path = project_dir.join(&filename);
657 if let Some(p) = try_load_toml(&path) {
658 return Some(p);
659 }
660 }
661
662 if let Some(global_dir) = profiles_dir_global() {
663 let path = global_dir.join(&filename);
664 if let Some(p) = try_load_toml(&path) {
665 return Some(p);
666 }
667 }
668
669 None
670}
671
672fn try_load_toml(path: &Path) -> Option<Profile> {
673 let content = std::fs::read_to_string(path).ok()?;
674 toml::from_str(&content).ok()
675}
676
677fn merge_profiles(parent: Profile, child: Profile) -> Profile {
683 let read = ReadConfig {
684 default_mode: child.read.default_mode.or(parent.read.default_mode),
685 max_tokens_per_file: child
686 .read
687 .max_tokens_per_file
688 .or(parent.read.max_tokens_per_file),
689 prefer_cache: child.read.prefer_cache.or(parent.read.prefer_cache),
690 };
691 let compression = CompressionConfig {
692 crp_mode: child.compression.crp_mode.or(parent.compression.crp_mode),
693 output_density: child
694 .compression
695 .output_density
696 .or(parent.compression.output_density),
697 entropy_threshold: child
698 .compression
699 .entropy_threshold
700 .or(parent.compression.entropy_threshold),
701 terse_mode: child
702 .compression
703 .terse_mode
704 .or(parent.compression.terse_mode),
705 };
706 let translation = TranslationConfig {
707 enabled: child.translation.enabled.or(parent.translation.enabled),
708 ruleset: child.translation.ruleset.or(parent.translation.ruleset),
709 };
710 let layout = LayoutConfig {
711 enabled: child.layout.enabled.or(parent.layout.enabled),
712 min_lines: child.layout.min_lines.or(parent.layout.min_lines),
713 };
714 let memory = crate::core::memory_policy::MemoryPolicyOverrides {
715 knowledge: crate::core::memory_policy::KnowledgePolicyOverrides {
716 max_facts: child
717 .memory
718 .knowledge
719 .max_facts
720 .or(parent.memory.knowledge.max_facts),
721 max_patterns: child
722 .memory
723 .knowledge
724 .max_patterns
725 .or(parent.memory.knowledge.max_patterns),
726 max_history: child
727 .memory
728 .knowledge
729 .max_history
730 .or(parent.memory.knowledge.max_history),
731 contradiction_threshold: child
732 .memory
733 .knowledge
734 .contradiction_threshold
735 .or(parent.memory.knowledge.contradiction_threshold),
736 recall_facts_limit: child
737 .memory
738 .knowledge
739 .recall_facts_limit
740 .or(parent.memory.knowledge.recall_facts_limit),
741 rooms_limit: child
742 .memory
743 .knowledge
744 .rooms_limit
745 .or(parent.memory.knowledge.rooms_limit),
746 timeline_limit: child
747 .memory
748 .knowledge
749 .timeline_limit
750 .or(parent.memory.knowledge.timeline_limit),
751 relations_limit: child
752 .memory
753 .knowledge
754 .relations_limit
755 .or(parent.memory.knowledge.relations_limit),
756 },
757 lifecycle: crate::core::memory_policy::LifecyclePolicyOverrides {
758 decay_rate: child
759 .memory
760 .lifecycle
761 .decay_rate
762 .or(parent.memory.lifecycle.decay_rate),
763 low_confidence_threshold: child
764 .memory
765 .lifecycle
766 .low_confidence_threshold
767 .or(parent.memory.lifecycle.low_confidence_threshold),
768 stale_days: child
769 .memory
770 .lifecycle
771 .stale_days
772 .or(parent.memory.lifecycle.stale_days),
773 similarity_threshold: child
774 .memory
775 .lifecycle
776 .similarity_threshold
777 .or(parent.memory.lifecycle.similarity_threshold),
778 },
779 };
780 let verification = crate::core::output_verification::VerificationConfig {
781 enabled: child.verification.enabled.or(parent.verification.enabled),
782 mode: child.verification.mode.or(parent.verification.mode),
783 strict_mode: child
784 .verification
785 .strict_mode
786 .or(parent.verification.strict_mode),
787 check_paths: child
788 .verification
789 .check_paths
790 .or(parent.verification.check_paths),
791 check_identifiers: child
792 .verification
793 .check_identifiers
794 .or(parent.verification.check_identifiers),
795 check_line_numbers: child
796 .verification
797 .check_line_numbers
798 .or(parent.verification.check_line_numbers),
799 check_structure: child
800 .verification
801 .check_structure
802 .or(parent.verification.check_structure),
803 };
804 let budget = BudgetConfig {
805 max_context_tokens: child
806 .budget
807 .max_context_tokens
808 .or(parent.budget.max_context_tokens),
809 max_shell_invocations: child
810 .budget
811 .max_shell_invocations
812 .or(parent.budget.max_shell_invocations),
813 max_cost_usd: child.budget.max_cost_usd.or(parent.budget.max_cost_usd),
814 };
815 let pipeline = PipelineConfig {
816 intent: child.pipeline.intent.or(parent.pipeline.intent),
817 relevance: child.pipeline.relevance.or(parent.pipeline.relevance),
818 compression: child.pipeline.compression.or(parent.pipeline.compression),
819 translation: child.pipeline.translation.or(parent.pipeline.translation),
820 };
821 let routing = RoutingConfig {
822 max_model_tier: child
823 .routing
824 .max_model_tier
825 .or(parent.routing.max_model_tier),
826 degrade_under_pressure: child
827 .routing
828 .degrade_under_pressure
829 .or(parent.routing.degrade_under_pressure),
830 };
831 let degradation = DegradationConfig {
832 enforce: child.degradation.enforce.or(parent.degradation.enforce),
833 throttle_ms: child
834 .degradation
835 .throttle_ms
836 .or(parent.degradation.throttle_ms),
837 };
838 let autonomy = ProfileAutonomy {
839 enabled: child.autonomy.enabled.or(parent.autonomy.enabled),
840 auto_preload: child.autonomy.auto_preload.or(parent.autonomy.auto_preload),
841 auto_dedup: child.autonomy.auto_dedup.or(parent.autonomy.auto_dedup),
842 auto_related: child.autonomy.auto_related.or(parent.autonomy.auto_related),
843 silent_preload: child
844 .autonomy
845 .silent_preload
846 .or(parent.autonomy.silent_preload),
847 auto_prefetch: child
848 .autonomy
849 .auto_prefetch
850 .or(parent.autonomy.auto_prefetch),
851 auto_response: child
852 .autonomy
853 .auto_response
854 .or(parent.autonomy.auto_response),
855 dedup_threshold: child
856 .autonomy
857 .dedup_threshold
858 .or(parent.autonomy.dedup_threshold),
859 prefetch_max_files: child
860 .autonomy
861 .prefetch_max_files
862 .or(parent.autonomy.prefetch_max_files),
863 prefetch_budget_tokens: child
864 .autonomy
865 .prefetch_budget_tokens
866 .or(parent.autonomy.prefetch_budget_tokens),
867 response_min_tokens: child
868 .autonomy
869 .response_min_tokens
870 .or(parent.autonomy.response_min_tokens),
871 checkpoint_interval: child
872 .autonomy
873 .checkpoint_interval
874 .or(parent.autonomy.checkpoint_interval),
875 };
876 let output_hints = OutputHints {
877 compressed_hint: child
878 .output_hints
879 .compressed_hint
880 .or(parent.output_hints.compressed_hint),
881 archive_hint: child
882 .output_hints
883 .archive_hint
884 .or(parent.output_hints.archive_hint),
885 verify_footer: child
886 .output_hints
887 .verify_footer
888 .or(parent.output_hints.verify_footer),
889 related_hint: child
890 .output_hints
891 .related_hint
892 .or(parent.output_hints.related_hint),
893 semantic_hint: child
894 .output_hints
895 .semantic_hint
896 .or(parent.output_hints.semantic_hint),
897 elicitation_hint: child
898 .output_hints
899 .elicitation_hint
900 .or(parent.output_hints.elicitation_hint),
901 checkpoint_in_output: child
902 .output_hints
903 .checkpoint_in_output
904 .or(parent.output_hints.checkpoint_in_output),
905 graph_context_block: child
906 .output_hints
907 .graph_context_block
908 .or(parent.output_hints.graph_context_block),
909 efficiency_hint: child
910 .output_hints
911 .efficiency_hint
912 .or(parent.output_hints.efficiency_hint),
913 };
914 Profile {
915 profile: ProfileMeta {
916 name: child.profile.name,
917 inherits: child.profile.inherits,
918 description: if child.profile.description.is_empty() {
919 parent.profile.description
920 } else {
921 child.profile.description
922 },
923 },
924 read,
925 compression,
926 translation,
927 layout,
928 memory,
929 verification,
930 budget,
931 pipeline,
932 routing,
933 degradation,
934 autonomy,
935 output_hints,
936 }
937}
938
939pub fn active_profile_name() -> String {
941 std::env::var("LEAN_CTX_PROFILE")
942 .ok()
943 .filter(|s| !s.trim().is_empty())
944 .unwrap_or_else(|| "coder".to_string())
945}
946
947pub fn active_profile() -> Profile {
949 let name = active_profile_name();
950 load_profile(&name).unwrap_or_else(builtin_coder)
951}
952
953pub fn set_active_profile(name: &str) -> Result<Profile, String> {
957 let name = name.trim();
958 if name.is_empty() {
959 return Err("profile name is empty".to_string());
960 }
961 let prev = active_profile_name();
962 let profile = load_profile(name).ok_or_else(|| format!("profile '{name}' not found"))?;
963 std::env::set_var("LEAN_CTX_PROFILE", name);
964 if prev != name {
965 crate::core::events::emit_profile_changed(&prev, name);
966 }
967 Ok(profile)
968}
969
970pub fn list_profiles() -> Vec<ProfileInfo> {
972 let mut profiles: HashMap<String, ProfileInfo> = HashMap::new();
973
974 for (name, p) in builtin_profiles() {
975 profiles.insert(
976 name.clone(),
977 ProfileInfo {
978 name,
979 description: p.profile.description,
980 source: ProfileSource::Builtin,
981 },
982 );
983 }
984
985 for (source, dir) in [
986 (ProfileSource::Global, profiles_dir_global()),
987 (ProfileSource::Project, profiles_dir_project()),
988 ] {
989 if let Some(dir) = dir {
990 if let Ok(entries) = std::fs::read_dir(&dir) {
991 for entry in entries.flatten() {
992 let path = entry.path();
993 if path.extension().and_then(|e| e.to_str()) == Some("toml") {
994 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
995 let name = stem.to_string();
996 let desc = try_load_toml(&path)
997 .map(|p| p.profile.description)
998 .unwrap_or_default();
999 profiles.insert(
1000 name.clone(),
1001 ProfileInfo {
1002 name,
1003 description: desc,
1004 source,
1005 },
1006 );
1007 }
1008 }
1009 }
1010 }
1011 }
1012 }
1013
1014 let mut result: Vec<ProfileInfo> = profiles.into_values().collect();
1015 result.sort_by_key(|p| p.name.clone());
1016 result
1017}
1018
1019#[derive(Debug, Clone)]
1021pub struct ProfileInfo {
1022 pub name: String,
1023 pub description: String,
1024 pub source: ProfileSource,
1025}
1026
1027#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1029pub enum ProfileSource {
1030 Builtin,
1031 Global,
1032 Project,
1033}
1034
1035impl std::fmt::Display for ProfileSource {
1036 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1037 match self {
1038 Self::Builtin => write!(f, "built-in"),
1039 Self::Global => write!(f, "global"),
1040 Self::Project => write!(f, "project"),
1041 }
1042 }
1043}
1044
1045pub fn format_as_toml(profile: &Profile) -> String {
1047 toml::to_string_pretty(profile).unwrap_or_else(|_| "[error serializing profile]".to_string())
1048}
1049
1050#[cfg(test)]
1053mod tests {
1054 use super::*;
1055
1056 #[test]
1057 fn builtin_profiles_count() {
1058 let builtins = builtin_profiles();
1059 assert_eq!(builtins.len(), 6);
1060 assert!(builtins.contains_key("coder"));
1061 assert!(builtins.contains_key("exploration"));
1062 assert!(builtins.contains_key("bugfix"));
1063 assert!(builtins.contains_key("hotfix"));
1064 assert!(builtins.contains_key("ci-debug"));
1065 assert!(builtins.contains_key("review"));
1066 }
1067
1068 #[test]
1069 fn hotfix_has_minimal_budget() {
1070 let p = builtin_profiles().remove("hotfix").unwrap();
1071 assert_eq!(p.budget.max_context_tokens_effective(), 30_000);
1072 assert_eq!(p.budget.max_shell_invocations_effective(), 20);
1073 assert_eq!(p.read.default_mode_effective(), "signatures");
1074 assert_eq!(p.compression.output_density_effective(), "ultra");
1075 }
1076
1077 #[test]
1078 fn exploration_has_broad_context() {
1079 let p = builtin_profiles().remove("exploration").unwrap();
1080 assert_eq!(p.budget.max_context_tokens_effective(), 200_000);
1081 assert_eq!(p.read.default_mode_effective(), "map");
1082 assert!(p.read.prefer_cache_effective());
1083 }
1084
1085 #[test]
1086 fn profile_roundtrip_toml() {
1087 let original = builtin_exploration();
1088 let toml_str = format_as_toml(&original);
1089 let parsed: Profile = toml::from_str(&toml_str).unwrap();
1090 assert_eq!(parsed.profile.name, "exploration");
1091 assert_eq!(parsed.read.default_mode_effective(), "map");
1092 assert_eq!(parsed.budget.max_context_tokens_effective(), 200_000);
1093 }
1094
1095 #[test]
1096 fn merge_child_overrides_parent() {
1097 let parent = builtin_exploration();
1098 let child = Profile {
1099 profile: ProfileMeta {
1100 name: "custom".to_string(),
1101 inherits: Some("exploration".to_string()),
1102 description: String::new(),
1103 },
1104 read: ReadConfig {
1105 default_mode: Some("signatures".to_string()),
1106 ..ReadConfig::default()
1107 },
1108 compression: CompressionConfig::default(),
1109 translation: TranslationConfig::default(),
1110 layout: LayoutConfig::default(),
1111 memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
1112 verification: crate::core::output_verification::VerificationConfig::default(),
1113 budget: BudgetConfig {
1114 max_context_tokens: Some(10_000),
1115 ..BudgetConfig::default()
1116 },
1117 pipeline: PipelineConfig::default(),
1118 routing: RoutingConfig::default(),
1119 degradation: DegradationConfig::default(),
1120 autonomy: ProfileAutonomy::default(),
1121 output_hints: OutputHints::default(),
1122 };
1123
1124 let merged = merge_profiles(parent, child);
1125 assert_eq!(merged.read.default_mode_effective(), "signatures");
1126 assert_eq!(merged.budget.max_context_tokens_effective(), 10_000);
1127 assert_eq!(
1128 merged.profile.description,
1129 "Broad context for understanding codebases"
1130 );
1131 }
1132
1133 #[test]
1134 fn merge_partial_child_inherits_parent_fields() {
1135 let parent = builtin_exploration();
1136 let child = Profile {
1137 profile: ProfileMeta {
1138 name: "partial".to_string(),
1139 inherits: Some("exploration".to_string()),
1140 description: String::new(),
1141 },
1142 read: ReadConfig {
1143 default_mode: Some("map".to_string()),
1144 ..ReadConfig::default()
1145 },
1146 compression: CompressionConfig::default(),
1147 translation: TranslationConfig::default(),
1148 layout: LayoutConfig::default(),
1149 memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
1150 verification: crate::core::output_verification::VerificationConfig::default(),
1151 budget: BudgetConfig::default(),
1152 pipeline: PipelineConfig::default(),
1153 routing: RoutingConfig::default(),
1154 degradation: DegradationConfig::default(),
1155 autonomy: ProfileAutonomy::default(),
1156 output_hints: OutputHints::default(),
1157 };
1158
1159 let merged = merge_profiles(parent, child);
1160 assert_eq!(merged.read.default_mode_effective(), "map");
1161 assert_eq!(
1162 merged.read.max_tokens_per_file_effective(),
1163 80_000,
1164 "should inherit max_tokens_per_file from parent"
1165 );
1166 assert!(
1167 merged.read.prefer_cache_effective(),
1168 "should inherit prefer_cache from parent"
1169 );
1170 assert_eq!(
1171 merged.budget.max_context_tokens_effective(),
1172 200_000,
1173 "should inherit budget from parent"
1174 );
1175 }
1176
1177 #[test]
1178 fn load_builtin_by_name() {
1179 let p = load_profile("hotfix").unwrap();
1180 assert_eq!(p.profile.name, "hotfix");
1181 assert_eq!(p.read.default_mode_effective(), "signatures");
1182 }
1183
1184 #[test]
1185 fn load_nonexistent_returns_none() {
1186 assert!(load_profile("does-not-exist-xyz").is_none());
1187 }
1188
1189 #[test]
1190 fn list_profiles_includes_builtins() {
1191 let list = list_profiles();
1192 assert!(list.len() >= 5);
1193 let names: Vec<&str> = list.iter().map(|p| p.name.as_str()).collect();
1194 assert!(names.contains(&"exploration"));
1195 assert!(names.contains(&"hotfix"));
1196 assert!(names.contains(&"review"));
1197 }
1198
1199 #[test]
1200 fn active_profile_defaults_to_coder() {
1201 let _lock = crate::core::data_dir::test_env_lock();
1202 std::env::remove_var("LEAN_CTX_PROFILE");
1203 let p = active_profile();
1204 assert_eq!(p.profile.name, "coder");
1205 }
1206
1207 #[test]
1208 fn active_profile_from_env() {
1209 let _lock = crate::core::data_dir::test_env_lock();
1210 std::env::set_var("LEAN_CTX_PROFILE", "hotfix");
1211 let name = active_profile_name();
1212 assert_eq!(name, "hotfix");
1213 std::env::remove_var("LEAN_CTX_PROFILE");
1214 }
1215
1216 #[test]
1217 fn profile_source_display() {
1218 assert_eq!(ProfileSource::Builtin.to_string(), "built-in");
1219 assert_eq!(ProfileSource::Global.to_string(), "global");
1220 assert_eq!(ProfileSource::Project.to_string(), "project");
1221 }
1222
1223 #[test]
1224 fn default_profile_has_sane_values() {
1225 let p = Profile {
1226 profile: ProfileMeta::default(),
1227 read: ReadConfig::default(),
1228 compression: CompressionConfig::default(),
1229 translation: TranslationConfig::default(),
1230 layout: LayoutConfig::default(),
1231 memory: crate::core::memory_policy::MemoryPolicyOverrides::default(),
1232 verification: crate::core::output_verification::VerificationConfig::default(),
1233 budget: BudgetConfig::default(),
1234 pipeline: PipelineConfig::default(),
1235 routing: RoutingConfig::default(),
1236 degradation: DegradationConfig::default(),
1237 autonomy: ProfileAutonomy::default(),
1238 output_hints: OutputHints::default(),
1239 };
1240 assert_eq!(p.read.default_mode_effective(), "auto");
1241 assert_eq!(p.compression.crp_mode_effective(), "tdd");
1242 assert_eq!(p.budget.max_context_tokens_effective(), 200_000);
1243 assert!(p.pipeline.compression_effective());
1244 assert!(p.pipeline.intent_effective());
1245 }
1246
1247 #[test]
1248 fn pipeline_layers_configurable() {
1249 let toml_str = r#"
1250[profile]
1251name = "no-intent"
1252
1253[pipeline]
1254intent = false
1255relevance = false
1256"#;
1257 let p: Profile = toml::from_str(toml_str).unwrap();
1258 assert!(!p.pipeline.intent_effective());
1259 assert!(!p.pipeline.relevance_effective());
1260 assert!(p.pipeline.compression_effective());
1261 assert!(p.pipeline.translation_effective());
1262 }
1263
1264 #[test]
1265 fn partial_toml_fills_defaults() {
1266 let toml_str = r#"
1267[profile]
1268name = "minimal"
1269
1270[read]
1271default_mode = "entropy"
1272"#;
1273 let p: Profile = toml::from_str(toml_str).unwrap();
1274 assert_eq!(p.read.default_mode_effective(), "entropy");
1275 assert_eq!(p.read.max_tokens_per_file_effective(), 50_000);
1276 assert_eq!(p.budget.max_context_tokens_effective(), 200_000);
1277 assert_eq!(p.compression.crp_mode_effective(), "tdd");
1278 }
1279
1280 #[test]
1281 fn partial_toml_leaves_unset_as_none() {
1282 let toml_str = r#"
1283[profile]
1284name = "sparse"
1285
1286[read]
1287default_mode = "map"
1288"#;
1289 let p: Profile = toml::from_str(toml_str).unwrap();
1290 assert_eq!(p.read.default_mode, Some("map".to_string()));
1291 assert_eq!(p.read.max_tokens_per_file, None);
1292 assert_eq!(p.read.prefer_cache, None);
1293 assert_eq!(p.budget.max_context_tokens, None);
1294 assert_eq!(p.compression.crp_mode, None);
1295 }
1296}