use serde_json::Value;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
pub const GESTURES_DIR: &str = "gestures";
const CLAIMED_DIR: &str = "claimed";
const REPLIES_DIR: &str = "replies";
const EXT: &str = ".json";
pub fn gestures_dir(state_root: &Path) -> PathBuf {
state_root.join(GESTURES_DIR)
}
pub fn reply_path(state_root: &Path, id: &str) -> PathBuf {
gestures_dir(state_root)
.join(REPLIES_DIR)
.join(format!("{id}{EXT}"))
}
pub fn valid_id(id: &str) -> bool {
!id.is_empty()
&& !id.starts_with('.')
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
pub fn mint(state_root: &Path, seed: &str) -> io::Result<String> {
if !valid_id(seed) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid gesture id seed {seed:?}"),
));
}
let dir = gestures_dir(state_root).join(REPLIES_DIR);
fs::create_dir_all(&dir)?;
let mut n: u64 = 0;
loop {
let id = format!("{seed}-{n}");
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(dir.join(format!("{id}{EXT}")))
{
Ok(_) => return Ok(id),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => n = n.saturating_add(1),
Err(e) => return Err(e),
}
}
}
pub fn deposit(state_root: &Path, id: &str, gesture: &Value) -> io::Result<PathBuf> {
if !valid_id(id) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid gesture id {id:?}"),
));
}
let dir = gestures_dir(state_root);
fs::create_dir_all(&dir)?;
let path = dir.join(format!("{id}{EXT}"));
if path.exists() || dir.join(CLAIMED_DIR).join(format!("{id}{EXT}")).exists() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("gesture {id:?} already deposited"),
));
}
let tmp = dir.join(format!(".{id}{EXT}.tmp"));
fs::write(&tmp, gesture.to_string().as_bytes())?;
fs::rename(&tmp, &path)?;
Ok(path)
}
fn list(dir: &Path) -> Vec<(String, PathBuf)> {
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
let mut out: Vec<(String, PathBuf)> = entries
.filter_map(Result::ok)
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
let id = name.strip_suffix(EXT)?;
if !valid_id(id) {
return None;
}
Some((id.to_owned(), e.path()))
})
.filter(|(_, p)| p.is_file())
.collect();
out.sort();
out
}
pub fn pending(state_root: &Path) -> Vec<(String, PathBuf)> {
list(&gestures_dir(state_root))
}
mod claim;
pub use claim::{Claim, claim, claimed, unheld};
pub fn write_reply(state_root: &Path, id: &str, reply: &Value) -> io::Result<()> {
let dir = gestures_dir(state_root).join(REPLIES_DIR);
fs::create_dir_all(&dir)?;
let tmp = dir.join(format!(".{id}{EXT}.tmp"));
fs::write(&tmp, reply.to_string().as_bytes())?;
fs::rename(&tmp, dir.join(format!("{id}{EXT}")))
}
pub fn read_reply(state_root: &Path, id: &str) -> Option<Value> {
let bytes = fs::read(reply_path(state_root, id)).ok()?;
serde_json::from_slice(&bytes).ok()
}
#[cfg(test)]
mod tests;