use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use jiff::Timestamp;
use layover_core::agent::AgentName;
use layover_core::flight::{ItineraryId, RunId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Live {
pub run: RunId,
pub itinerary: ItineraryId,
pub agent: AgentName,
pub pid: u32,
pub started_at: Timestamp,
pub hangar: PathBuf,
}
#[derive(Debug, Clone)]
pub struct Ledger {
root: PathBuf,
}
impl Ledger {
pub fn open(root: impl Into<PathBuf>) -> io::Result<Self> {
let root = root.into();
fs::create_dir_all(&root)?;
Ok(Self { root })
}
pub fn starting(&self, live: &Live) -> io::Result<()> {
let text = serde_json::to_string(live).map_err(io::Error::other)?;
fs::write(self.path_for(&live.run), text)
}
pub fn finished(&self, run: &RunId) -> io::Result<()> {
match fs::remove_file(self.path_for(run)) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
pub fn live(&self) -> io::Result<Vec<Live>> {
let mut found = Vec::new();
for entry in fs::read_dir(&self.root)? {
let path = entry?.path();
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
if let Ok(text) = fs::read_to_string(&path)
&& let Ok(live) = serde_json::from_str::<Live>(&text)
{
found.push(live);
}
}
found.sort_by_key(|live| live.started_at);
Ok(found)
}
fn path_for(&self, run: &RunId) -> PathBuf {
self.root.join(format!("{run}.json"))
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
StillRunning,
ConfirmedGone,
Reused,
}
impl Verdict {
#[must_use]
pub const fn is_recoverable(self) -> bool {
matches!(self, Self::ConfirmedGone | Self::Reused)
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Temp(PathBuf);
impl Temp {
fn new(name: &str) -> Self {
let path =
std::env::temp_dir().join(format!("layover-state-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&path);
Self(path)
}
}
impl Drop for Temp {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn live() -> Live {
Live {
run: RunId::generate(),
itinerary: ItineraryId::generate(),
agent: AgentName::new("tester"),
pid: 4242,
started_at: Timestamp::now(),
hangar: PathBuf::from("hangar"),
}
}
#[test]
fn a_started_run_is_readable_before_anything_else_happens() {
let temp = Temp::new("started");
let ledger = Ledger::open(&temp.0).expect("opens");
let record = live();
ledger.starting(&record).expect("records");
let found = ledger.live().expect("reads");
assert_eq!(found, vec![record]);
}
#[test]
fn a_finished_run_is_forgotten() {
let temp = Temp::new("finished");
let ledger = Ledger::open(&temp.0).expect("opens");
let record = live();
ledger.starting(&record).expect("records");
ledger.finished(&record.run).expect("forgets");
assert!(ledger.live().expect("reads").is_empty());
}
#[test]
fn forgetting_a_run_twice_is_not_an_error() {
let temp = Temp::new("twice");
let ledger = Ledger::open(&temp.0).expect("opens");
let record = live();
ledger.starting(&record).expect("records");
ledger.finished(&record.run).expect("first");
ledger.finished(&record.run).expect("second");
}
#[test]
fn a_half_written_record_does_not_stop_the_supervisor_starting() {
let temp = Temp::new("corrupt");
let ledger = Ledger::open(&temp.0).expect("opens");
ledger.starting(&live()).expect("records");
fs::write(temp.0.join("run_bad.json"), "{\"run\":").expect("writes rubbish");
let found = ledger.live().expect("reads");
assert_eq!(found.len(), 1, "the readable record still arrives");
}
#[test]
fn live_runs_come_back_oldest_first() {
let temp = Temp::new("order");
let ledger = Ledger::open(&temp.0).expect("opens");
let mut first = live();
first.started_at = Timestamp::now()
.checked_sub(jiff::SignedDuration::from_hours(2))
.expect("in range");
let second = live();
ledger.starting(&second).expect("records");
ledger.starting(&first).expect("records");
let found = ledger.live().expect("reads");
assert_eq!(found[0].run, first.run, "the longest-running is first");
}
#[test]
fn a_reused_identifier_is_still_recoverable_but_says_why() {
assert!(Verdict::ConfirmedGone.is_recoverable());
assert!(Verdict::Reused.is_recoverable());
assert!(!Verdict::StillRunning.is_recoverable());
}
}