Skip to main content

swink_agent_eval/
store.rs

1//! Persistence for eval sets and results.
2//!
3//! Provides the [`EvalStore`] trait and a filesystem-backed implementation
4//! ([`FsEvalStore`]) that stores data as JSON files.
5
6use std::fs;
7use std::io::{self, BufWriter, Write};
8use std::path::{Component, Path, PathBuf};
9
10use crate::error::EvalError;
11use crate::types::{EvalSet, EvalSetResult};
12
13/// Persistence interface for eval sets and results.
14///
15/// Implementations handle storage and retrieval of eval definitions and
16/// scored results, enabling historical comparison and trending.
17pub trait EvalStore: Send + Sync {
18    /// Save an eval set definition.
19    fn save_set(&self, set: &EvalSet) -> Result<(), EvalError>;
20
21    /// Load an eval set by ID.
22    fn load_set(&self, id: &str) -> Result<EvalSet, EvalError>;
23
24    /// Save an eval set result.
25    fn save_result(&self, result: &EvalSetResult) -> Result<(), EvalError>;
26
27    /// Load a specific result by eval set ID and timestamp.
28    fn load_result(&self, eval_set_id: &str, timestamp: u64) -> Result<EvalSetResult, EvalError>;
29
30    /// List all result timestamps for an eval set, sorted ascending.
31    fn list_results(&self, eval_set_id: &str) -> Result<Vec<u64>, EvalError>;
32}
33
34/// Filesystem-backed eval store using JSON files.
35///
36/// Directory layout:
37/// ```text
38/// {dir}/sets/{id}.json
39/// {dir}/results/{eval_set_id}/{timestamp}.json
40/// ```
41pub struct FsEvalStore {
42    dir: PathBuf,
43}
44
45impl FsEvalStore {
46    /// Create a new store rooted at the given directory.
47    ///
48    /// The directory and subdirectories are created on first write.
49    #[must_use]
50    pub fn new(dir: impl Into<PathBuf>) -> Self {
51        Self { dir: dir.into() }
52    }
53
54    fn sets_dir(&self) -> PathBuf {
55        self.dir.join("sets")
56    }
57
58    fn results_dir(&self, eval_set_id: &str) -> PathBuf {
59        self.dir.join("results").join(eval_set_id)
60    }
61
62    fn set_path(&self, id: &str) -> PathBuf {
63        self.sets_dir().join(format!("{id}.json"))
64    }
65
66    #[cfg(feature = "yaml")]
67    fn set_path_yaml(&self, id: &str) -> PathBuf {
68        self.sets_dir().join(format!("{id}.yaml"))
69    }
70
71    #[cfg(feature = "yaml")]
72    fn set_path_yml(&self, id: &str) -> PathBuf {
73        self.sets_dir().join(format!("{id}.yml"))
74    }
75
76    fn result_path(&self, eval_set_id: &str, timestamp: u64) -> PathBuf {
77        self.results_dir(eval_set_id)
78            .join(format!("{timestamp}.json"))
79    }
80
81    fn validate_identifier(kind: &'static str, id: &str) -> Result<(), EvalError> {
82        if id.is_empty() || id.contains('\0') || id.contains('/') || id.contains('\\') {
83            return Err(EvalError::invalid_identifier(kind, id));
84        }
85
86        let mut components = Path::new(id).components();
87        match (components.next(), components.next()) {
88            (Some(Component::Normal(_)), None) => Ok(()),
89            _ => Err(EvalError::invalid_identifier(kind, id)),
90        }
91    }
92
93    fn ensure_dir(path: &Path) -> Result<(), EvalError> {
94        if !path.exists() {
95            fs::create_dir_all(path)?;
96        }
97        Ok(())
98    }
99
100    fn write_atomically(
101        target: &Path,
102        write: impl FnOnce(&mut BufWriter<&std::fs::File>) -> io::Result<()>,
103    ) -> Result<(), EvalError> {
104        swink_agent::atomic_fs::atomic_write(target, write)?;
105        Ok(())
106    }
107
108    fn write_json_atomically(target: &Path, json: &str) -> Result<(), EvalError> {
109        Self::write_atomically(target, |writer| writer.write_all(json.as_bytes()))
110    }
111}
112
113impl EvalStore for FsEvalStore {
114    fn save_set(&self, set: &EvalSet) -> Result<(), EvalError> {
115        Self::validate_identifier("eval set", &set.id)?;
116        Self::ensure_dir(&self.sets_dir())?;
117        let json = serde_json::to_string_pretty(set)?;
118        Self::write_json_atomically(&self.set_path(&set.id), &json)?;
119        Ok(())
120    }
121
122    fn load_set(&self, id: &str) -> Result<EvalSet, EvalError> {
123        Self::validate_identifier("eval set", id)?;
124        #[cfg(feature = "yaml")]
125        {
126            for path in [self.set_path_yaml(id), self.set_path_yml(id)] {
127                if path.exists() {
128                    let contents = fs::read_to_string(path)?;
129                    return Ok(serde_yaml::from_str(&contents)?);
130                }
131            }
132        }
133
134        let path = self.set_path(id);
135        if !path.exists() {
136            return Err(EvalError::SetNotFound { id: id.to_string() });
137        }
138        let json = fs::read_to_string(path)?;
139        Ok(serde_json::from_str(&json)?)
140    }
141
142    fn save_result(&self, result: &EvalSetResult) -> Result<(), EvalError> {
143        Self::validate_identifier("eval result set", &result.eval_set_id)?;
144        Self::ensure_dir(&self.results_dir(&result.eval_set_id))?;
145        let json = serde_json::to_string_pretty(result)?;
146        Self::write_json_atomically(
147            &self.result_path(&result.eval_set_id, result.timestamp),
148            &json,
149        )?;
150        Ok(())
151    }
152
153    fn load_result(&self, eval_set_id: &str, timestamp: u64) -> Result<EvalSetResult, EvalError> {
154        Self::validate_identifier("eval result set", eval_set_id)?;
155        let path = self.result_path(eval_set_id, timestamp);
156        if !path.exists() {
157            return Err(EvalError::ResultNotFound {
158                eval_set_id: eval_set_id.to_string(),
159                timestamp,
160            });
161        }
162        let json = fs::read_to_string(path)?;
163        Ok(serde_json::from_str(&json)?)
164    }
165
166    fn list_results(&self, eval_set_id: &str) -> Result<Vec<u64>, EvalError> {
167        Self::validate_identifier("eval result set", eval_set_id)?;
168        let dir = self.results_dir(eval_set_id);
169        if !dir.exists() {
170            return Ok(Vec::new());
171        }
172
173        let mut timestamps: Vec<u64> = fs::read_dir(dir)?
174            .filter_map(Result::ok)
175            .filter_map(|entry| {
176                entry
177                    .path()
178                    .file_stem()
179                    .and_then(|s| s.to_str())
180                    .and_then(|s| s.parse::<u64>().ok())
181            })
182            .collect();
183
184        timestamps.sort_unstable();
185        Ok(timestamps)
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::FsEvalStore;
192    use std::fs;
193    use std::io::{self, Write};
194
195    #[test]
196    fn failed_atomic_rewrite_preserves_existing_eval_json() {
197        let dir = tempfile::tempdir().unwrap();
198        let target = dir.path().join("sets").join("suite.json");
199        fs::create_dir_all(target.parent().unwrap()).unwrap();
200        fs::write(&target, "{\"stable\":true}\n").unwrap();
201
202        let error = FsEvalStore::write_atomically(&target, |writer| {
203            writer.write_all(b"{\"stable\":false")?;
204            Err(io::Error::other("boom"))
205        })
206        .unwrap_err();
207
208        assert!(matches!(error, crate::error::EvalError::Io { .. }));
209        assert_eq!(fs::read_to_string(&target).unwrap(), "{\"stable\":true}\n");
210
211        let temp_files: Vec<_> = fs::read_dir(target.parent().unwrap())
212            .unwrap()
213            .filter_map(Result::ok)
214            .map(|entry| entry.file_name().to_string_lossy().into_owned())
215            .filter(|name| name.starts_with(".suite.json.tmp."))
216            .collect();
217        assert!(temp_files.is_empty());
218    }
219}