use serde::Deserialize;
use crate::error::Error;
use crate::persona::AuthorshipMode;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PersonaPhase {
#[default]
Calibration,
Supervised,
Autonomous,
Sentinel,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PersonaConfig {
pub name: String,
pub recipe: String,
#[serde(default)]
pub credentials_env: Option<String>,
#[serde(default)]
pub authorship_mode: AuthorshipMode,
#[serde(default)]
pub phase: PersonaPhase,
#[serde(default)]
pub overrides: toml::Table,
}
impl PersonaConfig {
pub fn validate(&self) -> Result<(), Error> {
if self.name.trim().is_empty() {
return Err(Error::Config("persona name must be non-empty".into()));
}
if !self.recipe.contains(':') {
return Err(Error::Config(format!(
"persona '{}' recipe '{}' must be of the form '<crate>:<name>'",
self.name, self.recipe
)));
}
let (lhs, rhs) = self.recipe.split_once(':').unwrap();
if lhs.trim().is_empty() || rhs.trim().is_empty() {
return Err(Error::Config(format!(
"persona '{}' recipe '{}' has empty crate or name component",
self.name, self.recipe
)));
}
Ok(())
}
}
impl Default for PersonaConfig {
fn default() -> Self {
Self {
name: String::new(),
recipe: String::new(),
credentials_env: None,
authorship_mode: AuthorshipMode::default(),
phase: PersonaPhase::default(),
overrides: toml::Table::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(toml_text: &str) -> Result<PersonaConfig, toml::de::Error> {
toml::from_str::<PersonaConfig>(toml_text)
}
#[test]
fn parses_minimal_persona() {
let c: PersonaConfig = parse(
r#"
name = "x"
recipe = "heartbit-ghost:x"
"#,
)
.expect("parses");
assert_eq!(c.name, "x");
assert_eq!(c.recipe, "heartbit-ghost:x");
assert_eq!(c.authorship_mode, AuthorshipMode::HumanAssisted);
assert_eq!(c.phase, PersonaPhase::Calibration);
}
#[test]
fn parses_full_persona() {
let c: PersonaConfig = parse(
r#"
name = "x"
recipe = "heartbit-ghost:x"
credentials_env = "X_*"
authorship_mode = "autonomous_undisclosed"
phase = "supervised"
"#,
)
.expect("parses");
assert_eq!(c.credentials_env.as_deref(), Some("X_*"));
assert_eq!(c.authorship_mode, AuthorshipMode::AutonomousUndisclosed);
assert_eq!(c.phase, PersonaPhase::Supervised);
}
#[test]
fn validate_rejects_empty_name() {
let c = PersonaConfig {
name: "".into(),
recipe: "heartbit-ghost:x".into(),
..Default::default()
};
let err = c.validate().unwrap_err();
assert!(matches!(err, Error::Config(s) if s.contains("non-empty")));
}
#[test]
fn validate_rejects_recipe_without_colon() {
let c = PersonaConfig {
name: "x".into(),
recipe: "heartbit-ghost-x".into(),
..Default::default()
};
let err = c.validate().unwrap_err();
assert!(matches!(err, Error::Config(s) if s.contains("'<crate>:<name>'")));
}
#[test]
fn validate_rejects_empty_lhs_or_rhs() {
for bad in [":x", "heartbit-ghost:", ":"] {
let c = PersonaConfig {
name: "x".into(),
recipe: bad.into(),
..Default::default()
};
let err = c.validate().unwrap_err();
assert!(
matches!(err, Error::Config(_)),
"expected Config error for recipe {:?}",
bad
);
}
}
#[test]
fn rejects_unknown_phase() {
let result = parse(
r#"
name = "x"
recipe = "heartbit-ghost:x"
phase = "ludicrous"
"#,
);
assert!(result.is_err());
}
}