use crate::paths::Paths;
use std::fs;
use std::io;
#[derive(Debug, Clone)]
pub enum Key {
Ask(String),
Answer(String),
Claim(String),
Goal(String),
Playbook,
Journal,
Sensor(String),
GoalActivity,
ActionWal,
}
#[derive(Debug, Clone, Copy)]
pub enum Collection {
Asks,
Answers,
Claims,
Goals,
}
impl Collection {
fn ext(self) -> &'static str {
match self {
Collection::Asks | Collection::Answers | Collection::Claims => "json",
Collection::Goals => "md",
}
}
}
pub trait StateStore {
fn read(&self, key: &Key) -> Option<String>;
fn exists(&self, key: &Key) -> bool;
fn write_atomic(&self, key: &Key, contents: &str) -> io::Result<()>;
fn create_exclusive(&self, key: &Key, contents: &str) -> io::Result<bool>;
fn append_line(&self, key: &Key, line: &str) -> io::Result<()>;
fn archive(&self, key: &Key) -> io::Result<()>;
fn remove(&self, key: &Key) -> io::Result<()>;
fn list(&self, collection: &Collection) -> Vec<String>;
}
pub struct FileStore<'a> {
paths: &'a Paths,
}
impl<'a> FileStore<'a> {
pub fn new(paths: &'a Paths) -> Self {
FileStore { paths }
}
fn path(&self, key: &Key) -> std::path::PathBuf {
match key {
Key::Ask(id) => self.paths.asks_dir().join(format!("{id}.json")),
Key::Answer(id) => self.paths.answers_dir().join(format!("{id}.json")),
Key::Claim(name) => self.paths.claims_dir().join(format!("{name}.json")),
Key::Goal(id) => self.paths.goals_dir().join(format!("{id}.md")),
Key::Playbook => self.paths.playbook(),
Key::Journal => self.paths.journal(),
Key::Sensor(name) => self.paths.sensors_dir().join(format!("{name}.sh")),
Key::GoalActivity => self.paths.goal_activity(),
Key::ActionWal => self.paths.action_wal(),
}
}
fn dir(&self, c: &Collection) -> std::path::PathBuf {
match c {
Collection::Asks => self.paths.asks_dir(),
Collection::Answers => self.paths.answers_dir(),
Collection::Claims => self.paths.claims_dir(),
Collection::Goals => self.paths.goals_dir(),
}
}
}
impl StateStore for FileStore<'_> {
fn read(&self, key: &Key) -> Option<String> {
fs::read_to_string(self.path(key)).ok()
}
fn exists(&self, key: &Key) -> bool {
self.path(key).is_file()
}
fn write_atomic(&self, key: &Key, contents: &str) -> io::Result<()> {
let path = self.path(key);
crate::util::write_atomic(&path, contents.as_bytes())?;
#[cfg(unix)]
if matches!(key, Key::Sensor(_)) {
use std::os::unix::fs::PermissionsExt;
let mut perm = fs::metadata(&path)?.permissions();
perm.set_mode(0o755);
fs::set_permissions(&path, perm)?;
}
Ok(())
}
fn create_exclusive(&self, key: &Key, contents: &str) -> io::Result<bool> {
let path = self.path(key);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut f) => {
use io::Write;
f.write_all(contents.as_bytes())?;
Ok(true)
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(false),
Err(e) => Err(e),
}
}
fn append_line(&self, key: &Key, line: &str) -> io::Result<()> {
use io::Write;
let path = self.path(key);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
writeln!(f, "{line}")
}
fn archive(&self, key: &Key) -> io::Result<()> {
match key {
Key::Goal(id) => {
let from = self.paths.goals_dir().join(format!("{id}.md"));
let archive = self.paths.goals_dir().join("archive");
fs::create_dir_all(&archive)?;
fs::rename(&from, archive.join(format!("{id}.md")))
}
_ => Err(io::Error::new(
io::ErrorKind::Unsupported,
"archive: only goals are archivable",
)),
}
}
fn remove(&self, key: &Key) -> io::Result<()> {
match fs::remove_file(self.path(key)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
fn list(&self, collection: &Collection) -> Vec<String> {
let ext = collection.ext();
let mut names: Vec<String> = fs::read_dir(self.dir(collection))
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().map(|x| x == ext).unwrap_or(false))
.filter_map(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
.collect();
names.sort();
names
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_read_remove_round_trip() {
let p = Paths::temp();
let s = FileStore::new(&p);
let k = Key::Ask("w-1".into());
assert!(!s.exists(&k));
s.write_atomic(&k, "hello").unwrap();
assert!(s.exists(&k));
assert_eq!(s.read(&k).as_deref(), Some("hello"));
s.remove(&k).unwrap();
assert!(!s.exists(&k));
s.remove(&k).unwrap();
}
#[test]
fn create_exclusive_is_a_test_and_set() {
let p = Paths::temp();
let s = FileStore::new(&p);
let k = Key::Claim("repo".into());
assert!(s.create_exclusive(&k, "first").unwrap(), "first wins");
assert!(
!s.create_exclusive(&k, "second").unwrap(),
"second sees it already exists"
);
assert_eq!(s.read(&k).as_deref(), Some("first"), "loser never clobbers");
}
#[test]
fn list_returns_sorted_stems() {
let p = Paths::temp();
let s = FileStore::new(&p);
s.write_atomic(&Key::Claim("b".into()), "{}").unwrap();
s.write_atomic(&Key::Claim("a".into()), "{}").unwrap();
assert_eq!(s.list(&Collection::Claims), vec!["a", "b"]);
}
}