use std::path::{Path, PathBuf};
use engenho_store::data_dir_lock::{DataDirLock, LockError};
use engenho_substrate::{AtomicWriteError, write_atomic};
use super::journal::{BootJournal, IdentityRecord, RunMarker};
use crate::boot::Timestamp;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ControlDir {
root: PathBuf,
}
#[derive(Debug, thiserror::Error)]
pub enum ControlWriteError {
#[error("serialize {file}: {source}")]
Serialize {
file: &'static str,
source: serde_json::Error,
},
#[error("write {file}: {source}")]
Write {
file: &'static str,
source: AtomicWriteError,
},
#[error("remove {file}: {source}")]
Remove {
file: &'static str,
source: std::io::Error,
},
}
impl ControlDir {
pub const NAME: &'static str = crate::layout::Area::Control.dir();
const JOURNAL: &'static str = "boot-journal.json";
const RUN: &'static str = "run.json";
const IDENTITY: &'static str = "identity.json";
const HOLD: &'static str = "hold";
const LOCK_STEM: &'static str = "daemon";
#[must_use]
pub fn under(data_dir: &Path) -> Self {
Self {
root: data_dir.join(Self::NAME),
}
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
pub fn create(&self) -> std::io::Result<()> {
std::fs::create_dir_all(&self.root)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&self.root, std::fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
pub fn lock(&self) -> Result<DataDirLock, LockError> {
DataDirLock::acquire(self.root.join(Self::LOCK_STEM))
}
#[must_use]
pub fn read_journal(&self) -> BootJournal {
BootJournal::from_bytes(self.read(Self::JOURNAL).as_deref())
}
pub fn write_journal(&self, journal: &BootJournal) -> Result<(), ControlWriteError> {
let bytes = journal
.to_bytes()
.map_err(|source| ControlWriteError::Serialize {
file: Self::JOURNAL,
source,
})?;
self.write(Self::JOURNAL, &bytes)
}
#[must_use]
pub fn read_run(&self) -> Option<RunMarker> {
self.read_json(Self::RUN)
}
pub fn write_run(&self, marker: &RunMarker) -> Result<(), ControlWriteError> {
self.write_json(Self::RUN, marker)
}
#[must_use]
pub fn read_identity(&self) -> Option<IdentityRecord> {
self.read_json(Self::IDENTITY)
}
pub fn write_identity(&self, identity: &IdentityRecord) -> Result<(), ControlWriteError> {
self.write_json(Self::IDENTITY, identity)
}
#[must_use]
pub fn is_held(&self) -> bool {
self.root.join(Self::HOLD).exists()
}
pub fn set_hold(&self, at: Timestamp) -> Result<(), ControlWriteError> {
self.write(Self::HOLD, at.to_rfc3339().as_bytes())
}
pub fn clear_hold(&self) -> Result<(), ControlWriteError> {
match std::fs::remove_file(self.root.join(Self::HOLD)) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(ControlWriteError::Remove {
file: Self::HOLD,
source,
}),
}
}
fn read(&self, file: &str) -> Option<Vec<u8>> {
std::fs::read(self.root.join(file)).ok()
}
fn read_json<T: serde::de::DeserializeOwned>(&self, file: &'static str) -> Option<T> {
let bytes = self.read(file)?;
serde_json::from_slice(&bytes)
.map_err(
|err| tracing::warn!(file, error = %err, "unreadable control file; ignoring it"),
)
.ok()
}
fn write_json<T: serde::Serialize>(
&self,
file: &'static str,
value: &T,
) -> Result<(), ControlWriteError> {
let bytes = serde_json::to_vec_pretty(value)
.map_err(|source| ControlWriteError::Serialize { file, source })?;
self.write(file, &bytes)
}
fn write(&self, file: &'static str, bytes: &[u8]) -> Result<(), ControlWriteError> {
write_atomic(&self.root.join(file), bytes)
.map_err(|source| ControlWriteError::Write { file, source })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_directory_is_private_and_its_files_round_trip() {
let tmp = tempfile::tempdir().expect("tempdir");
let control = ControlDir::under(tmp.path());
control.create().expect("create");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(control.root())
.expect("stat")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o700);
}
let at = Timestamp::parse("2026-09-22T00:00:00Z").expect("literal");
assert!(control.read_run().is_none());
control
.write_run(&RunMarker::Released { at })
.expect("write run");
assert_eq!(control.read_run(), Some(RunMarker::Released { at }));
assert!(!control.is_held());
control.set_hold(at).expect("hold");
assert!(control.is_held());
control.clear_hold().expect("clear");
control.clear_hold().expect("clearing twice is fine");
assert!(!control.is_held());
}
#[test]
fn one_daemon_per_data_directory() {
let tmp = tempfile::tempdir().expect("tempdir");
let control = ControlDir::under(tmp.path());
control.create().expect("create");
let first = control.lock().expect("the first daemon locks");
assert!(matches!(control.lock(), Err(LockError::Held { .. })));
drop(first);
control.lock().expect("released with the first");
}
}