Skip to main content

room_plugin_agent/
personalities.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4
5use serde::Deserialize;
6
7/// Known model names that can be used in personality files.
8const KNOWN_MODELS: &[&str] = &["opus", "sonnet", "haiku"];
9
10/// Errors that can occur when loading or validating a personality TOML file.
11#[derive(Debug)]
12pub enum PersonalityError {
13    /// File could not be read.
14    Io(std::io::Error),
15    /// TOML content could not be parsed into the expected schema.
16    Parse(toml::de::Error),
17    /// Parsed successfully but field values are invalid.
18    Validation(Vec<String>),
19}
20
21impl fmt::Display for PersonalityError {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        match self {
24            Self::Io(e) => write!(f, "failed to read personality file: {e}"),
25            Self::Parse(e) => write!(f, "failed to parse personality TOML: {e}"),
26            Self::Validation(errors) => {
27                write!(f, "personality validation failed: {}", errors.join("; "))
28            }
29        }
30    }
31}
32
33impl std::error::Error for PersonalityError {
34    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
35        match self {
36            Self::Io(e) => Some(e),
37            Self::Parse(e) => Some(e),
38            Self::Validation(_) => None,
39        }
40    }
41}
42
43impl From<std::io::Error> for PersonalityError {
44    fn from(e: std::io::Error) -> Self {
45        Self::Io(e)
46    }
47}
48
49impl From<toml::de::Error> for PersonalityError {
50    fn from(e: toml::de::Error) -> Self {
51        Self::Parse(e)
52    }
53}
54
55/// A named agent personality preset.
56///
57/// Personalities define everything needed to spawn an agent: model, tool
58/// restrictions, prompt, and naming conventions. They are resolved from
59/// user-defined TOML files (`~/.room/personalities/<name>.toml`) first,
60/// then built-in defaults compiled into the binary.
61#[derive(Debug, Clone, Deserialize)]
62pub struct Personality {
63    pub personality: PersonalityCore,
64    #[serde(default)]
65    pub tools: ToolConfig,
66    #[serde(default)]
67    pub prompt: PromptConfig,
68    #[serde(default)]
69    pub naming: NamingConfig,
70}
71
72#[derive(Debug, Clone, Deserialize)]
73pub struct PersonalityCore {
74    pub name: String,
75    pub description: String,
76    #[serde(default = "default_model")]
77    pub model: String,
78}
79
80fn default_model() -> String {
81    "sonnet".to_owned()
82}
83
84#[derive(Debug, Clone, Default, Deserialize)]
85pub struct ToolConfig {
86    #[serde(default)]
87    pub allow: Vec<String>,
88    #[serde(default)]
89    pub disallow: Vec<String>,
90    #[serde(default)]
91    pub allow_all: bool,
92}
93
94#[derive(Debug, Clone, Default, Deserialize)]
95pub struct PromptConfig {
96    #[serde(default)]
97    pub template: String,
98}
99
100#[derive(Debug, Clone, Default, Deserialize)]
101pub struct NamingConfig {
102    #[serde(default)]
103    pub name_pool: Vec<String>,
104}
105
106impl Personality {
107    /// Validate that all fields have sensible values.
108    ///
109    /// Returns `Ok(())` if valid, or `Err(PersonalityError::Validation)` with
110    /// a list of human-readable error messages.
111    pub fn validate(&self) -> Result<(), PersonalityError> {
112        let mut errors = Vec::new();
113
114        if self.personality.name.is_empty() {
115            errors.push("personality.name must not be empty".to_owned());
116        }
117
118        if self.personality.description.is_empty() {
119            errors.push("personality.description must not be empty".to_owned());
120        }
121
122        if !KNOWN_MODELS.contains(&self.personality.model.as_str()) {
123            errors.push(format!(
124                "personality.model '{}' is not a known model (expected one of: {})",
125                self.personality.model,
126                KNOWN_MODELS.join(", ")
127            ));
128        }
129
130        for (i, name) in self.naming.name_pool.iter().enumerate() {
131            if name.is_empty() {
132                errors.push(format!("naming.name_pool[{i}] must not be empty"));
133            } else if !name
134                .chars()
135                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
136            {
137                errors.push(format!(
138                    "naming.name_pool[{i}] '{}' contains invalid characters \
139                     (only ASCII alphanumeric, '-', '_' allowed)",
140                    name
141                ));
142            }
143        }
144
145        if errors.is_empty() {
146            Ok(())
147        } else {
148            Err(PersonalityError::Validation(errors))
149        }
150    }
151
152    /// Generate a username for this personality.
153    ///
154    /// If a name pool is configured and `used_names` doesn't exhaust it,
155    /// picks an unused name from the pool. Otherwise falls back to
156    /// `<personality>-<short-uuid>`.
157    pub fn generate_username(&self, used_names: &[String]) -> String {
158        let prefix = &self.personality.name;
159
160        // Try name pool first
161        for name in &self.naming.name_pool {
162            let candidate = format!("{prefix}-{name}");
163            if !used_names.iter().any(|u| u == &candidate) {
164                return candidate;
165            }
166        }
167
168        // Fallback: short UUID
169        let short = &uuid::Uuid::new_v4().to_string()[..8];
170        format!("{prefix}-{short}")
171    }
172}
173
174// ── Built-in personalities ──────────────────────────────────────────────────
175
176fn builtin_coder() -> Personality {
177    Personality {
178        personality: PersonalityCore {
179            name: "coder".to_owned(),
180            description: "Development agent — reads, writes, tests, commits".to_owned(),
181            model: "opus".to_owned(),
182        },
183        tools: ToolConfig::default(),
184        prompt: PromptConfig {
185            template: "You are a development agent. Your workflow:\n\
186                1. Poll the taskboard for available tasks\n\
187                2. Claim a task and announce your plan\n\
188                3. Implement, test, and open a PR\n\
189                4. Announce completion and return to idle"
190                .to_owned(),
191        },
192        naming: NamingConfig {
193            name_pool: vec![
194                "anna".to_owned(),
195                "kai".to_owned(),
196                "nova".to_owned(),
197                "zara".to_owned(),
198                "leo".to_owned(),
199                "mika".to_owned(),
200            ],
201        },
202    }
203}
204
205fn builtin_reviewer() -> Personality {
206    Personality {
207        personality: PersonalityCore {
208            name: "reviewer".to_owned(),
209            description: "PR reviewer — read-only code access, gh commands".to_owned(),
210            model: "sonnet".to_owned(),
211        },
212        tools: ToolConfig {
213            disallow: vec!["Write".to_owned(), "Edit".to_owned()],
214            ..Default::default()
215        },
216        prompt: PromptConfig {
217            template: "You are a code reviewer. Focus on correctness, test coverage, \
218                and adherence to the project's coding standards. Use `gh pr` commands \
219                to leave reviews."
220                .to_owned(),
221        },
222        naming: NamingConfig {
223            name_pool: vec!["alice".to_owned(), "bob".to_owned(), "charlie".to_owned()],
224        },
225    }
226}
227
228fn builtin_scout() -> Personality {
229    Personality {
230        personality: PersonalityCore {
231            name: "scout".to_owned(),
232            description: "Codebase explorer — search and summarize only".to_owned(),
233            model: "haiku".to_owned(),
234        },
235        tools: ToolConfig {
236            disallow: vec!["Write".to_owned(), "Edit".to_owned(), "Bash".to_owned()],
237            ..Default::default()
238        },
239        prompt: PromptConfig {
240            template: "You are a codebase explorer. Search and summarize code, \
241                answer questions about architecture and patterns. Do not modify files."
242                .to_owned(),
243        },
244        naming: NamingConfig {
245            name_pool: vec!["hawk".to_owned(), "owl".to_owned(), "fox".to_owned()],
246        },
247    }
248}
249
250fn builtin_qa() -> Personality {
251    Personality {
252        personality: PersonalityCore {
253            name: "qa".to_owned(),
254            description: "Test writer — finds coverage gaps, writes tests".to_owned(),
255            model: "sonnet".to_owned(),
256        },
257        tools: ToolConfig::default(),
258        prompt: PromptConfig {
259            template: "You are a QA agent. Your workflow:\n\
260                1. Identify test coverage gaps\n\
261                2. Write unit and integration tests\n\
262                3. Ensure all tests pass\n\
263                4. Open a PR with the new tests"
264                .to_owned(),
265        },
266        naming: NamingConfig {
267            name_pool: vec!["tara".to_owned(), "reo".to_owned(), "juno".to_owned()],
268        },
269    }
270}
271
272fn builtin_coordinator() -> Personality {
273    Personality {
274        personality: PersonalityCore {
275            name: "coordinator".to_owned(),
276            description: "BA/triage — reads code, manages issues, coordinates".to_owned(),
277            model: "sonnet".to_owned(),
278        },
279        tools: ToolConfig {
280            disallow: vec!["Write".to_owned(), "Edit".to_owned()],
281            ..Default::default()
282        },
283        prompt: PromptConfig {
284            template: "You are a coordinator agent. Triage issues, manage the taskboard, \
285                review plans, and coordinate work across agents. Do not modify code directly."
286                .to_owned(),
287        },
288        naming: NamingConfig {
289            name_pool: vec!["sage".to_owned(), "atlas".to_owned()],
290        },
291    }
292}
293
294/// Returns the built-in personality defaults compiled into the binary.
295pub fn builtin_personalities() -> HashMap<String, Personality> {
296    let mut map = HashMap::new();
297    for p in [
298        builtin_coder(),
299        builtin_reviewer(),
300        builtin_scout(),
301        builtin_qa(),
302        builtin_coordinator(),
303    ] {
304        map.insert(p.personality.name.clone(), p);
305    }
306    map
307}
308
309/// Returns the list of all known personality names (built-in + user-defined).
310pub fn all_personality_names() -> Vec<String> {
311    let (all, _errors) = all_personalities();
312    let mut names: Vec<String> = all.into_keys().collect();
313    names.sort();
314    names
315}
316
317/// Resolve a personality by name.
318///
319/// Resolution order:
320/// 1. User-defined TOML at `~/.room/personalities/<name>.toml`
321/// 2. Built-in defaults compiled into the binary
322///
323/// When a user TOML overrides a built-in with the same name, the two are
324/// merged via [`merge_personality`] (user fields win, tool deny lists are
325/// unioned). A user TOML with no matching built-in is used as-is.
326///
327/// Returns `Err` if the user TOML exists but is malformed or invalid.
328/// Returns `Ok(None)` if neither a user TOML nor a built-in exists.
329pub fn resolve_personality(name: &str) -> Result<Option<Personality>, PersonalityError> {
330    let builtin = builtin_personalities().remove(name);
331
332    // Try user-defined TOML
333    if let Some(dir) = personalities_dir() {
334        let toml_path = dir.join(format!("{name}.toml"));
335        if toml_path.exists() {
336            let user = load_personality_toml(&toml_path)?;
337            return match builtin {
338                Some(base) => Ok(Some(merge_personality(&user, &base))),
339                None => Ok(Some(user)),
340            };
341        }
342    }
343
344    Ok(builtin)
345}
346
347/// Scan `~/.room/personalities/` for all `.toml` files.
348///
349/// Returns a map of successfully loaded personalities keyed by file stem,
350/// plus a list of `(filename, error)` pairs for files that failed to load
351/// or validate. Healthy files are not affected by broken neighbours.
352pub fn scan_personalities_dir() -> (
353    HashMap<String, Personality>,
354    Vec<(String, PersonalityError)>,
355) {
356    let mut loaded = HashMap::new();
357    let mut errors = Vec::new();
358
359    let dir = match personalities_dir() {
360        Some(d) if d.is_dir() => d,
361        _ => return (loaded, errors),
362    };
363
364    let entries = match std::fs::read_dir(&dir) {
365        Ok(e) => e,
366        Err(_) => return (loaded, errors),
367    };
368
369    for entry in entries.flatten() {
370        let path = entry.path();
371        if path.extension().is_some_and(|e| e == "toml") {
372            let stem = match path.file_stem().and_then(|s| s.to_str()) {
373                Some(s) => s.to_owned(),
374                None => continue,
375            };
376            match load_personality_toml(&path) {
377                Ok(p) => {
378                    loaded.insert(stem, p);
379                }
380                Err(e) => {
381                    errors.push((stem, e));
382                }
383            }
384        }
385    }
386
387    (loaded, errors)
388}
389
390/// Merge a user-defined personality override into a built-in base.
391///
392/// User fields win for scalar values (name, description, model, prompt
393/// template, name pool). For tool restrictions, deny lists are **unioned**
394/// so that built-in safety restrictions cannot be removed by user overrides.
395/// Allow lists are taken from the user if non-empty, otherwise from the base.
396/// `allow_all` is only true if both user and base agree.
397pub fn merge_personality(user: &Personality, base: &Personality) -> Personality {
398    // Union deny lists (deduplicated)
399    let mut disallow = base.tools.disallow.clone();
400    for item in &user.tools.disallow {
401        if !disallow.contains(item) {
402            disallow.push(item.clone());
403        }
404    }
405
406    // Allow list: user wins if non-empty
407    let allow = if user.tools.allow.is_empty() {
408        base.tools.allow.clone()
409    } else {
410        user.tools.allow.clone()
411    };
412
413    Personality {
414        personality: user.personality.clone(),
415        tools: ToolConfig {
416            allow,
417            disallow,
418            allow_all: user.tools.allow_all && base.tools.allow_all,
419        },
420        prompt: if user.prompt.template.is_empty() {
421            base.prompt.clone()
422        } else {
423            user.prompt.clone()
424        },
425        naming: if user.naming.name_pool.is_empty() {
426            base.naming.clone()
427        } else {
428            user.naming.clone()
429        },
430    }
431}
432
433/// Returns all personalities: built-ins merged with user overrides from
434/// `~/.room/personalities/`.
435///
436/// User TOML files that match a built-in name are merged (see
437/// [`merge_personality`]). User files with no matching built-in are
438/// included as-is. Returns errors for files that failed to load.
439pub fn all_personalities() -> (
440    HashMap<String, Personality>,
441    Vec<(String, PersonalityError)>,
442) {
443    let mut result = builtin_personalities();
444    let (user_defined, errors) = scan_personalities_dir();
445
446    for (name, user) in user_defined {
447        match result.remove(&name) {
448            Some(base) => {
449                result.insert(name, merge_personality(&user, &base));
450            }
451            None => {
452                result.insert(name, user);
453            }
454        }
455    }
456
457    (result, errors)
458}
459
460/// Load and validate a personality from a TOML file.
461///
462/// Returns the parsed and validated personality, or a descriptive error
463/// explaining what went wrong (IO failure, parse error, or validation issues).
464fn load_personality_toml(path: &Path) -> Result<Personality, PersonalityError> {
465    let content = std::fs::read_to_string(path)?;
466    let personality: Personality = toml::from_str(&content)?;
467    personality.validate()?;
468    Ok(personality)
469}
470
471/// Returns the personality directory path (`~/.room/personalities/`).
472fn personalities_dir() -> Option<PathBuf> {
473    dirs_path().map(|d| d.join("personalities"))
474}
475
476/// Returns `~/.room` base path.
477fn dirs_path() -> Option<PathBuf> {
478    std::env::var("HOME")
479        .ok()
480        .map(|h| PathBuf::from(h).join(".room"))
481}
482
483// ── Tests ─────────────────────────────────────────────────────────────────────
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn builtin_personalities_has_all_five() {
491        let builtins = builtin_personalities();
492        assert!(builtins.contains_key("coder"));
493        assert!(builtins.contains_key("reviewer"));
494        assert!(builtins.contains_key("scout"));
495        assert!(builtins.contains_key("qa"));
496        assert!(builtins.contains_key("coordinator"));
497        assert_eq!(builtins.len(), 5);
498    }
499
500    #[test]
501    fn builtin_coder_has_opus_model() {
502        let builtins = builtin_personalities();
503        let coder = &builtins["coder"];
504        assert_eq!(coder.personality.model, "opus");
505    }
506
507    #[test]
508    fn builtin_reviewer_disallows_write_edit() {
509        let builtins = builtin_personalities();
510        let reviewer = &builtins["reviewer"];
511        assert!(reviewer.tools.disallow.contains(&"Write".to_owned()));
512        assert!(reviewer.tools.disallow.contains(&"Edit".to_owned()));
513    }
514
515    #[test]
516    fn builtin_scout_disallows_write_edit_bash() {
517        let builtins = builtin_personalities();
518        let scout = &builtins["scout"];
519        assert!(scout.tools.disallow.contains(&"Write".to_owned()));
520        assert!(scout.tools.disallow.contains(&"Edit".to_owned()));
521        assert!(scout.tools.disallow.contains(&"Bash".to_owned()));
522    }
523
524    #[test]
525    fn generate_username_from_name_pool() {
526        let p = builtin_coder();
527        let username = p.generate_username(&[]);
528        assert!(username.starts_with("coder-"));
529        // Should be a name from the pool, not a UUID
530        assert!(
531            p.naming
532                .name_pool
533                .iter()
534                .any(|n| username == format!("coder-{n}")),
535            "expected name from pool, got: {username}"
536        );
537    }
538
539    #[test]
540    fn generate_username_skips_used_names() {
541        let p = builtin_coder();
542        // Mark first name as used
543        let first_name = format!("coder-{}", p.naming.name_pool[0]);
544        let username = p.generate_username(&[first_name.clone()]);
545        assert_ne!(username, first_name);
546        assert!(username.starts_with("coder-"));
547    }
548
549    #[test]
550    fn generate_username_fallback_to_uuid_when_pool_exhausted() {
551        let p = builtin_reviewer();
552        // Exhaust the pool
553        let used: Vec<String> = p
554            .naming
555            .name_pool
556            .iter()
557            .map(|n| format!("reviewer-{n}"))
558            .collect();
559        let username = p.generate_username(&used);
560        assert!(username.starts_with("reviewer-"));
561        // Should be 8-char hex UUID suffix
562        let suffix = username.strip_prefix("reviewer-").unwrap();
563        assert_eq!(suffix.len(), 8);
564    }
565
566    #[test]
567    fn generate_username_empty_pool_uses_uuid() {
568        let mut p = builtin_coder();
569        p.naming.name_pool.clear();
570        let username = p.generate_username(&[]);
571        assert!(username.starts_with("coder-"));
572        let suffix = username.strip_prefix("coder-").unwrap();
573        assert_eq!(suffix.len(), 8);
574    }
575
576    #[test]
577    fn toml_deserialization_roundtrip() {
578        let toml_str = r#"
579[personality]
580name = "custom"
581description = "A custom personality"
582model = "opus"
583
584[tools]
585disallow = ["Bash"]
586
587[prompt]
588template = "You are a custom agent."
589
590[naming]
591name_pool = ["alpha", "beta"]
592"#;
593        let p: Personality = toml::from_str(toml_str).unwrap();
594        assert_eq!(p.personality.name, "custom");
595        assert_eq!(p.personality.description, "A custom personality");
596        assert_eq!(p.personality.model, "opus");
597        assert_eq!(p.tools.disallow, vec!["Bash"]);
598        assert!(p.tools.allow.is_empty());
599        assert!(!p.tools.allow_all);
600        assert_eq!(p.prompt.template, "You are a custom agent.");
601        assert_eq!(p.naming.name_pool, vec!["alpha", "beta"]);
602    }
603
604    #[test]
605    fn toml_deserialization_minimal() {
606        let toml_str = r#"
607[personality]
608name = "minimal"
609description = "Minimal personality"
610"#;
611        let p: Personality = toml::from_str(toml_str).unwrap();
612        assert_eq!(p.personality.name, "minimal");
613        assert_eq!(p.personality.model, "sonnet"); // default
614        assert!(p.tools.disallow.is_empty());
615        assert!(p.tools.allow.is_empty());
616        assert!(p.prompt.template.is_empty());
617        assert!(p.naming.name_pool.is_empty());
618    }
619
620    #[test]
621    fn toml_deserialization_allow_all() {
622        let toml_str = r#"
623[personality]
624name = "unrestricted"
625description = "No tool restrictions"
626
627[tools]
628allow_all = true
629"#;
630        let p: Personality = toml::from_str(toml_str).unwrap();
631        assert!(p.tools.allow_all);
632    }
633
634    #[test]
635    fn resolve_personality_returns_builtin() {
636        let p = resolve_personality("coder").unwrap().unwrap();
637        assert_eq!(p.personality.name, "coder");
638        assert_eq!(p.personality.model, "opus");
639    }
640
641    #[test]
642    fn resolve_personality_returns_none_for_unknown() {
643        assert!(resolve_personality("nonexistent-personality-xyz")
644            .unwrap()
645            .is_none());
646    }
647
648    #[test]
649    fn resolve_personality_user_toml_overrides_builtin() {
650        let dir = tempfile::tempdir().unwrap();
651        let personalities_dir = dir.path().join("personalities");
652        std::fs::create_dir_all(&personalities_dir).unwrap();
653
654        let toml_content = r#"
655[personality]
656name = "coder"
657description = "Custom coder override"
658model = "haiku"
659"#;
660        std::fs::write(personalities_dir.join("coder.toml"), toml_content).unwrap();
661
662        // Test loading directly from the TOML file
663        let p = load_personality_toml(&personalities_dir.join("coder.toml")).unwrap();
664        assert_eq!(p.personality.name, "coder");
665        assert_eq!(p.personality.model, "haiku");
666        assert_eq!(p.personality.description, "Custom coder override");
667    }
668
669    #[test]
670    fn all_personality_names_includes_builtins() {
671        let names = all_personality_names();
672        assert!(names.contains(&"coder".to_owned()));
673        assert!(names.contains(&"reviewer".to_owned()));
674        assert!(names.contains(&"scout".to_owned()));
675        assert!(names.contains(&"qa".to_owned()));
676        assert!(names.contains(&"coordinator".to_owned()));
677    }
678
679    #[test]
680    fn all_personality_names_sorted() {
681        let names = all_personality_names();
682        let mut sorted = names.clone();
683        sorted.sort();
684        assert_eq!(names, sorted);
685    }
686
687    // ── Validation tests ──────────────────────────────────────────────────
688
689    #[test]
690    fn validate_builtin_personalities_all_pass() {
691        for (name, p) in builtin_personalities() {
692            p.validate()
693                .unwrap_or_else(|e| panic!("builtin '{name}' failed validation: {e}"));
694        }
695    }
696
697    #[test]
698    fn validate_empty_name_rejected() {
699        let toml_str = r#"
700[personality]
701name = ""
702description = "Has a description"
703model = "sonnet"
704"#;
705        let p: Personality = toml::from_str(toml_str).unwrap();
706        let err = p.validate().unwrap_err();
707        let msg = err.to_string();
708        assert!(
709            msg.contains("name must not be empty"),
710            "expected name error, got: {msg}"
711        );
712    }
713
714    #[test]
715    fn validate_empty_description_rejected() {
716        let toml_str = r#"
717[personality]
718name = "test"
719description = ""
720model = "sonnet"
721"#;
722        let p: Personality = toml::from_str(toml_str).unwrap();
723        let err = p.validate().unwrap_err();
724        let msg = err.to_string();
725        assert!(
726            msg.contains("description must not be empty"),
727            "expected description error, got: {msg}"
728        );
729    }
730
731    #[test]
732    fn validate_unknown_model_rejected() {
733        let toml_str = r#"
734[personality]
735name = "test"
736description = "A test personality"
737model = "gpt-4"
738"#;
739        let p: Personality = toml::from_str(toml_str).unwrap();
740        let err = p.validate().unwrap_err();
741        let msg = err.to_string();
742        assert!(
743            msg.contains("not a known model"),
744            "expected model error, got: {msg}"
745        );
746        assert!(msg.contains("gpt-4"), "should mention the bad model name");
747    }
748
749    #[test]
750    fn validate_empty_name_pool_entry_rejected() {
751        let toml_str = r#"
752[personality]
753name = "test"
754description = "A test personality"
755model = "sonnet"
756
757[naming]
758name_pool = ["good", ""]
759"#;
760        let p: Personality = toml::from_str(toml_str).unwrap();
761        let err = p.validate().unwrap_err();
762        let msg = err.to_string();
763        assert!(
764            msg.contains("name_pool[1] must not be empty"),
765            "expected name_pool error, got: {msg}"
766        );
767    }
768
769    #[test]
770    fn validate_name_pool_invalid_chars_rejected() {
771        let toml_str = r#"
772[personality]
773name = "test"
774description = "A test personality"
775model = "sonnet"
776
777[naming]
778name_pool = ["good-name", "bad name!", "also_ok"]
779"#;
780        let p: Personality = toml::from_str(toml_str).unwrap();
781        let err = p.validate().unwrap_err();
782        let msg = err.to_string();
783        assert!(
784            msg.contains("name_pool[1]") && msg.contains("invalid characters"),
785            "expected invalid chars error for index 1, got: {msg}"
786        );
787    }
788
789    #[test]
790    fn validate_multiple_errors_collected() {
791        let toml_str = r#"
792[personality]
793name = ""
794description = ""
795model = "unknown"
796
797[naming]
798name_pool = [""]
799"#;
800        let p: Personality = toml::from_str(toml_str).unwrap();
801        let err = p.validate().unwrap_err();
802        match &err {
803            PersonalityError::Validation(errors) => {
804                assert!(
805                    errors.len() >= 4,
806                    "expected at least 4 errors, got: {errors:?}"
807                );
808            }
809            _ => panic!("expected Validation error, got: {err}"),
810        }
811    }
812
813    #[test]
814    fn validate_valid_personality_passes() {
815        let toml_str = r#"
816[personality]
817name = "custom"
818description = "A valid personality"
819model = "opus"
820
821[naming]
822name_pool = ["alpha", "beta-1", "gamma_2"]
823"#;
824        let p: Personality = toml::from_str(toml_str).unwrap();
825        p.validate().unwrap();
826    }
827
828    #[test]
829    fn load_personality_toml_parse_error() {
830        let dir = tempfile::tempdir().unwrap();
831        let path = dir.path().join("bad.toml");
832        std::fs::write(&path, "this is not valid [[[ toml").unwrap();
833
834        let err = load_personality_toml(&path).unwrap_err();
835        assert!(
836            matches!(err, PersonalityError::Parse(_)),
837            "expected Parse error, got: {err}"
838        );
839    }
840
841    #[test]
842    fn load_personality_toml_missing_required_field() {
843        let dir = tempfile::tempdir().unwrap();
844        let path = dir.path().join("missing.toml");
845        // Missing [personality] section entirely
846        std::fs::write(&path, "[tools]\nallow_all = true\n").unwrap();
847
848        let err = load_personality_toml(&path).unwrap_err();
849        assert!(
850            matches!(err, PersonalityError::Parse(_)),
851            "expected Parse error for missing section, got: {err}"
852        );
853    }
854
855    #[test]
856    fn load_personality_toml_io_error() {
857        let err =
858            load_personality_toml(Path::new("/nonexistent/path/personality.toml")).unwrap_err();
859        assert!(
860            matches!(err, PersonalityError::Io(_)),
861            "expected Io error, got: {err}"
862        );
863    }
864
865    #[test]
866    fn load_personality_toml_validation_error() {
867        let dir = tempfile::tempdir().unwrap();
868        let path = dir.path().join("invalid.toml");
869        std::fs::write(
870            &path,
871            r#"
872[personality]
873name = ""
874description = "valid desc"
875model = "sonnet"
876"#,
877        )
878        .unwrap();
879
880        let err = load_personality_toml(&path).unwrap_err();
881        assert!(
882            matches!(err, PersonalityError::Validation(_)),
883            "expected Validation error, got: {err}"
884        );
885    }
886
887    #[test]
888    fn personality_error_display_io() {
889        let err = PersonalityError::Io(std::io::Error::new(
890            std::io::ErrorKind::NotFound,
891            "not found",
892        ));
893        let msg = err.to_string();
894        assert!(msg.contains("failed to read personality file"));
895    }
896
897    #[test]
898    fn personality_error_display_validation() {
899        let err = PersonalityError::Validation(vec!["bad name".to_owned(), "bad model".to_owned()]);
900        let msg = err.to_string();
901        assert!(msg.contains("bad name"));
902        assert!(msg.contains("bad model"));
903        assert!(msg.contains("; "));
904    }
905
906    // ── Directory scanning tests ──────────────────────────────────────────
907
908    #[test]
909    fn scan_empty_dir_returns_nothing() {
910        let dir = tempfile::tempdir().unwrap();
911        let personalities = dir.path().join("personalities");
912        std::fs::create_dir_all(&personalities).unwrap();
913
914        // scan_personalities_dir reads from $HOME/.room/personalities,
915        // so test load_personality_toml directly for isolation
916        let entries = std::fs::read_dir(&personalities).unwrap();
917        assert_eq!(entries.count(), 0);
918    }
919
920    #[test]
921    fn scan_dir_loads_valid_files() {
922        let dir = tempfile::tempdir().unwrap();
923        let p_dir = dir.path();
924
925        std::fs::write(
926            p_dir.join("custom.toml"),
927            r#"
928[personality]
929name = "custom"
930description = "A custom agent"
931model = "opus"
932"#,
933        )
934        .unwrap();
935
936        let p = load_personality_toml(&p_dir.join("custom.toml")).unwrap();
937        assert_eq!(p.personality.name, "custom");
938        assert_eq!(p.personality.model, "opus");
939    }
940
941    #[test]
942    fn scan_dir_collects_errors_for_invalid_files() {
943        let dir = tempfile::tempdir().unwrap();
944        let p_dir = dir.path();
945
946        std::fs::write(p_dir.join("broken.toml"), "not valid toml [[[").unwrap();
947
948        let err = load_personality_toml(&p_dir.join("broken.toml")).unwrap_err();
949        assert!(matches!(err, PersonalityError::Parse(_)));
950    }
951
952    // ── Merge tests ──────────────────────────────────────────────────────
953
954    #[test]
955    fn merge_user_overrides_scalar_fields() {
956        let base = builtin_coder();
957        let user_toml = r#"
958[personality]
959name = "coder"
960description = "My custom coder"
961model = "haiku"
962
963[prompt]
964template = "Custom prompt"
965
966[naming]
967name_pool = ["x", "y"]
968"#;
969        let user: Personality = toml::from_str(user_toml).unwrap();
970        let merged = merge_personality(&user, &base);
971
972        assert_eq!(merged.personality.name, "coder");
973        assert_eq!(merged.personality.description, "My custom coder");
974        assert_eq!(merged.personality.model, "haiku");
975        assert_eq!(merged.prompt.template, "Custom prompt");
976        assert_eq!(merged.naming.name_pool, vec!["x", "y"]);
977    }
978
979    #[test]
980    fn merge_deny_lists_unioned() {
981        let base = builtin_reviewer(); // disallows Write, Edit
982        let user_toml = r#"
983[personality]
984name = "reviewer"
985description = "Custom reviewer"
986model = "sonnet"
987
988[tools]
989disallow = ["Bash", "Write"]
990"#;
991        let user: Personality = toml::from_str(user_toml).unwrap();
992        let merged = merge_personality(&user, &base);
993
994        // Union: Write + Edit (base) + Bash (user) — Write deduplicated
995        assert!(merged.tools.disallow.contains(&"Write".to_owned()));
996        assert!(merged.tools.disallow.contains(&"Edit".to_owned()));
997        assert!(merged.tools.disallow.contains(&"Bash".to_owned()));
998        assert_eq!(merged.tools.disallow.len(), 3);
999    }
1000
1001    #[test]
1002    fn merge_user_cannot_remove_base_deny() {
1003        let base = builtin_scout(); // disallows Write, Edit, Bash
1004        let user_toml = r#"
1005[personality]
1006name = "scout"
1007description = "My scout"
1008model = "haiku"
1009
1010[tools]
1011disallow = []
1012"#;
1013        let user: Personality = toml::from_str(user_toml).unwrap();
1014        let merged = merge_personality(&user, &base);
1015
1016        // Base denies are preserved even if user specifies empty
1017        assert!(merged.tools.disallow.contains(&"Write".to_owned()));
1018        assert!(merged.tools.disallow.contains(&"Edit".to_owned()));
1019        assert!(merged.tools.disallow.contains(&"Bash".to_owned()));
1020    }
1021
1022    #[test]
1023    fn merge_allow_all_requires_both() {
1024        let mut base = builtin_coder();
1025        base.tools.allow_all = true;
1026
1027        let user_toml = r#"
1028[personality]
1029name = "coder"
1030description = "Coder"
1031model = "opus"
1032
1033[tools]
1034allow_all = false
1035"#;
1036        let user: Personality = toml::from_str(user_toml).unwrap();
1037        let merged = merge_personality(&user, &base);
1038        assert!(
1039            !merged.tools.allow_all,
1040            "allow_all should be false if user says false"
1041        );
1042    }
1043
1044    #[test]
1045    fn merge_empty_prompt_inherits_base() {
1046        let base = builtin_coder();
1047        let user_toml = r#"
1048[personality]
1049name = "coder"
1050description = "Custom coder"
1051model = "opus"
1052"#;
1053        let user: Personality = toml::from_str(user_toml).unwrap();
1054        let merged = merge_personality(&user, &base);
1055
1056        assert_eq!(merged.prompt.template, base.prompt.template);
1057    }
1058
1059    #[test]
1060    fn merge_empty_name_pool_inherits_base() {
1061        let base = builtin_coder();
1062        let user_toml = r#"
1063[personality]
1064name = "coder"
1065description = "Custom coder"
1066model = "opus"
1067"#;
1068        let user: Personality = toml::from_str(user_toml).unwrap();
1069        let merged = merge_personality(&user, &base);
1070
1071        assert_eq!(merged.naming.name_pool, base.naming.name_pool);
1072    }
1073
1074    #[test]
1075    fn merge_user_allow_overrides_base() {
1076        let base = builtin_coder(); // no allow list
1077        let user_toml = r#"
1078[personality]
1079name = "coder"
1080description = "Coder"
1081model = "opus"
1082
1083[tools]
1084allow = ["Read", "Grep"]
1085"#;
1086        let user: Personality = toml::from_str(user_toml).unwrap();
1087        let merged = merge_personality(&user, &base);
1088
1089        assert_eq!(merged.tools.allow, vec!["Read", "Grep"]);
1090    }
1091
1092    // ── all_personalities tests ──────────────────────────────────────────
1093
1094    #[test]
1095    fn all_personalities_includes_builtins() {
1096        let (all, _errors) = all_personalities();
1097        assert!(all.contains_key("coder"));
1098        assert!(all.contains_key("reviewer"));
1099        assert!(all.contains_key("scout"));
1100        assert!(all.contains_key("qa"));
1101        assert!(all.contains_key("coordinator"));
1102    }
1103
1104    #[test]
1105    fn all_personality_names_uses_all_personalities() {
1106        let names = all_personality_names();
1107        // Should include all builtins
1108        assert!(names.contains(&"coder".to_owned()));
1109        assert!(names.contains(&"reviewer".to_owned()));
1110        // Should be sorted
1111        let mut sorted = names.clone();
1112        sorted.sort();
1113        assert_eq!(names, sorted);
1114    }
1115}