use crate::core::{DeadlineSpec, Digest, Timestamp};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CalendarError {
#[error("this calendar does not implement deadline kind '{0}'")]
UnknownKind(String),
#[error("malformed parameters for '{kind}': {detail}")]
BadParams { kind: String, detail: String },
#[error("'{kind}' resolved outside the representable range")]
OutOfRange { kind: String },
}
pub trait Calendar: Send + Sync + std::fmt::Debug {
fn resolve(&self, from: Timestamp, spec: &DeadlineSpec) -> Result<Timestamp, CalendarError>;
fn digest(&self) -> Digest;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct WallClock;
impl WallClock {
fn count(spec: &DeadlineSpec) -> Result<i64, CalendarError> {
spec.params
.get("n")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| CalendarError::BadParams {
kind: spec.kind.clone(),
detail: "expected an integer field `n`".into(),
})
}
}
impl Calendar for WallClock {
fn resolve(&self, from: Timestamp, spec: &DeadlineSpec) -> Result<Timestamp, CalendarError> {
let n = Self::count(spec)?;
let seconds = match spec.kind.as_str() {
"hours" => n.checked_mul(3_600),
"days" => n.checked_mul(86_400),
"minutes" => n.checked_mul(60),
other => return Err(CalendarError::UnknownKind(other.to_owned())),
};
let out_of_range = || CalendarError::OutOfRange {
kind: spec.kind.clone(),
};
let delta = seconds
.map(time::Duration::seconds)
.ok_or_else(out_of_range)?;
from.checked_add(delta).ok_or_else(out_of_range)
}
fn digest(&self) -> Digest {
Digest::of(b"agentplane.calendar.wallclock.v1")
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::datetime;
#[test]
fn resolves_hours_and_days() {
let base = datetime!(2026-07-30 09:00:00 UTC);
assert_eq!(
WallClock.resolve(base, &DeadlineSpec::hours(24)).unwrap(),
datetime!(2026-07-31 09:00:00 UTC)
);
assert_eq!(
WallClock.resolve(base, &DeadlineSpec::days(5)).unwrap(),
datetime!(2026-08-04 09:00:00 UTC)
);
}
#[test]
fn resolution_is_pure() {
let base = datetime!(2026-07-30 09:00:00 UTC);
let spec = DeadlineSpec::hours(72);
assert_eq!(
WallClock.resolve(base, &spec).unwrap(),
WallClock.resolve(base, &spec).unwrap()
);
}
#[test]
fn unknown_rules_are_refused_not_approximated() {
let err = WallClock
.resolve(
datetime!(2026-07-30 09:00:00 UTC),
&DeadlineSpec::new("working-days", serde_json::json!({ "n": 5 })),
)
.unwrap_err();
assert!(matches!(err, CalendarError::UnknownKind(_)));
}
#[test]
fn a_count_that_cannot_be_a_duration_is_refused_not_a_panic() {
let base = datetime!(2026-07-30 09:00:00 UTC);
for kind in ["hours", "days", "minutes"] {
let spec = DeadlineSpec::new(kind, serde_json::json!({ "n": i64::MAX }));
let err = WallClock.resolve(base, &spec).unwrap_err();
assert!(
matches!(err, CalendarError::OutOfRange { .. }),
"{kind}: expected a refusal, got {err:?}"
);
}
}
#[test]
fn an_instant_past_the_representable_range_is_refused() {
let base = datetime!(2026-07-30 09:00:00 UTC);
let spec = DeadlineSpec::days(4_000_000);
let err = WallClock.resolve(base, &spec).unwrap_err();
assert!(matches!(err, CalendarError::OutOfRange { .. }), "{err:?}");
}
#[test]
fn malformed_parameters_are_refused() {
let err = WallClock
.resolve(
datetime!(2026-07-30 09:00:00 UTC),
&DeadlineSpec::new("hours", serde_json::json!({ "wrong": 1 })),
)
.unwrap_err();
assert!(matches!(err, CalendarError::BadParams { .. }));
}
#[test]
fn digest_is_stable_for_a_ruleset() {
assert_eq!(WallClock.digest(), WallClock.digest());
}
}