use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Persona {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub base: Option<String>,
#[serde(default)]
pub settings: Option<serde_json::Value>,
#[serde(default)]
pub skills: Option<SkillsConfig>,
#[serde(default)]
pub mcp: Option<McpConfig>,
#[serde(default)]
pub claude_md: Option<ClaudeMdConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct SkillsConfig {
#[serde(default)]
pub active: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct McpConfig {
#[serde(default)]
pub enable: Vec<String>,
#[serde(default)]
pub disable: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct ClaudeMdConfig {
#[serde(default)]
pub file: Option<String>,
}
impl Persona {
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read persona file: {}", path.display()))?;
let persona: Persona = toml::from_str(&content)
.with_context(|| format!("Failed to parse: {}", path.display()))?;
Ok(persona)
}
pub fn save(&self, path: &Path) -> Result<()> {
let content =
toml::to_string_pretty(self).context("Failed to serialize persona to TOML")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, content)?;
Ok(())
}
pub fn resolve(name: &str, personas_dir: &Path) -> Result<Persona> {
let mut chain = Vec::new();
let mut current_name = name.to_string();
let mut visited = std::collections::HashSet::new();
loop {
if !visited.insert(current_name.clone()) {
bail!(
"Circular inheritance detected: {} already in chain",
current_name
);
}
let path = personas_dir.join(format!("{}.toml", current_name));
let persona = Persona::load(&path)?;
let base = persona.base.clone();
chain.push(persona);
match base {
Some(b) => current_name = b,
None => break,
}
}
chain.reverse();
let mut merged = chain.remove(0);
for overlay in chain {
merged = Self::merge(merged, overlay);
}
Ok(merged)
}
fn merge(base: Persona, overlay: Persona) -> Persona {
Persona {
name: overlay.name,
description: if overlay.description.is_empty() {
base.description
} else {
overlay.description
},
base: overlay.base,
settings: match (base.settings, overlay.settings) {
(Some(b), Some(o)) => Some(json_merge(b, o)),
(b, o) => o.or(b),
},
skills: overlay.skills.or(base.skills),
mcp: overlay.mcp.or(base.mcp),
claude_md: overlay.claude_md.or(base.claude_md),
}
}
}
fn json_merge(base: serde_json::Value, overlay: serde_json::Value) -> serde_json::Value {
use serde_json::Value;
match (base, overlay) {
(Value::Object(mut b), Value::Object(o)) => {
for (k, v) in o {
let merged = if let Some(base_v) = b.remove(&k) {
json_merge(base_v, v)
} else {
v
};
b.insert(k, merged);
}
Value::Object(b)
}
(_, overlay) => overlay,
}
}
pub fn list_personas(personas_dir: &Path) -> Result<Vec<String>> {
let mut names = Vec::new();
if !personas_dir.exists() {
return Ok(names);
}
for entry in std::fs::read_dir(personas_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "toml")
&& let Some(stem) = path.file_stem()
{
names.push(stem.to_string_lossy().to_string());
}
}
names.sort();
Ok(names)
}
pub fn persona_path(personas_dir: &Path, name: &str) -> PathBuf {
personas_dir.join(format!("{}.toml", name))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::TestEnv;
use serde_json::json;
#[test]
fn resolve_merges_inheritance_and_replaces_non_settings_sections() {
let env = TestEnv::new();
std::fs::create_dir_all(&env.paths.personas).unwrap();
env.write_file(
&env.paths.personas.join("base.toml"),
r#"
name = "base"
description = "Base persona"
[settings]
model = "claude-sonnet"
[settings.ui]
theme = "light"
font = "mono"
[skills]
active = ["base-skill"]
[mcp]
enable = ["GitHub"]
disable = ["Figma"]
[claude_md]
file = "base.md"
"#,
);
env.write_file(
&env.paths.personas.join("engineer.toml"),
r#"
name = "engineer"
description = "Engineer persona"
base = "base"
[settings]
outputStyle = "Concise"
[settings.ui]
theme = "dark"
[skills]
active = ["alpha"]
[mcp]
enable = ["Linear"]
disable = ["Playwright"]
[claude_md]
file = "engineer.md"
"#,
);
let resolved = Persona::resolve("engineer", &env.paths.personas).unwrap();
assert_eq!(resolved.name, "engineer");
assert_eq!(resolved.description, "Engineer persona");
assert_eq!(
resolved.settings,
Some(json!({
"model": "claude-sonnet",
"outputStyle": "Concise",
"ui": {
"theme": "dark",
"font": "mono"
}
}))
);
assert_eq!(resolved.skills.unwrap().active, vec!["alpha"]);
let mcp = resolved.mcp.unwrap();
assert_eq!(mcp.enable, vec!["Linear"]);
assert_eq!(mcp.disable, vec!["Playwright"]);
assert_eq!(
resolved.claude_md.unwrap().file.as_deref(),
Some("engineer.md")
);
}
#[test]
fn resolve_detects_circular_inheritance() {
let env = TestEnv::new();
std::fs::create_dir_all(&env.paths.personas).unwrap();
env.write_file(
&env.paths.personas.join("a.toml"),
"name = \"a\"\nbase = \"b\"\n",
);
env.write_file(
&env.paths.personas.join("b.toml"),
"name = \"b\"\nbase = \"a\"\n",
);
let err = Persona::resolve("a", &env.paths.personas).unwrap_err();
assert!(format!("{err:#}").contains("Circular inheritance detected"));
}
#[test]
fn list_personas_only_returns_sorted_toml_files() {
let env = TestEnv::new();
std::fs::create_dir_all(&env.paths.personas).unwrap();
env.write_file(&env.paths.personas.join("zeta.toml"), "name = \"zeta\"\n");
env.write_file(&env.paths.personas.join("alpha.toml"), "name = \"alpha\"\n");
env.write_file(&env.paths.personas.join("notes.txt"), "ignore me");
std::fs::create_dir_all(env.paths.personas.join("nested")).unwrap();
let names = list_personas(&env.paths.personas).unwrap();
assert_eq!(names, vec!["alpha".to_string(), "zeta".to_string()]);
}
}