1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5
6#[derive(Debug, Clone, Deserialize)]
13pub struct Personality {
14 pub personality: PersonalityCore,
15 #[serde(default)]
16 pub tools: ToolConfig,
17 #[serde(default)]
18 pub prompt: PromptConfig,
19 #[serde(default)]
20 pub naming: NamingConfig,
21}
22
23#[derive(Debug, Clone, Deserialize)]
24pub struct PersonalityCore {
25 pub name: String,
26 pub description: String,
27 #[serde(default = "default_model")]
28 pub model: String,
29}
30
31fn default_model() -> String {
32 "sonnet".to_owned()
33}
34
35#[derive(Debug, Clone, Default, Deserialize)]
36pub struct ToolConfig {
37 #[serde(default)]
38 pub allow: Vec<String>,
39 #[serde(default)]
40 pub disallow: Vec<String>,
41 #[serde(default)]
42 pub allow_all: bool,
43}
44
45#[derive(Debug, Clone, Default, Deserialize)]
46pub struct PromptConfig {
47 #[serde(default)]
48 pub template: String,
49}
50
51#[derive(Debug, Clone, Default, Deserialize)]
52pub struct NamingConfig {
53 #[serde(default)]
54 pub name_pool: Vec<String>,
55}
56
57impl Personality {
58 pub fn generate_username(&self, used_names: &[String]) -> String {
64 let prefix = &self.personality.name;
65
66 for name in &self.naming.name_pool {
68 let candidate = format!("{prefix}-{name}");
69 if !used_names.iter().any(|u| u == &candidate) {
70 return candidate;
71 }
72 }
73
74 let short = &uuid::Uuid::new_v4().to_string()[..8];
76 format!("{prefix}-{short}")
77 }
78}
79
80fn builtin_coder() -> Personality {
83 Personality {
84 personality: PersonalityCore {
85 name: "coder".to_owned(),
86 description: "Development agent — reads, writes, tests, commits".to_owned(),
87 model: "opus".to_owned(),
88 },
89 tools: ToolConfig::default(),
90 prompt: PromptConfig {
91 template: "You are a development agent. Your workflow:\n\
92 1. Poll the taskboard for available tasks\n\
93 2. Claim a task and announce your plan\n\
94 3. Implement, test, and open a PR\n\
95 4. Announce completion and return to idle"
96 .to_owned(),
97 },
98 naming: NamingConfig {
99 name_pool: vec![
100 "anna".to_owned(),
101 "kai".to_owned(),
102 "nova".to_owned(),
103 "zara".to_owned(),
104 "leo".to_owned(),
105 "mika".to_owned(),
106 ],
107 },
108 }
109}
110
111fn builtin_reviewer() -> Personality {
112 Personality {
113 personality: PersonalityCore {
114 name: "reviewer".to_owned(),
115 description: "PR reviewer — read-only code access, gh commands".to_owned(),
116 model: "sonnet".to_owned(),
117 },
118 tools: ToolConfig {
119 disallow: vec!["Write".to_owned(), "Edit".to_owned()],
120 ..Default::default()
121 },
122 prompt: PromptConfig {
123 template: "You are a code reviewer. Focus on correctness, test coverage, \
124 and adherence to the project's coding standards. Use `gh pr` commands \
125 to leave reviews."
126 .to_owned(),
127 },
128 naming: NamingConfig {
129 name_pool: vec!["alice".to_owned(), "bob".to_owned(), "charlie".to_owned()],
130 },
131 }
132}
133
134fn builtin_scout() -> Personality {
135 Personality {
136 personality: PersonalityCore {
137 name: "scout".to_owned(),
138 description: "Codebase explorer — search and summarize only".to_owned(),
139 model: "haiku".to_owned(),
140 },
141 tools: ToolConfig {
142 disallow: vec!["Write".to_owned(), "Edit".to_owned(), "Bash".to_owned()],
143 ..Default::default()
144 },
145 prompt: PromptConfig {
146 template: "You are a codebase explorer. Search and summarize code, \
147 answer questions about architecture and patterns. Do not modify files."
148 .to_owned(),
149 },
150 naming: NamingConfig {
151 name_pool: vec!["hawk".to_owned(), "owl".to_owned(), "fox".to_owned()],
152 },
153 }
154}
155
156fn builtin_qa() -> Personality {
157 Personality {
158 personality: PersonalityCore {
159 name: "qa".to_owned(),
160 description: "Test writer — finds coverage gaps, writes tests".to_owned(),
161 model: "sonnet".to_owned(),
162 },
163 tools: ToolConfig::default(),
164 prompt: PromptConfig {
165 template: "You are a QA agent. Your workflow:\n\
166 1. Identify test coverage gaps\n\
167 2. Write unit and integration tests\n\
168 3. Ensure all tests pass\n\
169 4. Open a PR with the new tests"
170 .to_owned(),
171 },
172 naming: NamingConfig {
173 name_pool: vec!["tara".to_owned(), "reo".to_owned(), "juno".to_owned()],
174 },
175 }
176}
177
178fn builtin_coordinator() -> Personality {
179 Personality {
180 personality: PersonalityCore {
181 name: "coordinator".to_owned(),
182 description: "BA/triage — reads code, manages issues, coordinates".to_owned(),
183 model: "sonnet".to_owned(),
184 },
185 tools: ToolConfig {
186 disallow: vec!["Write".to_owned(), "Edit".to_owned()],
187 ..Default::default()
188 },
189 prompt: PromptConfig {
190 template: "You are a coordinator agent. Triage issues, manage the taskboard, \
191 review plans, and coordinate work across agents. Do not modify code directly."
192 .to_owned(),
193 },
194 naming: NamingConfig {
195 name_pool: vec!["sage".to_owned(), "atlas".to_owned()],
196 },
197 }
198}
199
200pub fn builtin_personalities() -> HashMap<String, Personality> {
202 let mut map = HashMap::new();
203 for p in [
204 builtin_coder(),
205 builtin_reviewer(),
206 builtin_scout(),
207 builtin_qa(),
208 builtin_coordinator(),
209 ] {
210 map.insert(p.personality.name.clone(), p);
211 }
212 map
213}
214
215pub fn all_personality_names() -> Vec<String> {
217 let mut names: Vec<String> = builtin_personalities().keys().cloned().collect();
218
219 if let Some(dir) = personalities_dir() {
221 if let Ok(entries) = std::fs::read_dir(&dir) {
222 for entry in entries.flatten() {
223 let path = entry.path();
224 if path.extension().is_some_and(|e| e == "toml") {
225 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
226 if !names.contains(&stem.to_owned()) {
227 names.push(stem.to_owned());
228 }
229 }
230 }
231 }
232 }
233 }
234
235 names.sort();
236 names
237}
238
239pub fn resolve_personality(name: &str) -> Option<Personality> {
247 if let Some(dir) = personalities_dir() {
249 let toml_path = dir.join(format!("{name}.toml"));
250 if let Some(p) = load_personality_toml(&toml_path) {
251 return Some(p);
252 }
253 }
254
255 builtin_personalities().remove(name)
257}
258
259fn load_personality_toml(path: &Path) -> Option<Personality> {
261 let content = std::fs::read_to_string(path).ok()?;
262 toml::from_str(&content).ok()
263}
264
265fn personalities_dir() -> Option<PathBuf> {
267 dirs_path().map(|d| d.join("personalities"))
268}
269
270fn dirs_path() -> Option<PathBuf> {
272 std::env::var("HOME")
273 .ok()
274 .map(|h| PathBuf::from(h).join(".room"))
275}
276
277#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn builtin_personalities_has_all_five() {
285 let builtins = builtin_personalities();
286 assert!(builtins.contains_key("coder"));
287 assert!(builtins.contains_key("reviewer"));
288 assert!(builtins.contains_key("scout"));
289 assert!(builtins.contains_key("qa"));
290 assert!(builtins.contains_key("coordinator"));
291 assert_eq!(builtins.len(), 5);
292 }
293
294 #[test]
295 fn builtin_coder_has_opus_model() {
296 let builtins = builtin_personalities();
297 let coder = &builtins["coder"];
298 assert_eq!(coder.personality.model, "opus");
299 }
300
301 #[test]
302 fn builtin_reviewer_disallows_write_edit() {
303 let builtins = builtin_personalities();
304 let reviewer = &builtins["reviewer"];
305 assert!(reviewer.tools.disallow.contains(&"Write".to_owned()));
306 assert!(reviewer.tools.disallow.contains(&"Edit".to_owned()));
307 }
308
309 #[test]
310 fn builtin_scout_disallows_write_edit_bash() {
311 let builtins = builtin_personalities();
312 let scout = &builtins["scout"];
313 assert!(scout.tools.disallow.contains(&"Write".to_owned()));
314 assert!(scout.tools.disallow.contains(&"Edit".to_owned()));
315 assert!(scout.tools.disallow.contains(&"Bash".to_owned()));
316 }
317
318 #[test]
319 fn generate_username_from_name_pool() {
320 let p = builtin_coder();
321 let username = p.generate_username(&[]);
322 assert!(username.starts_with("coder-"));
323 assert!(
325 p.naming
326 .name_pool
327 .iter()
328 .any(|n| username == format!("coder-{n}")),
329 "expected name from pool, got: {username}"
330 );
331 }
332
333 #[test]
334 fn generate_username_skips_used_names() {
335 let p = builtin_coder();
336 let first_name = format!("coder-{}", p.naming.name_pool[0]);
338 let username = p.generate_username(&[first_name.clone()]);
339 assert_ne!(username, first_name);
340 assert!(username.starts_with("coder-"));
341 }
342
343 #[test]
344 fn generate_username_fallback_to_uuid_when_pool_exhausted() {
345 let p = builtin_reviewer();
346 let used: Vec<String> = p
348 .naming
349 .name_pool
350 .iter()
351 .map(|n| format!("reviewer-{n}"))
352 .collect();
353 let username = p.generate_username(&used);
354 assert!(username.starts_with("reviewer-"));
355 let suffix = username.strip_prefix("reviewer-").unwrap();
357 assert_eq!(suffix.len(), 8);
358 }
359
360 #[test]
361 fn generate_username_empty_pool_uses_uuid() {
362 let mut p = builtin_coder();
363 p.naming.name_pool.clear();
364 let username = p.generate_username(&[]);
365 assert!(username.starts_with("coder-"));
366 let suffix = username.strip_prefix("coder-").unwrap();
367 assert_eq!(suffix.len(), 8);
368 }
369
370 #[test]
371 fn toml_deserialization_roundtrip() {
372 let toml_str = r#"
373[personality]
374name = "custom"
375description = "A custom personality"
376model = "opus"
377
378[tools]
379disallow = ["Bash"]
380
381[prompt]
382template = "You are a custom agent."
383
384[naming]
385name_pool = ["alpha", "beta"]
386"#;
387 let p: Personality = toml::from_str(toml_str).unwrap();
388 assert_eq!(p.personality.name, "custom");
389 assert_eq!(p.personality.description, "A custom personality");
390 assert_eq!(p.personality.model, "opus");
391 assert_eq!(p.tools.disallow, vec!["Bash"]);
392 assert!(p.tools.allow.is_empty());
393 assert!(!p.tools.allow_all);
394 assert_eq!(p.prompt.template, "You are a custom agent.");
395 assert_eq!(p.naming.name_pool, vec!["alpha", "beta"]);
396 }
397
398 #[test]
399 fn toml_deserialization_minimal() {
400 let toml_str = r#"
401[personality]
402name = "minimal"
403description = "Minimal personality"
404"#;
405 let p: Personality = toml::from_str(toml_str).unwrap();
406 assert_eq!(p.personality.name, "minimal");
407 assert_eq!(p.personality.model, "sonnet"); assert!(p.tools.disallow.is_empty());
409 assert!(p.tools.allow.is_empty());
410 assert!(p.prompt.template.is_empty());
411 assert!(p.naming.name_pool.is_empty());
412 }
413
414 #[test]
415 fn toml_deserialization_allow_all() {
416 let toml_str = r#"
417[personality]
418name = "unrestricted"
419description = "No tool restrictions"
420
421[tools]
422allow_all = true
423"#;
424 let p: Personality = toml::from_str(toml_str).unwrap();
425 assert!(p.tools.allow_all);
426 }
427
428 #[test]
429 fn resolve_personality_returns_builtin() {
430 let p = resolve_personality("coder").unwrap();
431 assert_eq!(p.personality.name, "coder");
432 assert_eq!(p.personality.model, "opus");
433 }
434
435 #[test]
436 fn resolve_personality_returns_none_for_unknown() {
437 assert!(resolve_personality("nonexistent-personality-xyz").is_none());
438 }
439
440 #[test]
441 fn resolve_personality_user_toml_overrides_builtin() {
442 let dir = tempfile::tempdir().unwrap();
443 let personalities_dir = dir.path().join("personalities");
444 std::fs::create_dir_all(&personalities_dir).unwrap();
445
446 let toml_content = r#"
447[personality]
448name = "coder"
449description = "Custom coder override"
450model = "haiku"
451"#;
452 std::fs::write(personalities_dir.join("coder.toml"), toml_content).unwrap();
453
454 let p = load_personality_toml(&personalities_dir.join("coder.toml")).unwrap();
456 assert_eq!(p.personality.name, "coder");
457 assert_eq!(p.personality.model, "haiku");
458 assert_eq!(p.personality.description, "Custom coder override");
459 }
460
461 #[test]
462 fn all_personality_names_includes_builtins() {
463 let names = all_personality_names();
464 assert!(names.contains(&"coder".to_owned()));
465 assert!(names.contains(&"reviewer".to_owned()));
466 assert!(names.contains(&"scout".to_owned()));
467 assert!(names.contains(&"qa".to_owned()));
468 assert!(names.contains(&"coordinator".to_owned()));
469 }
470
471 #[test]
472 fn all_personality_names_sorted() {
473 let names = all_personality_names();
474 let mut sorted = names.clone();
475 sorted.sort();
476 assert_eq!(names, sorted);
477 }
478}