Skip to main content

heartbit_core/config/
persona.rs

1//! `[[persona]]` config block for declaring persona instances in
2//! `heartbit.toml` / `daemon.toml`.
3//!
4//! Phase 0 scope: Validation is **lexical only** — the `recipe` field is
5//! checked syntactically (must be `<crate>:<name>`), but no [`PersonaRegistry`]
6//! lookup happens here. The registry is empty in this phase; the resolution
7//! step runs at daemon startup once persona crates are loaded.
8//!
9//! [`PersonaRegistry`]: crate::persona::PersonaRegistry
10
11use serde::Deserialize;
12
13use crate::error::Error;
14use crate::persona::AuthorshipMode;
15
16/// Autonomy phase progression for a persona instance.
17///
18/// Phases drive the routing fraction of candidate posts that go to human
19/// review versus auto-publish. A persona typically progresses from
20/// `Calibration` (100% review) toward `Autonomous` (10% sampled review)
21/// as confidence in its outputs grows.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum PersonaPhase {
25    /// 100% candidates routed to review.
26    #[default]
27    Calibration,
28    /// 80% review / 20% auto-publish (high-confidence).
29    Supervised,
30    /// 10% review (sampled) / 90% auto-publish.
31    Autonomous,
32    /// Only flagged candidates routed to review.
33    Sentinel,
34}
35
36/// One `[[persona]]` block.
37///
38/// `recipe` is a key of the form `<crate_short>:<recipe>` (e.g.
39/// `"heartbit-ghost:x"`) that names a persona recipe registered in the
40/// [`PersonaRegistry`]. The lookup is deferred to daemon startup; this
41/// struct only enforces the lexical shape.
42///
43/// [`PersonaRegistry`]: crate::persona::PersonaRegistry
44#[derive(Debug, Clone, Deserialize)]
45pub struct PersonaConfig {
46    /// Local instance name (must be unique within the config file).
47    pub name: String,
48    /// Recipe key in the form `<crate_short>:<recipe>`, e.g. `"heartbit-ghost:x"`.
49    pub recipe: String,
50    /// Glob for env-var credential lookup, e.g. `"X_*"`.
51    #[serde(default)]
52    pub credentials_env: Option<String>,
53    /// Authorship mode (default `human_assisted`).
54    #[serde(default)]
55    pub authorship_mode: AuthorshipMode,
56    /// Initial autonomy phase.
57    #[serde(default)]
58    pub phase: PersonaPhase,
59    /// Persona-specific overrides (free-form TOML table; interpreted by the
60    /// recipe's `expand()`).
61    ///
62    /// Typed as [`toml::Table`] (not [`toml::Value`]) so a misuse like
63    /// `overrides = "string"` fails at deserialize-time with a clear schema
64    /// error instead of surfacing later as a confusing `expand()` failure.
65    #[serde(default)]
66    pub overrides: toml::Table,
67}
68
69impl PersonaConfig {
70    /// Lexical validation: recipe key parses, name is non-empty.
71    /// Does not consult the registry.
72    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}