Skip to main content

gap/
store.rs

1//! Versioned artifact store — stateful layer over the stateless apply engine.
2
3use std::collections::HashMap;
4
5use anyhow::{bail, Context, Result};
6use sha2::{Digest, Sha256};
7
8use crate::aap::{Artifact, Envelope, Name};
9use crate::apply;
10
11/// In-memory versioned artifact store.
12#[derive(Debug, Default)]
13pub struct ArtifactStore {
14    history: HashMap<String, Vec<Artifact>>,
15    max_history: usize,
16}
17
18impl ArtifactStore {
19    pub fn new(max_history: usize) -> Self {
20        Self {
21            history: HashMap::new(),
22            max_history,
23        }
24    }
25
26    pub fn get(&self, id: &str) -> Option<&Artifact> {
27        self.history.get(id).and_then(|v| v.last())
28    }
29
30    pub fn current_version(&self, id: &str) -> Option<u64> {
31        self.get(id).map(|a| a.version)
32    }
33
34    /// Apply an envelope. Returns (Artifact, Handle).
35    pub fn apply(&mut self, envelope: &Envelope) -> Result<(Artifact, Envelope)> {
36        if envelope.name != Name::Synthesize {
37            if let Some(current) = self.current_version(&envelope.id) {
38                if current != envelope.version - 1 {
39                    bail!(
40                        "version conflict: stored={current}, envelope={}, expected={}",
41                        envelope.version, envelope.version - 1
42                    );
43                }
44            } else {
45                bail!("no base artifact for '{}' — synthesize first", envelope.id);
46            }
47        }
48
49        let artifact = self.get(&envelope.id);
50        let (new_artifact, handle) = apply::apply(artifact, envelope)?;
51
52        let entries = self.history.entry(envelope.id.clone()).or_default();
53        entries.push(new_artifact.clone());
54        while entries.len() > self.max_history {
55            entries.remove(0);
56        }
57
58        Ok((new_artifact, handle))
59    }
60
61    pub fn checksum(&self, id: &str) -> Result<String> {
62        let art = self.get(id).context("artifact not found")?;
63        let mut hasher = Sha256::new();
64        hasher.update(art.body.as_bytes());
65        Ok(format!("sha256:{:x}", hasher.finalize()))
66    }
67
68    pub fn rollback(&mut self, id: &str, target_version: u64) -> Result<Artifact> {
69        let entries = self.history.get_mut(id).context("artifact not found")?;
70        let idx = entries
71            .iter()
72            .position(|a| a.version == target_version)
73            .with_context(|| format!("version {target_version} not in history"))?;
74
75        let mut rolled = entries[idx].clone();
76        let new_version = entries.last().map(|a| a.version).unwrap_or(0) + 1;
77        rolled.version = new_version;
78        entries.push(rolled.clone());
79
80        while entries.len() > self.max_history {
81            entries.remove(0);
82        }
83
84        Ok(rolled)
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::aap::*;
92
93    fn make_meta(fmt: &str) -> Meta {
94        Meta {
95            format: Some(fmt.to_string()),
96            tokens_used: None, checksum: None, state: None,
97        }
98    }
99
100    fn synth_env(id: &str, version: u64, body: &str) -> Envelope {
101        Envelope {
102            protocol: PROTOCOL_VERSION.to_string(),
103            id: id.to_string(), version,
104            name: Name::Synthesize,
105            meta: make_meta("text/html"),
106            content: vec![serde_json::json!({ "body": body })],
107        }
108    }
109
110    fn edit_env(id: &str, version: u64, ops: Vec<EditOp>) -> Envelope {
111        Envelope {
112            protocol: PROTOCOL_VERSION.to_string(),
113            id: id.to_string(), version,
114            name: Name::Edit,
115            meta: make_meta("text/html"),
116            content: ops.iter().map(|o| serde_json::to_value(o).unwrap()).collect(),
117        }
118    }
119
120    #[test]
121    fn test_synthesize_then_edit() {
122        let mut store = ArtifactStore::new(10);
123
124        let (art, handle) = store.apply(&synth_env("t", 1, r#"<aap:target id="msg">hello</aap:target>"#)).unwrap();
125        assert_eq!(art.body.contains("hello"), true);
126        assert_eq!(handle.name, Name::Handle);
127
128        let (art2, _) = store.apply(&edit_env("t", 2, vec![EditOp {
129            op: OpType::Replace,
130            target: Target::Id("msg".to_string()),
131            content: Some("world".to_string()),
132        }])).unwrap();
133        assert!(art2.body.contains("world"));
134        assert_eq!(store.current_version("t"), Some(2));
135    }
136
137    #[test]
138    fn test_version_conflict() {
139        let mut store = ArtifactStore::new(10);
140        store.apply(&synth_env("t", 1, "content")).unwrap();
141        assert!(store.apply(&edit_env("t", 6, vec![])).is_err());
142    }
143
144    #[test]
145    fn test_rollback() {
146        let mut store = ArtifactStore::new(10);
147        store.apply(&synth_env("t", 1, "v1")).unwrap();
148        store.apply(&synth_env("t", 2, "v2")).unwrap();
149        let rolled = store.rollback("t", 1).unwrap();
150        assert_eq!(rolled.body, "v1");
151        assert_eq!(store.current_version("t"), Some(3));
152    }
153
154    #[test]
155    fn test_checksum() {
156        let mut store = ArtifactStore::new(10);
157        store.apply(&synth_env("t", 1, "hello")).unwrap();
158        assert!(store.checksum("t").unwrap().starts_with("sha256:"));
159    }
160}