use std::fmt;
use jiff::{Timestamp, ToSpan};
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use crate::agent::AgentName;
use crate::flight::ItineraryId;
use crate::handover::Handover;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(transparent)]
pub struct LayoverId(String);
impl LayoverId {
#[must_use]
pub fn generate() -> Self {
Self(format!("lay_{}", Ulid::new()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for LayoverId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Standing {
Booked,
Resumed,
Expired,
Cancelled,
}
impl Standing {
#[must_use]
pub fn is_pending(self) -> bool {
matches!(self, Self::Booked)
}
#[must_use]
pub fn slug(self) -> &'static str {
match self {
Self::Booked => "booked",
Self::Resumed => "resumed",
Self::Expired => "expired",
Self::Cancelled => "cancelled",
}
}
#[must_use]
pub fn from_slug(slug: &str) -> Option<Self> {
[Self::Booked, Self::Resumed, Self::Expired, Self::Cancelled]
.into_iter()
.find(|standing| standing.slug() == slug)
}
}
impl fmt::Display for Standing {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.slug())
}
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Layover {
pub id: LayoverId,
pub agent: AgentName,
pub booked_by: ItineraryId,
pub waiting_for: String,
pub handover: Handover,
pub booked_at: Timestamp,
pub due_at: Timestamp,
pub checks: u32,
pub max_checks: u32,
pub standing: Standing,
}
impl Layover {
#[must_use]
pub fn book(
agent: AgentName,
booked_by: ItineraryId,
waiting_for: impl Into<String>,
handover: Handover,
booked_at: Timestamp,
due_at: Timestamp,
max_checks: u32,
) -> Self {
Self {
id: LayoverId::generate(),
agent,
booked_by,
waiting_for: waiting_for.into(),
handover,
booked_at,
due_at: due_at.max(booked_at),
checks: 0,
max_checks,
standing: Standing::Booked,
}
}
#[must_use]
pub fn is_due(&self, now: Timestamp) -> bool {
self.standing.is_pending() && now >= self.due_at
}
pub fn set_down_again(&mut self, now: Timestamp) {
self.checks = self.checks.saturating_add(1);
if self.checks >= self.max_checks {
self.standing = Standing::Expired;
return;
}
let minutes = backoff_minutes(self.checks);
self.due_at = now.checked_add(minutes.minutes()).unwrap_or(Timestamp::MAX);
}
pub fn resumed(&mut self) {
self.standing = Standing::Resumed;
}
pub fn cancel(&mut self) {
self.standing = Standing::Cancelled;
}
#[must_use]
pub fn minutes_until_due(&self, now: Timestamp) -> Option<i64> {
let seconds = self.due_at.as_second() - now.as_second();
(seconds > 0).then_some(seconds / 60)
}
}
fn backoff_minutes(checks: u32) -> i64 {
const FIRST: i64 = 15;
const CAP: i64 = 6 * 60;
let doublings = checks.saturating_sub(1).min(8);
(FIRST << doublings).min(CAP)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handover::{Handover, Steer};
fn at(rfc3339: &str) -> Timestamp {
rfc3339.parse().expect("valid timestamp")
}
fn booked(due: &str) -> Layover {
Layover::book(
"developer".into(),
ItineraryId::generate(),
"comments on PR 1543477",
Handover::steered(
Steer {
previous: crate::flight::RunId::generate(),
note: "address review comments".to_owned(),
at: at("2026-09-16T10:00:00Z"),
},
Vec::new(),
),
at("2026-09-16T10:00:00Z"),
at(due),
8,
)
}
#[test]
fn a_layover_starts_booked_and_becomes_due_at_its_time() {
let layover = booked("2026-09-16T11:00:00Z");
assert_eq!(layover.standing, Standing::Booked);
assert!(!layover.is_due(at("2026-09-16T10:30:00Z")));
assert!(layover.is_due(at("2026-09-16T11:00:00Z")));
assert_eq!(
layover.minutes_until_due(at("2026-09-16T10:30:00Z")),
Some(30)
);
}
#[test]
fn a_layover_due_before_it_was_booked_is_held_to_the_booking_time() {
let layover = booked("2026-09-16T09:00:00Z");
assert_eq!(layover.due_at, at("2026-09-16T10:00:00Z"));
}
#[test]
fn checking_and_finding_nothing_pushes_the_next_check_further_out() {
let mut layover = booked("2026-09-16T11:00:00Z");
layover.set_down_again(at("2026-09-16T11:00:00Z"));
let first = layover.minutes_until_due(at("2026-09-16T11:00:00Z"));
layover.set_down_again(at("2026-09-16T11:15:00Z"));
let second = layover.minutes_until_due(at("2026-09-16T11:15:00Z"));
assert_eq!(first, Some(15));
assert_eq!(second, Some(30));
assert!(second > first);
}
#[test]
fn the_backoff_is_capped_so_a_late_comment_is_not_ignored_for_days() {
let mut layover = booked("2026-09-16T11:00:00Z");
layover.max_checks = 40;
for _ in 0..20 {
layover.set_down_again(at("2026-09-16T11:00:00Z"));
}
assert_eq!(
layover.minutes_until_due(at("2026-09-16T11:00:00Z")),
Some(6 * 60)
);
}
#[test]
fn a_layover_that_waits_forever_is_given_up_on() {
let mut layover = booked("2026-09-16T11:00:00Z");
for _ in 0..8 {
layover.set_down_again(at("2026-09-16T11:00:00Z"));
}
assert_eq!(layover.standing, Standing::Expired);
assert!(!layover.is_due(at("2027-01-01T00:00:00Z")));
}
#[test]
fn resuming_and_cancelling_both_take_it_out_of_the_queue() {
let mut resumed = booked("2026-09-16T11:00:00Z");
resumed.resumed();
let mut cancelled = booked("2026-09-16T11:00:00Z");
cancelled.cancel();
for layover in [&resumed, &cancelled] {
assert!(!layover.standing.is_pending());
assert!(!layover.is_due(at("2026-09-16T12:00:00Z")));
}
}
#[test]
fn the_resumed_run_is_given_what_the_earlier_one_knew() {
let layover = booked("2026-09-16T11:00:00Z");
assert!(layover.handover.brief().contains("address review comments"));
}
#[test]
fn standings_round_trip() {
for standing in [
Standing::Booked,
Standing::Resumed,
Standing::Expired,
Standing::Cancelled,
] {
assert_eq!(Standing::from_slug(standing.slug()), Some(standing));
}
assert_eq!(Standing::from_slug("parked"), None);
}
#[test]
fn a_layover_serialises_readably() {
let line = serde_json::to_string(&booked("2026-09-16T11:00:00Z")).expect("serialises");
assert!(line.contains(r#""standing":"booked""#), "{line}");
assert!(
line.contains(r#""due_at":"2026-09-16T11:00:00Z""#),
"{line}"
);
assert!(line.contains("comments on PR 1543477"), "{line}");
}
}