1use std::collections::BTreeMap;
47use std::fs;
48use std::io::Write;
49use std::path::{Path, PathBuf};
50use std::sync::Mutex;
51
52use ikigai_core::{Error, Result};
53use serde::{Deserialize, Serialize};
54
55use crate::model::{Head, Language, Run, Version, SCHEMA};
56
57pub trait Backend: Send + Sync {
60 fn head(&self, name: &str) -> Result<Option<Head>>;
62
63 fn swap_head(&self, name: &str, expected: Option<&Head>, new: &Head) -> Result<()>;
66
67 fn put_version(&self, name: &str, version: &Version) -> Result<()>;
69
70 fn version(&self, name: &str, digest: &str) -> Result<Option<Version>>;
73
74 fn names(&self) -> Result<Vec<String>>;
76
77 fn start_run(&self, name: &str, run: &Run) -> Result<u64>;
80
81 fn finish_run(&self, name: &str, run: &Run) -> Result<()>;
83
84 fn run(&self, name: &str, id: u64) -> Result<Option<Run>>;
86
87 fn last_run(&self, name: &str) -> Result<Option<Run>>;
89}
90
91fn conflict(name: &str) -> Error {
92 Error::Conflict(format!(
93 "urn:script:{name} changed while this write was being prepared (another writer, \
94 or an edit out of band); read it again and retry"
95 ))
96}
97
98#[derive(Default)]
101pub struct MemoryBackend {
102 scripts: Mutex<BTreeMap<String, Entry>>,
103}
104
105#[derive(Default)]
106struct Entry {
107 head: Option<Head>,
108 versions: BTreeMap<String, Version>,
109 runs: BTreeMap<u64, Run>,
110}
111
112impl MemoryBackend {
113 pub fn new() -> MemoryBackend {
115 MemoryBackend::default()
116 }
117
118 fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, Entry>> {
119 self.scripts.lock().unwrap_or_else(|p| p.into_inner())
122 }
123}
124
125impl Backend for MemoryBackend {
126 fn head(&self, name: &str) -> Result<Option<Head>> {
127 Ok(self.lock().get(name).and_then(|e| e.head.clone()))
128 }
129
130 fn swap_head(&self, name: &str, expected: Option<&Head>, new: &Head) -> Result<()> {
131 let mut scripts = self.lock();
132 let entry = scripts.entry(name.to_string()).or_default();
133 if entry.head.as_ref() != expected {
134 return Err(conflict(name));
135 }
136 entry.head = Some(new.clone());
137 Ok(())
138 }
139
140 fn put_version(&self, name: &str, version: &Version) -> Result<()> {
141 self.lock()
142 .entry(name.to_string())
143 .or_default()
144 .versions
145 .insert(version.digest(), version.clone());
146 Ok(())
147 }
148
149 fn version(&self, name: &str, digest: &str) -> Result<Option<Version>> {
150 Ok(self
151 .lock()
152 .get(name)
153 .and_then(|e| e.versions.get(digest).cloned()))
154 }
155
156 fn names(&self) -> Result<Vec<String>> {
157 Ok(self
158 .lock()
159 .iter()
160 .filter(|(_, e)| e.head.is_some())
161 .map(|(n, _)| n.clone())
162 .collect())
163 }
164
165 fn start_run(&self, name: &str, run: &Run) -> Result<u64> {
166 let mut scripts = self.lock();
167 let runs = &mut scripts.entry(name.to_string()).or_default().runs;
168 let id = runs.keys().next_back().map_or(1, |last| last + 1);
169 runs.insert(id, Run { id, ..run.clone() });
170 Ok(id)
171 }
172
173 fn finish_run(&self, name: &str, run: &Run) -> Result<()> {
174 self.lock()
175 .entry(name.to_string())
176 .or_default()
177 .runs
178 .insert(run.id, run.clone());
179 Ok(())
180 }
181
182 fn run(&self, name: &str, id: u64) -> Result<Option<Run>> {
183 Ok(self.lock().get(name).and_then(|e| e.runs.get(&id).cloned()))
184 }
185
186 fn last_run(&self, name: &str) -> Result<Option<Run>> {
187 Ok(self
188 .lock()
189 .get(name)
190 .and_then(|e| e.runs.values().next_back().cloned()))
191 }
192}
193
194pub struct DirBackend {
207 root: PathBuf,
208 write: Mutex<()>,
209}
210
211#[derive(Serialize, Deserialize)]
213struct VersionFile {
214 schema: u32,
215 language: Language,
216 requires: std::collections::BTreeSet<String>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
220 budget: Option<u64>,
221 source: String,
222}
223
224impl DirBackend {
225 pub fn open(root: impl Into<PathBuf>) -> Result<DirBackend> {
227 let root = root.into();
228 fs::create_dir_all(&root).map_err(|e| io_error(&root, "create", &e))?;
229 Ok(DirBackend {
230 root,
231 write: Mutex::new(()),
232 })
233 }
234
235 pub fn root(&self) -> &Path {
237 &self.root
238 }
239
240 fn dir(&self, name: &str) -> PathBuf {
241 self.root.join(name)
242 }
243
244 fn head_path(&self, name: &str) -> PathBuf {
245 self.dir(name).join("head.json")
246 }
247
248 fn version_path(&self, name: &str, digest: &str) -> Result<PathBuf> {
249 let hex = digest
250 .strip_prefix("sha256:")
251 .filter(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()))
252 .ok_or_else(|| Error::InvalidArgument {
253 name: "digest".to_string(),
254 detail: format!("`{digest}` is not `sha256:` and 64 hex digits"),
255 })?;
256 Ok(self
257 .dir(name)
258 .join("versions")
259 .join(format!("{}.json", hex.to_ascii_lowercase())))
260 }
261
262 fn run_path(&self, name: &str, id: u64) -> PathBuf {
263 self.dir(name).join("runs").join(format!("{id}.json"))
264 }
265
266 fn lock(&self) -> std::sync::MutexGuard<'_, ()> {
267 self.write.lock().unwrap_or_else(|p| p.into_inner())
268 }
269
270 fn read_head(&self, name: &str) -> Result<Option<Head>> {
271 let path = self.head_path(name);
272 match read_json::<Head>(&path)? {
273 Some(head) if head.name != name => Err(Error::Endpoint(format!(
274 "{} names the script `{}`, not `{name}` (edited or moved out of band)",
275 path.display(),
276 head.name
277 ))),
278 other => Ok(other),
279 }
280 }
281
282 fn last_run_id(&self, name: &str) -> Result<u64> {
284 let dir = self.dir(name).join("runs");
285 let entries = match fs::read_dir(&dir) {
286 Ok(entries) => entries,
287 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
288 Err(e) => return Err(io_error(&dir, "list", &e)),
289 };
290 let mut last = 0;
291 for entry in entries {
292 let entry = entry.map_err(|e| io_error(&dir, "list", &e))?;
293 let file = entry.file_name();
294 if let Some(id) = file
295 .to_str()
296 .and_then(|f| f.strip_suffix(".json"))
297 .and_then(|stem| stem.parse::<u64>().ok())
298 {
299 last = last.max(id);
300 }
301 }
302 Ok(last)
303 }
304}
305
306impl Backend for DirBackend {
307 fn head(&self, name: &str) -> Result<Option<Head>> {
308 self.read_head(name)
309 }
310
311 fn swap_head(&self, name: &str, expected: Option<&Head>, new: &Head) -> Result<()> {
312 let _guard = self.lock();
313 let current = match self.read_head(name) {
315 Ok(current) => current,
316 Err(_) if expected.is_some() => return Err(conflict(name)),
318 Err(e) => return Err(e),
319 };
320 if current.as_ref() != expected {
321 return Err(conflict(name));
322 }
323 write_json(&self.head_path(name), new)
324 }
325
326 fn put_version(&self, name: &str, version: &Version) -> Result<()> {
327 let path = self.version_path(name, &version.digest())?;
328 let _guard = self.lock();
329 write_json(
332 &path,
333 &VersionFile {
334 schema: SCHEMA,
335 language: version.language,
336 requires: version.requires.clone(),
337 budget: version.budget,
338 source: version.source.clone(),
339 },
340 )
341 }
342
343 fn version(&self, name: &str, digest: &str) -> Result<Option<Version>> {
344 let path = self.version_path(name, digest)?;
345 let Some(file) = read_json::<VersionFile>(&path)? else {
346 return Ok(None);
347 };
348 let version = Version {
349 language: file.language,
350 requires: file.requires,
351 budget: file.budget,
352 source: file.source,
353 };
354 if version.digest() != digest.to_ascii_lowercase() {
355 return Err(Error::Endpoint(format!(
356 "{} does not match the digest it is named by (edited out of band); this \
357 version cannot be trusted. Publish the content again to restore it",
358 path.display()
359 )));
360 }
361 Ok(Some(version))
362 }
363
364 fn names(&self) -> Result<Vec<String>> {
365 let entries = fs::read_dir(&self.root).map_err(|e| io_error(&self.root, "list", &e))?;
366 let mut names = Vec::new();
367 for entry in entries {
368 let entry = entry.map_err(|e| io_error(&self.root, "list", &e))?;
369 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
370 continue;
371 };
372 if crate::name::validate(&name).is_ok() && self.head_path(&name).is_file() {
375 names.push(name);
376 }
377 }
378 names.sort();
379 Ok(names)
380 }
381
382 fn start_run(&self, name: &str, run: &Run) -> Result<u64> {
383 let _guard = self.lock();
384 let mut id = self.last_run_id(name)? + 1;
385 loop {
386 let path = self.run_path(name, id);
387 if let Some(parent) = path.parent() {
388 fs::create_dir_all(parent).map_err(|e| io_error(parent, "create", &e))?;
389 }
390 let bytes = to_json(&Run { id, ..run.clone() })?;
391 match fs::OpenOptions::new()
395 .write(true)
396 .create_new(true)
397 .open(&path)
398 {
399 Ok(mut file) => {
400 file.write_all(&bytes)
401 .map_err(|e| io_error(&path, "write", &e))?;
402 return Ok(id);
403 }
404 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => id += 1,
405 Err(e) => return Err(io_error(&path, "create", &e)),
406 }
407 }
408 }
409
410 fn finish_run(&self, name: &str, run: &Run) -> Result<()> {
411 let _guard = self.lock();
412 write_json(&self.run_path(name, run.id), run)
413 }
414
415 fn run(&self, name: &str, id: u64) -> Result<Option<Run>> {
416 read_json(&self.run_path(name, id))
417 }
418
419 fn last_run(&self, name: &str) -> Result<Option<Run>> {
420 match self.last_run_id(name)? {
421 0 => Ok(None),
422 id => self.run(name, id),
423 }
424 }
425}
426
427fn io_error(path: &Path, what: &str, e: &std::io::Error) -> Error {
428 Error::Unavailable(format!("could not {what} {}: {e}", path.display()))
429}
430
431fn to_json<T: Serialize>(value: &T) -> Result<Vec<u8>> {
432 let mut bytes = serde_json::to_vec_pretty(value)
433 .map_err(|e| Error::Endpoint(format!("could not serialize a record: {e}")))?;
434 bytes.push(b'\n');
435 Ok(bytes)
436}
437
438fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>> {
441 let bytes = match fs::read(path) {
442 Ok(bytes) => bytes,
443 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
444 Err(e) => return Err(io_error(path, "read", &e)),
445 };
446 serde_json::from_slice(&bytes).map(Some).map_err(|e| {
447 Error::Endpoint(format!(
448 "{} is not a record this crate wrote (edited out of band?): {e}",
449 path.display()
450 ))
451 })
452}
453
454fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
457 let parent = path
458 .parent()
459 .ok_or_else(|| Error::Endpoint(format!("{} has no parent directory", path.display())))?;
460 fs::create_dir_all(parent).map_err(|e| io_error(parent, "create", &e))?;
461 let tmp = path.with_extension("json.tmp");
462 fs::write(&tmp, to_json(value)?).map_err(|e| io_error(&tmp, "write", &e))?;
463 fs::rename(&tmp, path).map_err(|e| io_error(path, "replace", &e))
464}