1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::resources::SoulDoc;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct SoulTunableSpec {
9 pub label: &'static str,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SoulTunableState {
14 Preset(usize),
15 Edited,
16 Missing,
17}
18
19pub const SOUL_TUNABLE_SPECS: &[SoulTunableSpec] = &[
20 SoulTunableSpec { label: "Autonomy" },
21 SoulTunableSpec { label: "Brevity" },
22 SoulTunableSpec { label: "Caution" },
23 SoulTunableSpec { label: "Warmth" },
24 SoulTunableSpec { label: "Planning" },
25];
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
28#[serde(rename_all = "kebab-case")]
29pub enum PersonaFocus {
30 #[default]
31 Coding,
32 Engineering,
33 Software,
34 Debugging,
35 Research,
36 Writing,
37 Planning,
38 Operations,
39 Analysis,
40 General,
41}
42
43impl PersonaFocus {
44 pub const ALL: &'static [Self] = &[
45 Self::Coding,
46 Self::Engineering,
47 Self::Software,
48 Self::Debugging,
49 Self::Research,
50 Self::Writing,
51 Self::Planning,
52 Self::Operations,
53 Self::Analysis,
54 Self::General,
55 ];
56
57 pub fn as_str(&self) -> &'static str {
58 match self {
59 Self::Coding => "coding",
60 Self::Engineering => "engineering",
61 Self::Software => "software",
62 Self::Debugging => "debugging",
63 Self::Research => "research",
64 Self::Writing => "writing",
65 Self::Planning => "planning",
66 Self::Operations => "operations",
67 Self::Analysis => "analysis",
68 Self::General => "general",
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
74#[serde(rename_all = "kebab-case")]
75pub enum PersonaRole {
76 #[default]
77 Agent,
78 Assistant,
79 Worker,
80 Collaborator,
81 Partner,
82 Reviewer,
83 Planner,
84}
85
86impl PersonaRole {
87 pub const ALL: &'static [Self] = &[
88 Self::Agent,
89 Self::Assistant,
90 Self::Worker,
91 Self::Collaborator,
92 Self::Partner,
93 Self::Reviewer,
94 Self::Planner,
95 ];
96
97 pub fn as_str(&self) -> &'static str {
98 match self {
99 Self::Agent => "agent",
100 Self::Assistant => "assistant",
101 Self::Worker => "worker",
102 Self::Collaborator => "collaborator",
103 Self::Partner => "partner",
104 Self::Reviewer => "reviewer",
105 Self::Planner => "planner",
106 }
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
111#[serde(rename_all = "kebab-case")]
112pub enum WorkStyleWord {
113 #[default]
114 Practical,
115 Careful,
116 Disciplined,
117 Methodical,
118 Focused,
119 Thorough,
120 Precise,
121 Deliberate,
122 Skeptical,
123 Patient,
124}
125
126impl WorkStyleWord {
127 pub const ALL: &'static [Self] = &[
128 Self::Practical,
129 Self::Careful,
130 Self::Disciplined,
131 Self::Methodical,
132 Self::Focused,
133 Self::Thorough,
134 Self::Precise,
135 Self::Deliberate,
136 Self::Skeptical,
137 Self::Patient,
138 ];
139
140 pub fn as_str(&self) -> &'static str {
141 match self {
142 Self::Practical => "practical",
143 Self::Careful => "careful",
144 Self::Disciplined => "disciplined",
145 Self::Methodical => "methodical",
146 Self::Focused => "focused",
147 Self::Thorough => "thorough",
148 Self::Precise => "precise",
149 Self::Deliberate => "deliberate",
150 Self::Skeptical => "skeptical",
151 Self::Patient => "patient",
152 }
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
157#[serde(rename_all = "kebab-case")]
158pub enum VoiceWord {
159 #[default]
160 Concise,
161 Clear,
162 Direct,
163 Calm,
164 Thoughtful,
165 Collaborative,
166 Structured,
167 Friendly,
168 Terse,
169 Warm,
170}
171
172impl VoiceWord {
173 pub const ALL: &'static [Self] = &[
174 Self::Concise,
175 Self::Clear,
176 Self::Direct,
177 Self::Calm,
178 Self::Thoughtful,
179 Self::Collaborative,
180 Self::Structured,
181 Self::Friendly,
182 Self::Terse,
183 Self::Warm,
184 ];
185
186 pub fn as_str(&self) -> &'static str {
187 match self {
188 Self::Concise => "concise",
189 Self::Clear => "clear",
190 Self::Direct => "direct",
191 Self::Calm => "calm",
192 Self::Thoughtful => "thoughtful",
193 Self::Collaborative => "collaborative",
194 Self::Structured => "structured",
195 Self::Friendly => "friendly",
196 Self::Terse => "terse",
197 Self::Warm => "warm",
198 }
199 }
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
203#[serde(rename_all = "kebab-case")]
204pub enum PersonalityBand {
205 VeryLow,
206 Low,
207 #[default]
208 Medium,
209 High,
210 VeryHigh,
211}
212
213impl PersonalityBand {
214 pub const ALL: &'static [Self] = &[
215 Self::VeryLow,
216 Self::Low,
217 Self::Medium,
218 Self::High,
219 Self::VeryHigh,
220 ];
221
222 pub fn as_str(&self) -> &'static str {
223 match self {
224 Self::VeryLow => "very-low",
225 Self::Low => "low",
226 Self::Medium => "medium",
227 Self::High => "high",
228 Self::VeryHigh => "very-high",
229 }
230 }
231
232 pub fn ui_label(&self) -> &'static str {
233 match self {
234 Self::VeryLow => "very low",
235 Self::Low => "low",
236 Self::Medium => "balanced",
237 Self::High => "high",
238 Self::VeryHigh => "very high",
239 }
240 }
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub struct PersonalityOption {
245 pub value: &'static str,
246 pub label: &'static str,
247 pub hint: &'static str,
248}
249
250pub const WORK_STYLE_OPTIONS: &[PersonalityOption] = &[
251 PersonalityOption {
252 value: "practical",
253 label: "practical",
254 hint: "favor concrete progress over theory",
255 },
256 PersonalityOption {
257 value: "careful",
258 label: "careful",
259 hint: "avoid careless changes and read closely",
260 },
261 PersonalityOption {
262 value: "disciplined",
263 label: "disciplined",
264 hint: "stay consistent and procedure-minded",
265 },
266 PersonalityOption {
267 value: "methodical",
268 label: "methodical",
269 hint: "work step by step",
270 },
271 PersonalityOption {
272 value: "focused",
273 label: "focused",
274 hint: "minimize drift and stay on task",
275 },
276 PersonalityOption {
277 value: "thorough",
278 label: "thorough",
279 hint: "inspect details before concluding",
280 },
281 PersonalityOption {
282 value: "precise",
283 label: "precise",
284 hint: "optimize for exactness",
285 },
286 PersonalityOption {
287 value: "deliberate",
288 label: "deliberate",
289 hint: "slow down before important choices",
290 },
291 PersonalityOption {
292 value: "skeptical",
293 label: "skeptical",
294 hint: "challenge assumptions and verify them",
295 },
296 PersonalityOption {
297 value: "patient",
298 label: "patient",
299 hint: "take the time a problem needs",
300 },
301];
302
303pub const VOICE_OPTIONS: &[PersonalityOption] = &[
304 PersonalityOption {
305 value: "concise",
306 label: "concise",
307 hint: "keep responses compact",
308 },
309 PersonalityOption {
310 value: "clear",
311 label: "clear",
312 hint: "optimize for easy understanding",
313 },
314 PersonalityOption {
315 value: "direct",
316 label: "direct",
317 hint: "be straightforward and plainspoken",
318 },
319 PersonalityOption {
320 value: "calm",
321 label: "calm",
322 hint: "stay steady and unflustered",
323 },
324 PersonalityOption {
325 value: "thoughtful",
326 label: "thoughtful",
327 hint: "show measured consideration",
328 },
329 PersonalityOption {
330 value: "collaborative",
331 label: "collaborative",
332 hint: "feel like a teammate",
333 },
334 PersonalityOption {
335 value: "structured",
336 label: "structured",
337 hint: "organize responses cleanly",
338 },
339 PersonalityOption {
340 value: "friendly",
341 label: "friendly",
342 hint: "be approachable without fluff",
343 },
344 PersonalityOption {
345 value: "terse",
346 label: "terse",
347 hint: "be extremely brief",
348 },
349 PersonalityOption {
350 value: "warm",
351 label: "warm",
352 hint: "be supportive and human",
353 },
354];
355
356pub const FOCUS_OPTIONS: &[PersonalityOption] = &[
357 PersonalityOption {
358 value: "coding",
359 label: "coding",
360 hint: "default toward software implementation work",
361 },
362 PersonalityOption {
363 value: "engineering",
364 label: "engineering",
365 hint: "broader technical systems work",
366 },
367 PersonalityOption {
368 value: "software",
369 label: "software",
370 hint: "general software problem solving",
371 },
372 PersonalityOption {
373 value: "debugging",
374 label: "debugging",
375 hint: "default toward diagnosis and repair",
376 },
377 PersonalityOption {
378 value: "research",
379 label: "research",
380 hint: "default toward investigation and synthesis",
381 },
382 PersonalityOption {
383 value: "writing",
384 label: "writing",
385 hint: "default toward prose and editing",
386 },
387 PersonalityOption {
388 value: "planning",
389 label: "planning",
390 hint: "default toward breakdown and sequencing",
391 },
392 PersonalityOption {
393 value: "operations",
394 label: "operations",
395 hint: "default toward runbooks and systems ops",
396 },
397 PersonalityOption {
398 value: "analysis",
399 label: "analysis",
400 hint: "default toward reasoning and evaluation",
401 },
402 PersonalityOption {
403 value: "general",
404 label: "general",
405 hint: "stay broadly applicable",
406 },
407];
408
409pub const ROLE_OPTIONS: &[PersonalityOption] = &[
410 PersonalityOption {
411 value: "agent",
412 label: "agent",
413 hint: "active and tool-using",
414 },
415 PersonalityOption {
416 value: "assistant",
417 label: "assistant",
418 hint: "more consultative and supportive",
419 },
420 PersonalityOption {
421 value: "worker",
422 label: "worker",
423 hint: "task-oriented and execution-focused",
424 },
425 PersonalityOption {
426 value: "collaborator",
427 label: "collaborator",
428 hint: "peer-like and cooperative",
429 },
430 PersonalityOption {
431 value: "partner",
432 label: "partner",
433 hint: "close and team-oriented",
434 },
435 PersonalityOption {
436 value: "reviewer",
437 label: "reviewer",
438 hint: "critical and evaluative",
439 },
440 PersonalityOption {
441 value: "planner",
442 label: "planner",
443 hint: "organizational and sequencing-focused",
444 },
445];
446
447#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
448pub struct PersonalityIdentity {
449 #[serde(default = "default_personality_name")]
450 pub name: String,
451 #[serde(default)]
452 pub work_style: WorkStyleWord,
453 #[serde(default)]
454 pub voice: VoiceWord,
455 #[serde(default)]
456 pub focus: PersonaFocus,
457 #[serde(default)]
458 pub role: PersonaRole,
459}
460
461impl PersonalityIdentity {
462 pub fn render_sentence(&self) -> String {
463 format!(
464 "You are {}, a {}, {}, {} {}.",
465 self.name,
466 self.work_style.as_str(),
467 self.voice.as_str(),
468 self.focus.as_str(),
469 self.role.as_str()
470 )
471 }
472}
473
474impl Default for PersonalityIdentity {
475 fn default() -> Self {
476 Self {
477 name: default_personality_name(),
478 work_style: WorkStyleWord::default(),
479 voice: VoiceWord::default(),
480 focus: PersonaFocus::default(),
481 role: PersonaRole::default(),
482 }
483 }
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487pub struct PersonalitySliders {
488 #[serde(default)]
489 pub autonomy: PersonalityBand,
490 #[serde(default)]
491 pub verbosity: PersonalityBand,
492 #[serde(default)]
493 pub caution: PersonalityBand,
494 #[serde(default)]
495 pub warmth: PersonalityBand,
496 #[serde(default)]
497 pub planning_depth: PersonalityBand,
498}
499
500impl Default for PersonalitySliders {
501 fn default() -> Self {
502 Self {
503 autonomy: PersonalityBand::High,
504 verbosity: PersonalityBand::Low,
505 caution: PersonalityBand::High,
506 warmth: PersonalityBand::Medium,
507 planning_depth: PersonalityBand::Medium,
508 }
509 }
510}
511
512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
513pub struct PersonalityProfile {
514 #[serde(default)]
515 pub identity: PersonalityIdentity,
516 #[serde(default)]
517 pub sliders: PersonalitySliders,
518}
519
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
521pub struct PersonalityProfiles {
522 #[serde(default)]
523 pub active: Option<String>,
524 #[serde(default)]
525 pub saved: std::collections::BTreeMap<String, PersonalityProfile>,
526}
527
528impl PersonalityProfiles {
529 pub fn active_profile(&self) -> Option<&PersonalityProfile> {
530 self.active
531 .as_ref()
532 .and_then(|name| self.saved.get(name.as_str()))
533 }
534
535 pub fn set_active(&mut self, name: impl Into<String>) {
536 self.active = Some(name.into());
537 }
538
539 pub fn clear_active(&mut self) {
540 self.active = None;
541 }
542
543 pub fn save_profile(&mut self, name: impl Into<String>, profile: PersonalityProfile) -> String {
544 let name = sanitize_profile_name(&name.into());
545 self.saved.insert(name.clone(), profile);
546 self.active = Some(name.clone());
547 name
548 }
549
550 pub fn delete_profile(&mut self, name: &str) -> bool {
551 let removed = self.saved.remove(name).is_some();
552 if self.active.as_deref() == Some(name) {
553 self.active = None;
554 }
555 removed
556 }
557
558 pub fn rename_profile(&mut self, from: &str, to: impl Into<String>) -> bool {
559 let Some(profile) = self.saved.remove(from) else {
560 return false;
561 };
562 let to = sanitize_profile_name(&to.into());
563 self.saved.insert(to.clone(), profile);
564 if self.active.as_deref() == Some(from) {
565 self.active = Some(to);
566 }
567 true
568 }
569
570 pub fn profile_names(&self) -> Vec<String> {
571 self.saved.keys().cloned().collect()
572 }
573}
574
575#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
576pub struct PersonalityConfig {
577 #[serde(default)]
578 pub profile: PersonalityProfile,
579 #[serde(default)]
580 pub profiles: PersonalityProfiles,
581}
582
583impl PersonalityConfig {
584 pub fn merge(&mut self, other: Self) {
585 self.profile = other.profile;
586 if other.profiles.active.is_some() {
587 self.profiles.active = other.profiles.active;
588 }
589 self.profiles.saved.extend(other.profiles.saved);
590 }
591
592 pub fn effective_profile(&self) -> &PersonalityProfile {
593 self.profiles.active_profile().unwrap_or(&self.profile)
594 }
595}
596
597fn sanitize_profile_name(name: &str) -> String {
598 let trimmed = name.trim();
599 if trimmed.is_empty() {
600 return "profile".to_string();
601 }
602 trimmed
603 .chars()
604 .map(|c| {
605 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ' ' {
606 c
607 } else {
608 '-'
609 }
610 })
611 .collect::<String>()
612 .trim()
613 .to_string()
614}
615
616pub fn tunable_variants_for_label(label: &str) -> Option<[&'static str; 5]> {
617 match label {
618 "Autonomy" => Some([
619 crate::system_prompt::autonomy_line(PersonalityBand::VeryLow),
620 crate::system_prompt::autonomy_line(PersonalityBand::Low),
621 crate::system_prompt::autonomy_line(PersonalityBand::Medium),
622 crate::system_prompt::autonomy_line(PersonalityBand::High),
623 crate::system_prompt::autonomy_line(PersonalityBand::VeryHigh),
624 ]),
625 "Brevity" => Some([
626 crate::system_prompt::verbosity_line(PersonalityBand::VeryLow),
627 crate::system_prompt::verbosity_line(PersonalityBand::Low),
628 crate::system_prompt::verbosity_line(PersonalityBand::Medium),
629 crate::system_prompt::verbosity_line(PersonalityBand::High),
630 crate::system_prompt::verbosity_line(PersonalityBand::VeryHigh),
631 ]),
632 "Caution" => Some([
633 crate::system_prompt::caution_line(PersonalityBand::VeryLow),
634 crate::system_prompt::caution_line(PersonalityBand::Low),
635 crate::system_prompt::caution_line(PersonalityBand::Medium),
636 crate::system_prompt::caution_line(PersonalityBand::High),
637 crate::system_prompt::caution_line(PersonalityBand::VeryHigh),
638 ]),
639 "Warmth" => Some([
640 crate::system_prompt::warmth_line(PersonalityBand::VeryLow),
641 crate::system_prompt::warmth_line(PersonalityBand::Low),
642 crate::system_prompt::warmth_line(PersonalityBand::Medium),
643 crate::system_prompt::warmth_line(PersonalityBand::High),
644 crate::system_prompt::warmth_line(PersonalityBand::VeryHigh),
645 ]),
646 "Planning" => Some([
647 crate::system_prompt::planning_depth_line(PersonalityBand::VeryLow),
648 crate::system_prompt::planning_depth_line(PersonalityBand::Low),
649 crate::system_prompt::planning_depth_line(PersonalityBand::Medium),
650 crate::system_prompt::planning_depth_line(PersonalityBand::High),
651 crate::system_prompt::planning_depth_line(PersonalityBand::VeryHigh),
652 ]),
653 _ => None,
654 }
655}
656
657pub fn parse_tunables_section(content: &str) -> BTreeMap<String, String> {
658 let mut in_tunables = false;
659 let mut out = BTreeMap::new();
660
661 for line in content.lines() {
662 let trimmed = line.trim();
663 if trimmed.starts_with("## ") {
664 in_tunables = trimmed == "## Tunables";
665 continue;
666 }
667 if !in_tunables {
668 continue;
669 }
670 if let Some(rest) = trimmed.strip_prefix("- ") {
671 if let Some((label, value)) = rest.split_once(':') {
672 out.insert(label.trim().to_string(), value.trim().to_string());
673 }
674 }
675 }
676
677 out
678}
679
680pub fn tunable_state_for_label(content: &str, label: &str) -> SoulTunableState {
681 let parsed = parse_tunables_section(content);
682 let Some(current) = parsed.get(label) else {
683 return SoulTunableState::Missing;
684 };
685 let Some(_spec) = SOUL_TUNABLE_SPECS.iter().find(|s| s.label == label) else {
686 return SoulTunableState::Edited;
687 };
688 let Some(variants) = tunable_variants_for_label(label) else {
689 return SoulTunableState::Edited;
690 };
691 match variants.iter().position(|v| *v == current) {
692 Some(idx) => SoulTunableState::Preset(idx),
693 None => SoulTunableState::Edited,
694 }
695}
696
697pub fn generated_tunable_line(label: &str, variant_idx: usize) -> Option<String> {
698 let _spec = SOUL_TUNABLE_SPECS.iter().find(|s| s.label == label)?;
699 let variants = tunable_variants_for_label(label)?;
700 let variant = variants.get(variant_idx)?;
701 Some(format!("- {label}: {variant}"))
702}
703
704pub fn replace_tunable_line(content: &str, label: &str, new_line: &str) -> String {
705 let mut out = Vec::new();
706 let mut in_tunables = false;
707 let mut replaced = false;
708
709 for line in content.lines() {
710 let trimmed = line.trim();
711 if trimmed.starts_with("## ") {
712 if in_tunables && !replaced {
713 out.push(new_line.to_string());
714 replaced = true;
715 }
716 in_tunables = trimmed == "## Tunables";
717 out.push(line.to_string());
718 continue;
719 }
720 if in_tunables
721 && trimmed.starts_with("- ")
722 && trimmed[2..].starts_with(&format!("{label}:"))
723 {
724 if !replaced {
725 out.push(new_line.to_string());
726 replaced = true;
727 }
728 continue;
729 }
730 out.push(line.to_string());
731 }
732
733 if !replaced {
734 if in_tunables {
735 out.push(new_line.to_string());
736 } else {
737 if !content.ends_with('\n') && !content.is_empty() {
738 out.push(String::new());
739 }
740 out.push("## Tunables".to_string());
741 out.push(String::new());
742 out.push(new_line.to_string());
743 }
744 }
745
746 out.join("\n")
747}
748
749fn default_personality_name() -> String {
750 "imp".to_string()
751}
752
753pub fn soul_identity_text(content: &str) -> String {
754 for line in content.lines() {
755 let trimmed = line.trim();
756 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
757 continue;
758 }
759 return trimmed.to_string();
760 }
761 "You are imp, a coding agent.".to_string()
762}
763
764pub fn default_soul_markdown() -> String {
765 let profile = PersonalityProfile::default();
766 format!(
767 "# Soul\n\n{}\n\n## Tunables\n\n- Autonomy: {}\n- Brevity: {}\n- Caution: {}\n- Warmth: {}\n- Planning: {}\n",
768 profile.identity.render_sentence(),
769 crate::system_prompt::autonomy_line(profile.sliders.autonomy),
770 crate::system_prompt::verbosity_line(profile.sliders.verbosity),
771 crate::system_prompt::caution_line(profile.sliders.caution),
772 crate::system_prompt::warmth_line(profile.sliders.warmth),
773 crate::system_prompt::planning_depth_line(profile.sliders.planning_depth),
774 )
775}
776
777pub fn write_default_soul_if_missing(path: &std::path::Path) -> crate::Result<bool> {
778 if path.exists() {
779 return Ok(false);
780 }
781 if let Some(parent) = path.parent() {
782 std::fs::create_dir_all(parent)?;
783 }
784 std::fs::write(path, default_soul_markdown())?;
785 Ok(true)
786}
787
788pub fn migrate_personality_to_soul(profile: &PersonalityProfile) -> String {
789 format!(
790 "# Soul\n\n{}\n\n## Tunables\n\n- Autonomy: {}\n- Brevity: {}\n- Caution: {}\n- Warmth: {}\n- Planning: {}\n",
791 profile.identity.render_sentence(),
792 crate::system_prompt::autonomy_line(profile.sliders.autonomy),
793 crate::system_prompt::verbosity_line(profile.sliders.verbosity),
794 crate::system_prompt::caution_line(profile.sliders.caution),
795 crate::system_prompt::warmth_line(profile.sliders.warmth),
796 crate::system_prompt::planning_depth_line(profile.sliders.planning_depth),
797 )
798}
799
800pub fn soul_prompt_block(soul: &SoulDoc) -> String {
801 let mut s = String::from("Soul:\n");
802 s.push_str(&soul.content);
803 s
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809
810 #[test]
811 fn soul_default_file_write_only_happens_when_missing() {
812 let dir = tempfile::tempdir().unwrap();
813 let path = dir.path().join("soul.md");
814 assert_eq!(write_default_soul_if_missing(&path).unwrap(), true);
815 let first = std::fs::read_to_string(&path).unwrap();
816 assert!(first.contains("# Soul"));
817 assert_eq!(write_default_soul_if_missing(&path).unwrap(), false);
818 let second = std::fs::read_to_string(&path).unwrap();
819 assert_eq!(first, second);
820 }
821
822 #[test]
823 fn personality_profile_can_migrate_to_soul_markdown() {
824 let profile = PersonalityProfile::default();
825 let soul = migrate_personality_to_soul(&profile);
826 assert!(soul.contains("# Soul"));
827 assert!(soul.contains("## Tunables"));
828 assert!(soul.contains("- Autonomy:"));
829 }
830
831 #[test]
832 fn soul_tunables_parse_and_match_presets() {
833 let soul = default_soul_markdown();
834 let parsed = parse_tunables_section(&soul);
835 assert!(parsed.contains_key("Autonomy"));
836 assert!(parsed.contains_key("Brevity"));
837 assert_eq!(
838 tunable_state_for_label(&soul, "Autonomy"),
839 SoulTunableState::Preset(3)
840 );
841 assert_eq!(
842 tunable_state_for_label(&soul, "Brevity"),
843 SoulTunableState::Preset(1)
844 );
845 }
846
847 #[test]
848 fn soul_tunables_report_edited_when_line_changes() {
849 let soul = "# Soul\n\nYou are imp.\n\n## Tunables\n\n- Autonomy: Do your own thing in a custom way.\n";
850 assert_eq!(
851 tunable_state_for_label(soul, "Autonomy"),
852 SoulTunableState::Edited
853 );
854 }
855
856 #[test]
857 fn soul_tunables_report_missing_when_absent() {
858 let soul = "# Soul\n\nHello\n";
859 assert_eq!(
860 tunable_state_for_label(soul, "Autonomy"),
861 SoulTunableState::Missing
862 );
863 }
864
865 #[test]
866 fn soul_tunables_replace_line_in_place() {
867 let soul = default_soul_markdown();
868 let replacement = generated_tunable_line("Warmth", 4).unwrap();
869 let updated = replace_tunable_line(&soul, "Warmth", &replacement);
870 assert!(updated.contains(&replacement));
871 assert_eq!(
872 tunable_state_for_label(&updated, "Warmth"),
873 SoulTunableState::Preset(4)
874 );
875 }
876
877 #[test]
878 fn soul_tunables_append_section_when_missing() {
879 let soul = "# Soul\n\nYou are imp.\n";
880 let replacement = generated_tunable_line("Autonomy", 3).unwrap();
881 let updated = replace_tunable_line(soul, "Autonomy", &replacement);
882 assert!(updated.contains("## Tunables"));
883 assert!(updated.contains(&replacement));
884 }
885
886 #[test]
887 fn default_identity_sentence_is_strong_and_compact() {
888 let identity = PersonalityIdentity::default();
889 assert_eq!(
890 identity.render_sentence(),
891 "You are imp, a practical, concise, coding agent."
892 );
893 }
894
895 #[test]
896 fn personality_config_merge_overrides_profile_and_extends_saved_profiles() {
897 let mut base = PersonalityConfig::default();
898 base.profiles.active = Some("builder".into());
899 base.profiles.saved.insert(
900 "builder".into(),
901 PersonalityProfile {
902 identity: PersonalityIdentity::default(),
903 sliders: PersonalitySliders::default(),
904 },
905 );
906
907 let mut overlay = PersonalityConfig::default();
908 overlay.profile.identity.name = "Nova".into();
909 overlay.profiles.active = Some("researcher".into());
910 overlay.profiles.saved.insert(
911 "researcher".into(),
912 PersonalityProfile {
913 identity: PersonalityIdentity {
914 name: "Nova".into(),
915 focus: PersonaFocus::Research,
916 role: PersonaRole::Assistant,
917 ..PersonalityIdentity::default()
918 },
919 sliders: PersonalitySliders::default(),
920 },
921 );
922
923 base.merge(overlay);
924
925 assert_eq!(base.profile.identity.name, "Nova");
926 assert_eq!(base.profiles.active.as_deref(), Some("researcher"));
927 assert!(base.profiles.saved.contains_key("builder"));
928 assert!(base.profiles.saved.contains_key("researcher"));
929 }
930
931 #[test]
932 fn profiles_can_save_activate_rename_and_delete() {
933 let mut profiles = PersonalityProfiles::default();
934 let saved = profiles.save_profile("Builder", PersonalityProfile::default());
935 assert_eq!(saved, "Builder");
936 assert_eq!(profiles.active.as_deref(), Some("Builder"));
937 assert!(profiles.saved.contains_key("Builder"));
938
939 assert!(profiles.rename_profile("Builder", "Reviewer"));
940 assert_eq!(profiles.active.as_deref(), Some("Reviewer"));
941 assert!(profiles.saved.contains_key("Reviewer"));
942 assert!(!profiles.saved.contains_key("Builder"));
943
944 assert!(profiles.delete_profile("Reviewer"));
945 assert!(profiles.active.is_none());
946 assert!(profiles.saved.is_empty());
947 }
948
949 #[test]
950 fn config_effective_profile_prefers_active_saved_profile() {
951 let mut config = PersonalityConfig::default();
952 config.profile.identity.name = "imp".into();
953 config.profiles.save_profile(
954 "Researcher",
955 PersonalityProfile {
956 identity: PersonalityIdentity {
957 name: "Nova".into(),
958 focus: PersonaFocus::Research,
959 role: PersonaRole::Assistant,
960 ..PersonalityIdentity::default()
961 },
962 sliders: PersonalitySliders::default(),
963 },
964 );
965
966 assert_eq!(config.effective_profile().identity.name, "Nova");
967 }
968
969 #[test]
970 fn option_lists_and_band_labels_match_defaults() {
971 assert!(WORK_STYLE_OPTIONS.iter().any(|o| o.value == "practical"));
972 assert!(VOICE_OPTIONS.iter().any(|o| o.value == "concise"));
973 assert!(FOCUS_OPTIONS.iter().any(|o| o.value == "coding"));
974 assert!(ROLE_OPTIONS.iter().any(|o| o.value == "agent"));
975 assert_eq!(PersonalityBand::Medium.ui_label(), "balanced");
976 }
977}