use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::error::{OlError, ERR_STATE_FILE_CORRUPT, ERR_STATE_FILE_WRITE_FAILED};
type Records = BTreeSet<PathBuf>;
fn record_path(openlatch_dir: &Path) -> PathBuf {
openlatch_dir.join("created-hook-files.json")
}
fn load(openlatch_dir: &Path) -> Result<Option<Records>, OlError> {
let path = record_path(openlatch_dir);
let raw = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(OlError::new(
ERR_STATE_FILE_CORRUPT,
format!("cannot read {}: {e}", path.display()),
))
}
};
serde_json::from_str(&raw).map(Some).map_err(|e| {
OlError::new(
ERR_STATE_FILE_CORRUPT,
format!("{} is not valid JSON: {e}", path.display()),
)
})
}
const LOCK_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(30);
fn mutate<T>(openlatch_dir: &Path, f: impl FnOnce(&mut Records) -> T) -> Result<T, OlError> {
let path = record_path(openlatch_dir);
std::fs::create_dir_all(openlatch_dir).map_err(|e| {
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot create the OpenLatch directory: {e}"),
)
})?;
let lock = path.with_extension("json.lock");
crate::fs_secure::with_lockfile(&lock, LOCK_STALE_AFTER, || {
let mut records = load(openlatch_dir)?.unwrap_or_default();
let before = records.clone();
let out = f(&mut records);
if records != before {
let content = serde_json::to_string_pretty(&records).map_err(|e| {
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot serialize the created hook file record: {e}"),
)
})?;
crate::fs_secure::write_preserving_mode(&path, content.as_bytes()).map_err(|e| {
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot write the created hook file record: {e}"),
)
})?;
}
Ok(out)
})
.map_err(|e| {
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot lock the created hook file record: {e}"),
)
})?
}
pub fn record(openlatch_dir: &Path, file: &Path) -> Result<(), OlError> {
mutate(openlatch_dir, |records| {
records.insert(file.to_path_buf());
})
}
pub fn is_recorded(openlatch_dir: &Path, file: &Path) -> bool {
load(openlatch_dir)
.ok()
.flatten()
.is_some_and(|records| records.contains(file))
}
pub fn forget(openlatch_dir: &Path, file: &Path) -> Result<(), OlError> {
mutate(openlatch_dir, |records| {
records.remove(file);
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_recorded_file_is_found_until_forgotten() {
let ol = tempfile::tempdir().expect("state dir");
let file = ol.path().join("codex").join("hooks.json");
assert!(!is_recorded(ol.path(), &file));
record(ol.path(), &file).expect("record");
assert!(is_recorded(ol.path(), &file));
assert!(!is_recorded(ol.path(), &ol.path().join("other.json")));
forget(ol.path(), &file).expect("forget");
assert!(!is_recorded(ol.path(), &file));
}
#[test]
fn an_unreadable_record_is_not_overwritten_and_owns_nothing() {
let ol = tempfile::tempdir().expect("state dir");
std::fs::write(record_path(ol.path()), "not json").expect("seed");
assert!(record(ol.path(), &ol.path().join("f.json")).is_err());
assert!(!is_recorded(ol.path(), &ol.path().join("f.json")));
assert_eq!(
std::fs::read_to_string(record_path(ol.path())).expect("read"),
"not json"
);
}
}