Skip to main content

noether_store/
file.rs

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
12/// File-backed stage store. Persists to JSON on every mutation.
13/// Loads stdlib on first creation, then reads from disk on subsequent runs.
14pub struct JsonFileStore {
15    path: PathBuf,
16    stages: HashMap<String, Stage>,
17}
18
19/// On-disk format: just a list of stages.
20#[derive(Serialize, Deserialize)]
21struct StoreFile {
22    stages: Vec<Stage>,
23}
24
25impl JsonFileStore {
26    /// Open or create a store at the given path.
27    /// If the file exists, loads from it. Otherwise creates an empty store.
28    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    /// Number of stages in the store.
53    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    /// Persist current state to disk.
62    //
63    // NOTE(atomicity): `fs::write` truncates then writes — a mid-write
64    // crash can leave `path` empty or half-written. Future hardening:
65    // write to `<path>.tmp` and rename in place. Acceptable today
66    // because JsonFileStore is a developer-facing local registry, not a
67    // hot production path. Track via PR follow-up.
68    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        // M2.3 invariant: auto-deprecate existing Actives with the
94        // same signature_id. See MemoryStore::put for rationale.
95        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(&current.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        // M2.3 invariant: promoting to Active deprecates any other
174        // Active stage with the same signature_id.
175        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        // Create and add a stage
271        {
272            let mut store = JsonFileStore::open(&path).unwrap();
273            store.put(make_stage("abc123")).unwrap();
274            assert_eq!(store.len(), 1);
275        }
276
277        // Reload from disk
278        {
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        // Put two Active stages with the same signature through
315        // JsonFileStore; after reopening the file, the first must be
316        // Deprecated with the second as successor. Guards against a
317        // save() that writes stale state or forgets the deprecation.
318        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        // Delete the file so open() creates empty
351        fs::remove_file(&path).ok();
352
353        let store = JsonFileStore::open(&path).unwrap();
354        assert_eq!(store.len(), 0);
355    }
356}