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
9pub struct JsonFileStore {
12 path: PathBuf,
13 stages: HashMap<String, Stage>,
14}
15
16#[derive(Serialize, Deserialize)]
18struct StoreFile {
19 stages: Vec<Stage>,
20}
21
22impl JsonFileStore {
23 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 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 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(¤t.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 canonical_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 }
207 }
208
209 #[test]
210 fn create_and_reload() {
211 let tmp = NamedTempFile::new().unwrap();
212 let path = tmp.path().to_path_buf();
213
214 {
216 let mut store = JsonFileStore::open(&path).unwrap();
217 store.put(make_stage("abc123")).unwrap();
218 assert_eq!(store.len(), 1);
219 }
220
221 {
223 let store = JsonFileStore::open(&path).unwrap();
224 assert_eq!(store.len(), 1);
225 let stage = store.get(&StageId("abc123".into())).unwrap().unwrap();
226 assert_eq!(stage.description, "test stage");
227 }
228 }
229
230 #[test]
231 fn persists_lifecycle_changes() {
232 let tmp = NamedTempFile::new().unwrap();
233 let path = tmp.path().to_path_buf();
234
235 {
236 let mut store = JsonFileStore::open(&path).unwrap();
237 store.put(make_stage("old")).unwrap();
238 store.put(make_stage("new")).unwrap();
239 store
240 .update_lifecycle(
241 &StageId("old".into()),
242 StageLifecycle::Deprecated {
243 successor_id: StageId("new".into()),
244 },
245 )
246 .unwrap();
247 }
248
249 {
250 let store = JsonFileStore::open(&path).unwrap();
251 let stage = store.get(&StageId("old".into())).unwrap().unwrap();
252 assert!(matches!(stage.lifecycle, StageLifecycle::Deprecated { .. }));
253 }
254 }
255
256 #[test]
257 fn empty_file_creates_empty_store() {
258 let tmp = NamedTempFile::new().unwrap();
259 let path = tmp.path().to_path_buf();
260 fs::remove_file(&path).ok();
262
263 let store = JsonFileStore::open(&path).unwrap();
264 assert_eq!(store.len(), 0);
265 }
266}