Skip to main content

oxios_kernel/persona/
persistence.rs

1//! Persona persistence: load/save the full registry + active_persona_id to
2//! `StateStore` (RFC-039).
3//!
4//! File path: `~/.oxios/state/personas/index.json`
5//!
6//! Schema (schema_version = 1):
7//! ```json
8//! {
9//!   "schema_version": 1,
10//!   "active_persona_id": "dev",
11//!   "personas": [ { "id": "...", "name": "...", "role": "...",
12//!                   "description": "...", "system_prompt": "...",
13//!                   "enabled": true, "model": null,
14//!                   "personality_traits": [] }, ... ]
15//! }
16//! ```
17
18use anyhow::{Context, Result};
19use serde::{Deserialize, Serialize};
20
21use super::Persona;
22use crate::state_store::StateStore;
23
24const SCHEMA_VERSION: u32 = 1;
25
26/// Serializable snapshot of the persona registry.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PersonaSnapshot {
29    pub schema_version: u32,
30    /// Active persona ID. `None` if no persona is active.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub active_persona_id: Option<String>,
33    /// All personas (enabled and disabled). Empty Vec is allowed.
34    pub personas: Vec<Persona>,
35}
36
37/// Load the snapshot from `StateStore`. `Ok(None)` if file is absent.
38pub async fn load_from_state_store(store: &StateStore) -> Result<Option<PersonaSnapshot>> {
39    let raw: Option<PersonaSnapshot> = store
40        .load_json("personas", "index")
41        .await
42        .context("persona: failed to load index.json")?;
43    let Some(snap) = raw else { return Ok(None) };
44    if snap.schema_version != SCHEMA_VERSION {
45        anyhow::bail!(
46            "persona: schema_version {} not supported (expected {})",
47            snap.schema_version,
48            SCHEMA_VERSION
49        );
50    }
51    Ok(Some(snap))
52}
53
54/// Save the snapshot to `StateStore`. Atomic via `StateStore::durable_write`.
55pub async fn save_to_state_store(store: &StateStore, snapshot: &PersonaSnapshot) -> Result<()> {
56    store
57        .save_json("personas", "index", snapshot)
58        .await
59        .context("persona: failed to save index.json")
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use crate::persona::Persona;
66
67    fn make_persona(id: &str, name: &str, role: &str, enabled: bool) -> Persona {
68        Persona {
69            id: id.to_string(),
70            name: name.to_string(),
71            role: role.to_string(),
72            description: format!("Test persona {name}"),
73            system_prompt: format!("You are {name}."),
74            enabled,
75            model: None,
76            personality_traits: vec!["curious".to_string()],
77        }
78    }
79
80    fn make_store() -> StateStore {
81        let dir = tempfile::tempdir().unwrap();
82        StateStore::new(dir.keep()).unwrap()
83    }
84
85    #[tokio::test]
86    async fn test_round_trip() {
87        let store = make_store();
88        let snap = PersonaSnapshot {
89            schema_version: 1,
90            active_persona_id: Some("dev".to_string()),
91            personas: vec![
92                make_persona("dev", "Dev", "developer", true),
93                make_persona("qa", "Review", "qa", true),
94            ],
95        };
96        save_to_state_store(&store, &snap).await.unwrap();
97        let loaded = load_from_state_store(&store).await.unwrap().unwrap();
98        assert_eq!(loaded.schema_version, 1);
99        assert_eq!(loaded.active_persona_id, Some("dev".to_string()));
100        assert_eq!(loaded.personas.len(), 2);
101        assert_eq!(loaded.personas[0].id, "dev");
102        assert_eq!(loaded.personas[1].role, "qa");
103    }
104
105    #[tokio::test]
106    async fn test_no_file_returns_none() {
107        let store = make_store();
108        let loaded = load_from_state_store(&store).await.unwrap();
109        assert!(loaded.is_none());
110    }
111
112    #[tokio::test]
113    async fn test_schema_version_mismatch_errors() {
114        let store = make_store();
115        let snap = PersonaSnapshot {
116            schema_version: 99,
117            active_persona_id: None,
118            personas: vec![],
119        };
120        save_to_state_store(&store, &snap).await.unwrap();
121        let result = load_from_state_store(&store).await;
122        assert!(result.is_err());
123        let msg = result.unwrap_err().to_string();
124        assert!(msg.contains("schema_version"));
125    }
126
127    #[tokio::test]
128    async fn test_empty_personas_round_trip() {
129        let store = make_store();
130        let snap = PersonaSnapshot {
131            schema_version: 1,
132            active_persona_id: None,
133            personas: vec![],
134        };
135        save_to_state_store(&store, &snap).await.unwrap();
136        let loaded = load_from_state_store(&store).await.unwrap().unwrap();
137        assert!(loaded.personas.is_empty());
138        assert!(loaded.active_persona_id.is_none());
139    }
140
141    #[tokio::test]
142    async fn test_overwrite_on_save() {
143        let store = make_store();
144        let snap1 = PersonaSnapshot {
145            schema_version: 1,
146            active_persona_id: Some("dev".to_string()),
147            personas: vec![make_persona("dev", "Dev", "developer", true)],
148        };
149        save_to_state_store(&store, &snap1).await.unwrap();
150        let snap2 = PersonaSnapshot {
151            schema_version: 1,
152            active_persona_id: Some("qa".to_string()),
153            personas: vec![
154                make_persona("dev", "Dev", "developer", true),
155                make_persona("qa", "Review", "qa", true),
156            ],
157        };
158        save_to_state_store(&store, &snap2).await.unwrap();
159        let loaded = load_from_state_store(&store).await.unwrap().unwrap();
160        assert_eq!(loaded.active_persona_id, Some("qa".to_string()));
161        assert_eq!(loaded.personas.len(), 2);
162    }
163}