use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use ikigai_core::{Error, Result};
use serde::{Deserialize, Serialize};
use crate::model::{Head, Language, Run, Version, SCHEMA};
pub trait Backend: Send + Sync {
fn head(&self, name: &str) -> Result<Option<Head>>;
fn swap_head(&self, name: &str, expected: Option<&Head>, new: &Head) -> Result<()>;
fn put_version(&self, name: &str, version: &Version) -> Result<()>;
fn version(&self, name: &str, digest: &str) -> Result<Option<Version>>;
fn names(&self) -> Result<Vec<String>>;
fn start_run(&self, name: &str, run: &Run) -> Result<u64>;
fn finish_run(&self, name: &str, run: &Run) -> Result<()>;
fn run(&self, name: &str, id: u64) -> Result<Option<Run>>;
fn last_run(&self, name: &str) -> Result<Option<Run>>;
}
fn conflict(name: &str) -> Error {
Error::Conflict(format!(
"urn:script:{name} changed while this write was being prepared (another writer, \
or an edit out of band); read it again and retry"
))
}
#[derive(Default)]
pub struct MemoryBackend {
scripts: Mutex<BTreeMap<String, Entry>>,
}
#[derive(Default)]
struct Entry {
head: Option<Head>,
versions: BTreeMap<String, Version>,
runs: BTreeMap<u64, Run>,
}
impl MemoryBackend {
pub fn new() -> MemoryBackend {
MemoryBackend::default()
}
fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, Entry>> {
self.scripts.lock().unwrap_or_else(|p| p.into_inner())
}
}
impl Backend for MemoryBackend {
fn head(&self, name: &str) -> Result<Option<Head>> {
Ok(self.lock().get(name).and_then(|e| e.head.clone()))
}
fn swap_head(&self, name: &str, expected: Option<&Head>, new: &Head) -> Result<()> {
let mut scripts = self.lock();
let entry = scripts.entry(name.to_string()).or_default();
if entry.head.as_ref() != expected {
return Err(conflict(name));
}
entry.head = Some(new.clone());
Ok(())
}
fn put_version(&self, name: &str, version: &Version) -> Result<()> {
self.lock()
.entry(name.to_string())
.or_default()
.versions
.insert(version.digest(), version.clone());
Ok(())
}
fn version(&self, name: &str, digest: &str) -> Result<Option<Version>> {
Ok(self
.lock()
.get(name)
.and_then(|e| e.versions.get(digest).cloned()))
}
fn names(&self) -> Result<Vec<String>> {
Ok(self
.lock()
.iter()
.filter(|(_, e)| e.head.is_some())
.map(|(n, _)| n.clone())
.collect())
}
fn start_run(&self, name: &str, run: &Run) -> Result<u64> {
let mut scripts = self.lock();
let runs = &mut scripts.entry(name.to_string()).or_default().runs;
let id = runs.keys().next_back().map_or(1, |last| last + 1);
runs.insert(id, Run { id, ..run.clone() });
Ok(id)
}
fn finish_run(&self, name: &str, run: &Run) -> Result<()> {
self.lock()
.entry(name.to_string())
.or_default()
.runs
.insert(run.id, run.clone());
Ok(())
}
fn run(&self, name: &str, id: u64) -> Result<Option<Run>> {
Ok(self.lock().get(name).and_then(|e| e.runs.get(&id).cloned()))
}
fn last_run(&self, name: &str) -> Result<Option<Run>> {
Ok(self
.lock()
.get(name)
.and_then(|e| e.runs.values().next_back().cloned()))
}
}
pub struct DirBackend {
root: PathBuf,
write: Mutex<()>,
}
#[derive(Serialize, Deserialize)]
struct VersionFile {
schema: u32,
language: Language,
requires: std::collections::BTreeSet<String>,
source: String,
}
impl DirBackend {
pub fn open(root: impl Into<PathBuf>) -> Result<DirBackend> {
let root = root.into();
fs::create_dir_all(&root).map_err(|e| io_error(&root, "create", &e))?;
Ok(DirBackend {
root,
write: Mutex::new(()),
})
}
pub fn root(&self) -> &Path {
&self.root
}
fn dir(&self, name: &str) -> PathBuf {
self.root.join(name)
}
fn head_path(&self, name: &str) -> PathBuf {
self.dir(name).join("head.json")
}
fn version_path(&self, name: &str, digest: &str) -> Result<PathBuf> {
let hex = digest
.strip_prefix("sha256:")
.filter(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()))
.ok_or_else(|| Error::InvalidArgument {
name: "digest".to_string(),
detail: format!("`{digest}` is not `sha256:` and 64 hex digits"),
})?;
Ok(self
.dir(name)
.join("versions")
.join(format!("{}.json", hex.to_ascii_lowercase())))
}
fn run_path(&self, name: &str, id: u64) -> PathBuf {
self.dir(name).join("runs").join(format!("{id}.json"))
}
fn lock(&self) -> std::sync::MutexGuard<'_, ()> {
self.write.lock().unwrap_or_else(|p| p.into_inner())
}
fn read_head(&self, name: &str) -> Result<Option<Head>> {
let path = self.head_path(name);
match read_json::<Head>(&path)? {
Some(head) if head.name != name => Err(Error::Endpoint(format!(
"{} names the script `{}`, not `{name}` (edited or moved out of band)",
path.display(),
head.name
))),
other => Ok(other),
}
}
fn last_run_id(&self, name: &str) -> Result<u64> {
let dir = self.dir(name).join("runs");
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(e) => return Err(io_error(&dir, "list", &e)),
};
let mut last = 0;
for entry in entries {
let entry = entry.map_err(|e| io_error(&dir, "list", &e))?;
let file = entry.file_name();
if let Some(id) = file
.to_str()
.and_then(|f| f.strip_suffix(".json"))
.and_then(|stem| stem.parse::<u64>().ok())
{
last = last.max(id);
}
}
Ok(last)
}
}
impl Backend for DirBackend {
fn head(&self, name: &str) -> Result<Option<Head>> {
self.read_head(name)
}
fn swap_head(&self, name: &str, expected: Option<&Head>, new: &Head) -> Result<()> {
let _guard = self.lock();
let current = match self.read_head(name) {
Ok(current) => current,
Err(_) if expected.is_some() => return Err(conflict(name)),
Err(e) => return Err(e),
};
if current.as_ref() != expected {
return Err(conflict(name));
}
write_json(&self.head_path(name), new)
}
fn put_version(&self, name: &str, version: &Version) -> Result<()> {
let path = self.version_path(name, &version.digest())?;
let _guard = self.lock();
write_json(
&path,
&VersionFile {
schema: SCHEMA,
language: version.language,
requires: version.requires.clone(),
source: version.source.clone(),
},
)
}
fn version(&self, name: &str, digest: &str) -> Result<Option<Version>> {
let path = self.version_path(name, digest)?;
let Some(file) = read_json::<VersionFile>(&path)? else {
return Ok(None);
};
let version = Version {
language: file.language,
requires: file.requires,
source: file.source,
};
if version.digest() != digest.to_ascii_lowercase() {
return Err(Error::Endpoint(format!(
"{} does not match the digest it is named by (edited out of band); this \
version cannot be trusted. Publish the content again to restore it",
path.display()
)));
}
Ok(Some(version))
}
fn names(&self) -> Result<Vec<String>> {
let entries = fs::read_dir(&self.root).map_err(|e| io_error(&self.root, "list", &e))?;
let mut names = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| io_error(&self.root, "list", &e))?;
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
if crate::name::validate(&name).is_ok() && self.head_path(&name).is_file() {
names.push(name);
}
}
names.sort();
Ok(names)
}
fn start_run(&self, name: &str, run: &Run) -> Result<u64> {
let _guard = self.lock();
let mut id = self.last_run_id(name)? + 1;
loop {
let path = self.run_path(name, id);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| io_error(parent, "create", &e))?;
}
let bytes = to_json(&Run { id, ..run.clone() })?;
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
file.write_all(&bytes)
.map_err(|e| io_error(&path, "write", &e))?;
return Ok(id);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => id += 1,
Err(e) => return Err(io_error(&path, "create", &e)),
}
}
}
fn finish_run(&self, name: &str, run: &Run) -> Result<()> {
let _guard = self.lock();
write_json(&self.run_path(name, run.id), run)
}
fn run(&self, name: &str, id: u64) -> Result<Option<Run>> {
read_json(&self.run_path(name, id))
}
fn last_run(&self, name: &str) -> Result<Option<Run>> {
match self.last_run_id(name)? {
0 => Ok(None),
id => self.run(name, id),
}
}
}
fn io_error(path: &Path, what: &str, e: &std::io::Error) -> Error {
Error::Unavailable(format!("could not {what} {}: {e}", path.display()))
}
fn to_json<T: Serialize>(value: &T) -> Result<Vec<u8>> {
let mut bytes = serde_json::to_vec_pretty(value)
.map_err(|e| Error::Endpoint(format!("could not serialize a record: {e}")))?;
bytes.push(b'\n');
Ok(bytes)
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>> {
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(io_error(path, "read", &e)),
};
serde_json::from_slice(&bytes).map(Some).map_err(|e| {
Error::Endpoint(format!(
"{} is not a record this crate wrote (edited out of band?): {e}",
path.display()
))
})
}
fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| Error::Endpoint(format!("{} has no parent directory", path.display())))?;
fs::create_dir_all(parent).map_err(|e| io_error(parent, "create", &e))?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, to_json(value)?).map_err(|e| io_error(&tmp, "write", &e))?;
fs::rename(&tmp, path).map_err(|e| io_error(path, "replace", &e))
}