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 source: String,
218}
219
220impl DirBackend {
221 pub fn open(root: impl Into<PathBuf>) -> Result<DirBackend> {
223 let root = root.into();
224 fs::create_dir_all(&root).map_err(|e| io_error(&root, "create", &e))?;
225 Ok(DirBackend {
226 root,
227 write: Mutex::new(()),
228 })
229 }
230
231 pub fn root(&self) -> &Path {
233 &self.root
234 }
235
236 fn dir(&self, name: &str) -> PathBuf {
237 self.root.join(name)
238 }
239
240 fn head_path(&self, name: &str) -> PathBuf {
241 self.dir(name).join("head.json")
242 }
243
244 fn version_path(&self, name: &str, digest: &str) -> Result<PathBuf> {
245 let hex = digest
246 .strip_prefix("sha256:")
247 .filter(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()))
248 .ok_or_else(|| Error::InvalidArgument {
249 name: "digest".to_string(),
250 detail: format!("`{digest}` is not `sha256:` and 64 hex digits"),
251 })?;
252 Ok(self
253 .dir(name)
254 .join("versions")
255 .join(format!("{}.json", hex.to_ascii_lowercase())))
256 }
257
258 fn run_path(&self, name: &str, id: u64) -> PathBuf {
259 self.dir(name).join("runs").join(format!("{id}.json"))
260 }
261
262 fn lock(&self) -> std::sync::MutexGuard<'_, ()> {
263 self.write.lock().unwrap_or_else(|p| p.into_inner())
264 }
265
266 fn read_head(&self, name: &str) -> Result<Option<Head>> {
267 let path = self.head_path(name);
268 match read_json::<Head>(&path)? {
269 Some(head) if head.name != name => Err(Error::Endpoint(format!(
270 "{} names the script `{}`, not `{name}` (edited or moved out of band)",
271 path.display(),
272 head.name
273 ))),
274 other => Ok(other),
275 }
276 }
277
278 fn last_run_id(&self, name: &str) -> Result<u64> {
280 let dir = self.dir(name).join("runs");
281 let entries = match fs::read_dir(&dir) {
282 Ok(entries) => entries,
283 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
284 Err(e) => return Err(io_error(&dir, "list", &e)),
285 };
286 let mut last = 0;
287 for entry in entries {
288 let entry = entry.map_err(|e| io_error(&dir, "list", &e))?;
289 let file = entry.file_name();
290 if let Some(id) = file
291 .to_str()
292 .and_then(|f| f.strip_suffix(".json"))
293 .and_then(|stem| stem.parse::<u64>().ok())
294 {
295 last = last.max(id);
296 }
297 }
298 Ok(last)
299 }
300}
301
302impl Backend for DirBackend {
303 fn head(&self, name: &str) -> Result<Option<Head>> {
304 self.read_head(name)
305 }
306
307 fn swap_head(&self, name: &str, expected: Option<&Head>, new: &Head) -> Result<()> {
308 let _guard = self.lock();
309 let current = match self.read_head(name) {
311 Ok(current) => current,
312 Err(_) if expected.is_some() => return Err(conflict(name)),
314 Err(e) => return Err(e),
315 };
316 if current.as_ref() != expected {
317 return Err(conflict(name));
318 }
319 write_json(&self.head_path(name), new)
320 }
321
322 fn put_version(&self, name: &str, version: &Version) -> Result<()> {
323 let path = self.version_path(name, &version.digest())?;
324 let _guard = self.lock();
325 write_json(
328 &path,
329 &VersionFile {
330 schema: SCHEMA,
331 language: version.language,
332 requires: version.requires.clone(),
333 source: version.source.clone(),
334 },
335 )
336 }
337
338 fn version(&self, name: &str, digest: &str) -> Result<Option<Version>> {
339 let path = self.version_path(name, digest)?;
340 let Some(file) = read_json::<VersionFile>(&path)? else {
341 return Ok(None);
342 };
343 let version = Version {
344 language: file.language,
345 requires: file.requires,
346 source: file.source,
347 };
348 if version.digest() != digest.to_ascii_lowercase() {
349 return Err(Error::Endpoint(format!(
350 "{} does not match the digest it is named by (edited out of band); this \
351 version cannot be trusted. Publish the content again to restore it",
352 path.display()
353 )));
354 }
355 Ok(Some(version))
356 }
357
358 fn names(&self) -> Result<Vec<String>> {
359 let entries = fs::read_dir(&self.root).map_err(|e| io_error(&self.root, "list", &e))?;
360 let mut names = Vec::new();
361 for entry in entries {
362 let entry = entry.map_err(|e| io_error(&self.root, "list", &e))?;
363 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
364 continue;
365 };
366 if crate::name::validate(&name).is_ok() && self.head_path(&name).is_file() {
369 names.push(name);
370 }
371 }
372 names.sort();
373 Ok(names)
374 }
375
376 fn start_run(&self, name: &str, run: &Run) -> Result<u64> {
377 let _guard = self.lock();
378 let mut id = self.last_run_id(name)? + 1;
379 loop {
380 let path = self.run_path(name, id);
381 if let Some(parent) = path.parent() {
382 fs::create_dir_all(parent).map_err(|e| io_error(parent, "create", &e))?;
383 }
384 let bytes = to_json(&Run { id, ..run.clone() })?;
385 match fs::OpenOptions::new()
389 .write(true)
390 .create_new(true)
391 .open(&path)
392 {
393 Ok(mut file) => {
394 file.write_all(&bytes)
395 .map_err(|e| io_error(&path, "write", &e))?;
396 return Ok(id);
397 }
398 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => id += 1,
399 Err(e) => return Err(io_error(&path, "create", &e)),
400 }
401 }
402 }
403
404 fn finish_run(&self, name: &str, run: &Run) -> Result<()> {
405 let _guard = self.lock();
406 write_json(&self.run_path(name, run.id), run)
407 }
408
409 fn run(&self, name: &str, id: u64) -> Result<Option<Run>> {
410 read_json(&self.run_path(name, id))
411 }
412
413 fn last_run(&self, name: &str) -> Result<Option<Run>> {
414 match self.last_run_id(name)? {
415 0 => Ok(None),
416 id => self.run(name, id),
417 }
418 }
419}
420
421fn io_error(path: &Path, what: &str, e: &std::io::Error) -> Error {
422 Error::Unavailable(format!("could not {what} {}: {e}", path.display()))
423}
424
425fn to_json<T: Serialize>(value: &T) -> Result<Vec<u8>> {
426 let mut bytes = serde_json::to_vec_pretty(value)
427 .map_err(|e| Error::Endpoint(format!("could not serialize a record: {e}")))?;
428 bytes.push(b'\n');
429 Ok(bytes)
430}
431
432fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>> {
435 let bytes = match fs::read(path) {
436 Ok(bytes) => bytes,
437 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
438 Err(e) => return Err(io_error(path, "read", &e)),
439 };
440 serde_json::from_slice(&bytes).map(Some).map_err(|e| {
441 Error::Endpoint(format!(
442 "{} is not a record this crate wrote (edited out of band?): {e}",
443 path.display()
444 ))
445 })
446}
447
448fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
451 let parent = path
452 .parent()
453 .ok_or_else(|| Error::Endpoint(format!("{} has no parent directory", path.display())))?;
454 fs::create_dir_all(parent).map_err(|e| io_error(parent, "create", &e))?;
455 let tmp = path.with_extension("json.tmp");
456 fs::write(&tmp, to_json(value)?).map_err(|e| io_error(&tmp, "write", &e))?;
457 fs::rename(&tmp, path).map_err(|e| io_error(path, "replace", &e))
458}