1use std::collections::HashMap;
4
5use anyhow::{bail, Context, Result};
6use sha2::{Digest, Sha256};
7
8use crate::gap::{Artifact, Envelope, Name};
9use crate::apply;
10
11#[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 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::gap::*;
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#"<gap:target id="msg">hello</gap: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}