Skip to main content

noether_store/
file.rs

1use crate::lifecycle::validate_transition;
2use crate::traits::{StageStore, StoreError, StoreStats};
3use noether_core::stage::{Stage, StageId, StageLifecycle};
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, HashMap};
6use std::fs;
7use std::path::PathBuf;
8
9/// File-backed stage store. Persists to JSON on every mutation.
10/// Loads stdlib on first creation, then reads from disk on subsequent runs.
11pub struct JsonFileStore {
12    path: PathBuf,
13    stages: HashMap<String, Stage>,
14}
15
16/// On-disk format: just a list of stages.
17#[derive(Serialize, Deserialize)]
18struct StoreFile {
19    stages: Vec<Stage>,
20}
21
22impl JsonFileStore {
23    /// Open or create a store at the given path.
24    /// If the file exists, loads from it. Otherwise creates an empty store.
25    pub fn open(path: impl Into<PathBuf>) -> Result<Self, StoreError> {
26        let path = path.into();
27        let stages = if path.exists() {
28            let content = fs::read_to_string(&path).map_err(|e| StoreError::IoError {
29                message: format!("failed to read {}: {e}", path.display()),
30            })?;
31            if content.trim().is_empty() {
32                HashMap::new()
33            } else {
34                let file: StoreFile =
35                    serde_json::from_str(&content).map_err(|e| StoreError::IoError {
36                        message: format!("failed to parse {}: {e}", path.display()),
37                    })?;
38                file.stages
39                    .into_iter()
40                    .map(|s| (s.id.0.clone(), s))
41                    .collect()
42            }
43        } else {
44            HashMap::new()
45        };
46        Ok(Self { path, stages })
47    }
48
49    /// Number of stages in the store.
50    pub fn len(&self) -> usize {
51        self.stages.len()
52    }
53
54    pub fn is_empty(&self) -> bool {
55        self.stages.is_empty()
56    }
57
58    /// Persist current state to disk.
59    fn save(&self) -> Result<(), StoreError> {
60        if let Some(parent) = self.path.parent() {
61            fs::create_dir_all(parent).map_err(|e| StoreError::IoError {
62                message: format!("failed to create directory {}: {e}", parent.display()),
63            })?;
64        }
65        let file = StoreFile {
66            stages: self.stages.values().cloned().collect(),
67        };
68        let json = serde_json::to_string_pretty(&file).map_err(|e| StoreError::IoError {
69            message: format!("serialization failed: {e}"),
70        })?;
71        fs::write(&self.path, json).map_err(|e| StoreError::IoError {
72            message: format!("failed to write {}: {e}", self.path.display()),
73        })?;
74        Ok(())
75    }
76}
77
78impl StageStore for JsonFileStore {
79    fn put(&mut self, stage: Stage) -> Result<StageId, StoreError> {
80        let id = stage.id.clone();
81        if self.stages.contains_key(&id.0) {
82            return Err(StoreError::AlreadyExists(id));
83        }
84        self.stages.insert(id.0.clone(), stage);
85        self.save()?;
86        Ok(id)
87    }
88
89    fn upsert(&mut self, stage: Stage) -> Result<StageId, StoreError> {
90        let id = stage.id.clone();
91        self.stages.insert(id.0.clone(), stage);
92        self.save()?;
93        Ok(id)
94    }
95
96    fn remove(&mut self, id: &StageId) -> Result<(), StoreError> {
97        self.stages.remove(&id.0);
98        self.save()?;
99        Ok(())
100    }
101
102    fn get(&self, id: &StageId) -> Result<Option<&Stage>, StoreError> {
103        Ok(self.stages.get(&id.0))
104    }
105
106    fn contains(&self, id: &StageId) -> bool {
107        self.stages.contains_key(&id.0)
108    }
109
110    fn list(&self, lifecycle: Option<&StageLifecycle>) -> Vec<&Stage> {
111        self.stages
112            .values()
113            .filter(|s| lifecycle.is_none() || lifecycle == Some(&s.lifecycle))
114            .collect()
115    }
116
117    fn update_lifecycle(
118        &mut self,
119        id: &StageId,
120        lifecycle: StageLifecycle,
121    ) -> Result<(), StoreError> {
122        let current = self
123            .stages
124            .get(&id.0)
125            .ok_or_else(|| StoreError::NotFound(id.clone()))?;
126
127        validate_transition(&current.lifecycle, &lifecycle)
128            .map_err(|reason| StoreError::InvalidTransition { reason })?;
129
130        if let StageLifecycle::Deprecated { ref successor_id } = lifecycle {
131            if !self.stages.contains_key(&successor_id.0) {
132                return Err(StoreError::InvalidSuccessor {
133                    reason: format!("successor {successor_id:?} not found in store"),
134                });
135            }
136        }
137
138        self.stages.get_mut(&id.0).unwrap().lifecycle = lifecycle;
139        self.save()?;
140        Ok(())
141    }
142
143    fn stats(&self) -> StoreStats {
144        let mut by_lifecycle: BTreeMap<String, usize> = BTreeMap::new();
145        let mut by_effect: BTreeMap<String, usize> = BTreeMap::new();
146
147        for stage in self.stages.values() {
148            let lc_name = match &stage.lifecycle {
149                StageLifecycle::Draft => "draft",
150                StageLifecycle::Active => "active",
151                StageLifecycle::Deprecated { .. } => "deprecated",
152                StageLifecycle::Tombstone => "tombstone",
153            };
154            *by_lifecycle.entry(lc_name.into()).or_default() += 1;
155
156            for effect in stage.signature.effects.iter() {
157                let effect_name = format!("{effect:?}");
158                *by_effect.entry(effect_name).or_default() += 1;
159            }
160        }
161
162        StoreStats {
163            total: self.stages.len(),
164            by_lifecycle,
165            by_effect,
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use noether_core::effects::EffectSet;
174    use noether_core::stage::{CostEstimate, StageSignature};
175    use noether_core::types::NType;
176    use std::collections::BTreeSet;
177    use tempfile::NamedTempFile;
178
179    fn make_stage(id: &str) -> Stage {
180        Stage {
181            id: StageId(id.into()),
182            signature_id: None,
183            signature: StageSignature {
184                input: NType::Text,
185                output: NType::Number,
186                effects: EffectSet::pure(),
187                implementation_hash: format!("impl_{id}"),
188            },
189            capabilities: BTreeSet::new(),
190            cost: CostEstimate {
191                time_ms_p50: None,
192                tokens_est: None,
193                memory_mb: None,
194            },
195            description: "test stage".into(),
196            examples: vec![],
197            lifecycle: StageLifecycle::Active,
198            ed25519_signature: None,
199            signer_public_key: None,
200            implementation_code: None,
201            implementation_language: None,
202            ui_style: None,
203            tags: vec![],
204            aliases: vec![],
205            name: None,
206            properties: Vec::new(),
207        }
208    }
209
210    #[test]
211    fn create_and_reload() {
212        let tmp = NamedTempFile::new().unwrap();
213        let path = tmp.path().to_path_buf();
214
215        // Create and add a stage
216        {
217            let mut store = JsonFileStore::open(&path).unwrap();
218            store.put(make_stage("abc123")).unwrap();
219            assert_eq!(store.len(), 1);
220        }
221
222        // Reload from disk
223        {
224            let store = JsonFileStore::open(&path).unwrap();
225            assert_eq!(store.len(), 1);
226            let stage = store.get(&StageId("abc123".into())).unwrap().unwrap();
227            assert_eq!(stage.description, "test stage");
228        }
229    }
230
231    #[test]
232    fn persists_lifecycle_changes() {
233        let tmp = NamedTempFile::new().unwrap();
234        let path = tmp.path().to_path_buf();
235
236        {
237            let mut store = JsonFileStore::open(&path).unwrap();
238            store.put(make_stage("old")).unwrap();
239            store.put(make_stage("new")).unwrap();
240            store
241                .update_lifecycle(
242                    &StageId("old".into()),
243                    StageLifecycle::Deprecated {
244                        successor_id: StageId("new".into()),
245                    },
246                )
247                .unwrap();
248        }
249
250        {
251            let store = JsonFileStore::open(&path).unwrap();
252            let stage = store.get(&StageId("old".into())).unwrap().unwrap();
253            assert!(matches!(stage.lifecycle, StageLifecycle::Deprecated { .. }));
254        }
255    }
256
257    #[test]
258    fn empty_file_creates_empty_store() {
259        let tmp = NamedTempFile::new().unwrap();
260        let path = tmp.path().to_path_buf();
261        // Delete the file so open() creates empty
262        fs::remove_file(&path).ok();
263
264        let store = JsonFileStore::open(&path).unwrap();
265        assert_eq!(store.len(), 0);
266    }
267}