Skip to main content

imp_core/
personality.rs

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}