use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::error::{OlError, ERR_STATE_FILE_CORRUPT, ERR_STATE_FILE_WRITE_FAILED};
pub type Decided = BTreeSet<String>;
pub fn record_path(openlatch_dir: &Path) -> PathBuf {
openlatch_dir.join("decided-agents.json")
}
pub fn load(openlatch_dir: &Path) -> Result<Option<Decided>, 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);
pub fn record<'a>(
openlatch_dir: &Path,
agents: impl IntoIterator<Item = &'a str>,
) -> Result<(), 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 existing = load(openlatch_dir)?;
let mut decided = existing.clone().unwrap_or_default();
decided.extend(agents.into_iter().map(str::to_string));
if existing.as_ref() == Some(&decided) {
return Ok(());
}
let content = serde_json::to_string_pretty(&decided).map_err(|e| {
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot serialize the decided-agents 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 decided-agents record: {e}"),
)
})?;
crate::fs_secure::restrict_to_owner(&path).map_err(|e| {
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot restrict the decided-agents record: {e}"),
)
})
})
.map_err(|e| {
OlError::new(
ERR_STATE_FILE_WRITE_FAILED,
format!("Cannot lock the decided-agents record: {e}"),
)
})?
}
pub fn remove(openlatch_dir: &Path) -> std::io::Result<()> {
match std::fs::remove_file(record_path(openlatch_dir)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn absent_is_none_and_records_accumulate() {
let dir = tempfile::tempdir().expect("temp dir");
assert_eq!(load(dir.path()).expect("load"), None);
record(dir.path(), ["claude-code", "codex-cli"]).expect("record");
record(dir.path(), ["cursor", "claude-code"]).expect("record again");
let decided = load(dir.path()).expect("load").expect("present");
assert_eq!(
decided.into_iter().collect::<Vec<_>>(),
["claude-code", "codex-cli", "cursor"]
);
}
#[test]
fn an_empty_record_is_present_not_absent() {
let dir = tempfile::tempdir().expect("temp dir");
record(dir.path(), std::iter::empty()).expect("record");
assert_eq!(load(dir.path()).expect("load"), Some(Decided::new()));
}
#[test]
fn an_unreadable_record_is_an_error_and_is_not_overwritten() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(record_path(dir.path()), "not json").expect("seed");
assert!(load(dir.path()).is_err());
assert!(record(dir.path(), ["codex-cli"]).is_err());
assert_eq!(
std::fs::read_to_string(record_path(dir.path())).expect("read"),
"not json"
);
}
#[test]
fn remove_forgets_and_tolerates_absence() {
let dir = tempfile::tempdir().expect("temp dir");
record(dir.path(), ["codex-cli"]).expect("record");
remove(dir.path()).expect("remove");
assert_eq!(load(dir.path()).expect("load"), None);
remove(dir.path()).expect("remove again");
}
}