1use std::collections::HashMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4
5use serde::Deserialize;
6
7const KNOWN_MODELS: &[&str] = &["opus", "sonnet", "haiku"];
9
10#[derive(Debug)]
12pub enum PersonalityError {
13 Io(std::io::Error),
15 Parse(toml::de::Error),
17 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#[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 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 pub fn generate_username(&self, used_names: &[String]) -> String {
158 let prefix = &self.personality.name;
159
160 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 let short = &uuid::Uuid::new_v4().to_string()[..8];
170 format!("{prefix}-{short}")
171 }
172}
173
174fn 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
294pub 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
309pub 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
317pub fn resolve_personality(name: &str) -> Result<Option<Personality>, PersonalityError> {
330 let builtin = builtin_personalities().remove(name);
331
332 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
347pub 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
390pub fn merge_personality(user: &Personality, base: &Personality) -> Personality {
398 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 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
433pub 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
460fn 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
471fn personalities_dir() -> Option<PathBuf> {
473 dirs_path().map(|d| d.join("personalities"))
474}
475
476fn dirs_path() -> Option<PathBuf> {
478 std::env::var("HOME")
479 .ok()
480 .map(|h| PathBuf::from(h).join(".room"))
481}
482
483#[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 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 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 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 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"); 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 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 #[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 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 #[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 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 #[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(); 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 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(); 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 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(); 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 #[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 assert!(names.contains(&"coder".to_owned()));
1109 assert!(names.contains(&"reviewer".to_owned()));
1110 let mut sorted = names.clone();
1112 sorted.sort();
1113 assert_eq!(names, sorted);
1114 }
1115}