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