swink_agent_eval/
store.rs1use 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
13pub trait EvalStore: Send + Sync {
18 fn save_set(&self, set: &EvalSet) -> Result<(), EvalError>;
20
21 fn load_set(&self, id: &str) -> Result<EvalSet, EvalError>;
23
24 fn save_result(&self, result: &EvalSetResult) -> Result<(), EvalError>;
26
27 fn load_result(&self, eval_set_id: &str, timestamp: u64) -> Result<EvalSetResult, EvalError>;
29
30 fn list_results(&self, eval_set_id: &str) -> Result<Vec<u64>, EvalError>;
32}
33
34pub struct FsEvalStore {
42 dir: PathBuf,
43}
44
45impl FsEvalStore {
46 #[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}