heartbit_core/config/
persona.rs1use serde::Deserialize;
12
13use crate::error::Error;
14use crate::persona::AuthorshipMode;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum PersonaPhase {
25 #[default]
27 Calibration,
28 Supervised,
30 Autonomous,
32 Sentinel,
34}
35
36#[derive(Debug, Clone, Deserialize)]
45pub struct PersonaConfig {
46 pub name: String,
48 pub recipe: String,
50 #[serde(default)]
52 pub credentials_env: Option<String>,
53 #[serde(default)]
55 pub authorship_mode: AuthorshipMode,
56 #[serde(default)]
58 pub phase: PersonaPhase,
59 #[serde(default)]
66 pub overrides: toml::Table,
67}
68
69impl PersonaConfig {
70 pub fn validate(&self) -> Result<(), Error> {
73 if self.name.trim().is_empty() {
74 return Err(Error::Config("persona name must be non-empty".into()));
75 }
76 if !self.recipe.contains(':') {
77 return Err(Error::Config(format!(
78 "persona '{}' recipe '{}' must be of the form '<crate>:<name>'",
79 self.name, self.recipe
80 )));
81 }
82 let (lhs, rhs) = self.recipe.split_once(':').unwrap();
83 if lhs.trim().is_empty() || rhs.trim().is_empty() {
84 return Err(Error::Config(format!(
85 "persona '{}' recipe '{}' has empty crate or name component",
86 self.name, self.recipe
87 )));
88 }
89 Ok(())
90 }
91}
92
93impl Default for PersonaConfig {
94 fn default() -> Self {
95 Self {
96 name: String::new(),
97 recipe: String::new(),
98 credentials_env: None,
99 authorship_mode: AuthorshipMode::default(),
100 phase: PersonaPhase::default(),
101 overrides: toml::Table::new(),
102 }
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 fn parse(toml_text: &str) -> Result<PersonaConfig, toml::de::Error> {
111 toml::from_str::<PersonaConfig>(toml_text)
112 }
113
114 #[test]
115 fn parses_minimal_persona() {
116 let c: PersonaConfig = parse(
117 r#"
118 name = "x"
119 recipe = "heartbit-ghost:x"
120 "#,
121 )
122 .expect("parses");
123 assert_eq!(c.name, "x");
124 assert_eq!(c.recipe, "heartbit-ghost:x");
125 assert_eq!(c.authorship_mode, AuthorshipMode::HumanAssisted);
126 assert_eq!(c.phase, PersonaPhase::Calibration);
127 }
128
129 #[test]
130 fn parses_full_persona() {
131 let c: PersonaConfig = parse(
132 r#"
133 name = "x"
134 recipe = "heartbit-ghost:x"
135 credentials_env = "X_*"
136 authorship_mode = "autonomous_undisclosed"
137 phase = "supervised"
138 "#,
139 )
140 .expect("parses");
141 assert_eq!(c.credentials_env.as_deref(), Some("X_*"));
142 assert_eq!(c.authorship_mode, AuthorshipMode::AutonomousUndisclosed);
143 assert_eq!(c.phase, PersonaPhase::Supervised);
144 }
145
146 #[test]
147 fn validate_rejects_empty_name() {
148 let c = PersonaConfig {
149 name: "".into(),
150 recipe: "heartbit-ghost:x".into(),
151 ..Default::default()
152 };
153 let err = c.validate().unwrap_err();
154 assert!(matches!(err, Error::Config(s) if s.contains("non-empty")));
155 }
156
157 #[test]
158 fn validate_rejects_recipe_without_colon() {
159 let c = PersonaConfig {
160 name: "x".into(),
161 recipe: "heartbit-ghost-x".into(),
162 ..Default::default()
163 };
164 let err = c.validate().unwrap_err();
165 assert!(matches!(err, Error::Config(s) if s.contains("'<crate>:<name>'")));
166 }
167
168 #[test]
169 fn validate_rejects_empty_lhs_or_rhs() {
170 for bad in [":x", "heartbit-ghost:", ":"] {
171 let c = PersonaConfig {
172 name: "x".into(),
173 recipe: bad.into(),
174 ..Default::default()
175 };
176 let err = c.validate().unwrap_err();
177 assert!(
178 matches!(err, Error::Config(_)),
179 "expected Config error for recipe {:?}",
180 bad
181 );
182 }
183 }
184
185 #[test]
186 fn rejects_unknown_phase() {
187 let result = parse(
188 r#"
189 name = "x"
190 recipe = "heartbit-ghost:x"
191 phase = "ludicrous"
192 "#,
193 );
194 assert!(result.is_err());
195 }
196}