1use crate::invariant::{
2 duplicate_active_ids_for, duplicate_active_ids_for_incoming, log_auto_deprecation,
3};
4use crate::lifecycle::validate_transition;
5use crate::traits::{StageStore, StoreError, StoreStats};
6use noether_core::stage::{Stage, StageId, StageLifecycle};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, HashMap};
9use std::fs;
10use std::path::PathBuf;
11
12pub struct JsonFileStore {
15 path: PathBuf,
16 stages: HashMap<String, Stage>,
17}
18
19#[derive(Serialize, Deserialize)]
21struct StoreFile {
22 stages: Vec<Stage>,
23}
24
25impl JsonFileStore {
26 pub fn open(path: impl Into<PathBuf>) -> Result<Self, StoreError> {
29 let path = path.into();
30 let stages = if path.exists() {
31 let content = fs::read_to_string(&path).map_err(|e| StoreError::IoError {
32 message: format!("failed to read {}: {e}", path.display()),
33 })?;
34 if content.trim().is_empty() {
35 HashMap::new()
36 } else {
37 let file: StoreFile =
38 serde_json::from_str(&content).map_err(|e| StoreError::IoError {
39 message: format!("failed to parse {}: {e}", path.display()),
40 })?;
41 file.stages
42 .into_iter()
43 .map(|s| (s.id.0.clone(), s))
44 .collect()
45 }
46 } else {
47 HashMap::new()
48 };
49 Ok(Self { path, stages })
50 }
51
52 pub fn len(&self) -> usize {
54 self.stages.len()
55 }
56
57 pub fn is_empty(&self) -> bool {
58 self.stages.is_empty()
59 }
60
61 fn save(&self) -> Result<(), StoreError> {
69 if let Some(parent) = self.path.parent() {
70 fs::create_dir_all(parent).map_err(|e| StoreError::IoError {
71 message: format!("failed to create directory {}: {e}", parent.display()),
72 })?;
73 }
74 let file = StoreFile {
75 stages: self.stages.values().cloned().collect(),
76 };
77 let json = serde_json::to_string_pretty(&file).map_err(|e| StoreError::IoError {
78 message: format!("serialization failed: {e}"),
79 })?;
80 fs::write(&self.path, json).map_err(|e| StoreError::IoError {
81 message: format!("failed to write {}: {e}", self.path.display()),
82 })?;
83 Ok(())
84 }
85}
86
87impl StageStore for JsonFileStore {
88 fn put(&mut self, stage: Stage) -> Result<StageId, StoreError> {
89 let id = stage.id.clone();
90 if self.stages.contains_key(&id.0) {
91 return Err(StoreError::AlreadyExists(id));
92 }
93 let duplicates = duplicate_active_ids_for_incoming(&self.stages, &stage);
96 let signature_id = stage.signature_id.clone();
97 self.stages.insert(id.0.clone(), stage);
98 for old_id in &duplicates {
99 if let Some(existing) = self.stages.get_mut(&old_id.0) {
100 existing.lifecycle = StageLifecycle::Deprecated {
101 successor_id: id.clone(),
102 };
103 }
104 }
105 log_auto_deprecation(&duplicates, &id, signature_id.as_ref());
106 self.save()?;
107 Ok(id)
108 }
109
110 fn upsert(&mut self, stage: Stage) -> Result<StageId, StoreError> {
111 let id = stage.id.clone();
112 let duplicates = duplicate_active_ids_for_incoming(&self.stages, &stage);
113 let signature_id = stage.signature_id.clone();
114 self.stages.insert(id.0.clone(), stage);
115 let actually_deprecated: Vec<StageId> = duplicates
116 .into_iter()
117 .filter(|old_id| *old_id != id)
118 .collect();
119 for old_id in &actually_deprecated {
120 if let Some(existing) = self.stages.get_mut(&old_id.0) {
121 existing.lifecycle = StageLifecycle::Deprecated {
122 successor_id: id.clone(),
123 };
124 }
125 }
126 log_auto_deprecation(&actually_deprecated, &id, signature_id.as_ref());
127 self.save()?;
128 Ok(id)
129 }
130
131 fn remove(&mut self, id: &StageId) -> Result<(), StoreError> {
132 self.stages.remove(&id.0);
133 self.save()?;
134 Ok(())
135 }
136
137 fn get(&self, id: &StageId) -> Result<Option<&Stage>, StoreError> {
138 Ok(self.stages.get(&id.0))
139 }
140
141 fn contains(&self, id: &StageId) -> bool {
142 self.stages.contains_key(&id.0)
143 }
144
145 fn list(&self, lifecycle: Option<&StageLifecycle>) -> Vec<&Stage> {
146 self.stages
147 .values()
148 .filter(|s| lifecycle.is_none() || lifecycle == Some(&s.lifecycle))
149 .collect()
150 }
151
152 fn update_lifecycle(
153 &mut self,
154 id: &StageId,
155 lifecycle: StageLifecycle,
156 ) -> Result<(), StoreError> {
157 let current = self
158 .stages
159 .get(&id.0)
160 .ok_or_else(|| StoreError::NotFound(id.clone()))?;
161
162 validate_transition(¤t.lifecycle, &lifecycle)
163 .map_err(|reason| StoreError::InvalidTransition { reason })?;
164
165 if let StageLifecycle::Deprecated { ref successor_id } = lifecycle {
166 if !self.stages.contains_key(&successor_id.0) {
167 return Err(StoreError::InvalidSuccessor {
168 reason: format!("successor {successor_id:?} not found in store"),
169 });
170 }
171 }
172
173 let (duplicates, signature_id) = if matches!(lifecycle, StageLifecycle::Active) {
176 let sig = current.signature_id.clone();
177 (
178 duplicate_active_ids_for(&self.stages, id, sig.as_ref()),
179 sig,
180 )
181 } else {
182 (Vec::new(), None)
183 };
184
185 self.stages.get_mut(&id.0).unwrap().lifecycle = lifecycle;
186 for old_id in &duplicates {
187 if let Some(existing) = self.stages.get_mut(&old_id.0) {
188 existing.lifecycle = StageLifecycle::Deprecated {
189 successor_id: id.clone(),
190 };
191 }
192 }
193 log_auto_deprecation(&duplicates, id, signature_id.as_ref());
194 self.save()?;
195 Ok(())
196 }
197
198 fn stats(&self) -> StoreStats {
199 let mut by_lifecycle: BTreeMap<String, usize> = BTreeMap::new();
200 let mut by_effect: BTreeMap<String, usize> = BTreeMap::new();
201
202 for stage in self.stages.values() {
203 let lc_name = match &stage.lifecycle {
204 StageLifecycle::Draft => "draft",
205 StageLifecycle::Active => "active",
206 StageLifecycle::Deprecated { .. } => "deprecated",
207 StageLifecycle::Tombstone => "tombstone",
208 };
209 *by_lifecycle.entry(lc_name.into()).or_default() += 1;
210
211 for effect in stage.signature.effects.iter() {
212 let effect_name = format!("{effect:?}");
213 *by_effect.entry(effect_name).or_default() += 1;
214 }
215 }
216
217 StoreStats {
218 total: self.stages.len(),
219 by_lifecycle,
220 by_effect,
221 }
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use noether_core::effects::EffectSet;
229 use noether_core::stage::{CostEstimate, StageSignature};
230 use noether_core::types::NType;
231 use std::collections::BTreeSet;
232 use tempfile::NamedTempFile;
233
234 fn make_stage(id: &str) -> Stage {
235 Stage {
236 id: StageId(id.into()),
237 signature_id: None,
238 signature: StageSignature {
239 input: NType::Text,
240 output: NType::Number,
241 effects: EffectSet::pure(),
242 implementation_hash: format!("impl_{id}"),
243 },
244 capabilities: BTreeSet::new(),
245 cost: CostEstimate {
246 time_ms_p50: None,
247 tokens_est: None,
248 memory_mb: None,
249 },
250 description: "test stage".into(),
251 examples: vec![],
252 lifecycle: StageLifecycle::Active,
253 ed25519_signature: None,
254 signer_public_key: None,
255 implementation_code: None,
256 implementation_language: None,
257 ui_style: None,
258 tags: vec![],
259 aliases: vec![],
260 name: None,
261 properties: Vec::new(),
262 }
263 }
264
265 #[test]
266 fn create_and_reload() {
267 let tmp = NamedTempFile::new().unwrap();
268 let path = tmp.path().to_path_buf();
269
270 {
272 let mut store = JsonFileStore::open(&path).unwrap();
273 store.put(make_stage("abc123")).unwrap();
274 assert_eq!(store.len(), 1);
275 }
276
277 {
279 let store = JsonFileStore::open(&path).unwrap();
280 assert_eq!(store.len(), 1);
281 let stage = store.get(&StageId("abc123".into())).unwrap().unwrap();
282 assert_eq!(stage.description, "test stage");
283 }
284 }
285
286 #[test]
287 fn persists_lifecycle_changes() {
288 let tmp = NamedTempFile::new().unwrap();
289 let path = tmp.path().to_path_buf();
290
291 {
292 let mut store = JsonFileStore::open(&path).unwrap();
293 store.put(make_stage("old")).unwrap();
294 store.put(make_stage("new")).unwrap();
295 store
296 .update_lifecycle(
297 &StageId("old".into()),
298 StageLifecycle::Deprecated {
299 successor_id: StageId("new".into()),
300 },
301 )
302 .unwrap();
303 }
304
305 {
306 let store = JsonFileStore::open(&path).unwrap();
307 let stage = store.get(&StageId("old".into())).unwrap().unwrap();
308 assert!(matches!(stage.lifecycle, StageLifecycle::Deprecated { .. }));
309 }
310 }
311
312 #[test]
313 fn auto_deprecation_persists_across_reload() {
314 use noether_core::stage::SignatureId;
319 let tmp = NamedTempFile::new().unwrap();
320 let path = tmp.path().to_path_buf();
321
322 {
323 let mut store = JsonFileStore::open(&path).unwrap();
324 let mut a = make_stage("impl_a");
325 a.signature_id = Some(SignatureId("sig".into()));
326 let mut b = make_stage("impl_b");
327 b.signature_id = Some(SignatureId("sig".into()));
328 store.put(a).unwrap();
329 store.put(b).unwrap();
330 }
331
332 {
333 let store = JsonFileStore::open(&path).unwrap();
334 let stored_a = store.get(&StageId("impl_a".into())).unwrap().unwrap();
335 match &stored_a.lifecycle {
336 StageLifecycle::Deprecated { successor_id } => {
337 assert_eq!(successor_id.0, "impl_b");
338 }
339 other => panic!("expected Deprecated on reload, got {other:?}"),
340 }
341 let stored_b = store.get(&StageId("impl_b".into())).unwrap().unwrap();
342 assert!(matches!(stored_b.lifecycle, StageLifecycle::Active));
343 }
344 }
345
346 #[test]
347 fn empty_file_creates_empty_store() {
348 let tmp = NamedTempFile::new().unwrap();
349 let path = tmp.path().to_path_buf();
350 fs::remove_file(&path).ok();
352
353 let store = JsonFileStore::open(&path).unwrap();
354 assert_eq!(store.len(), 0);
355 }
356}