crates/nornir-poll-cadence/src/lib.rs
//! **Poll-cadence gate** — the pure, chrono-only scheduling predicates behind
//! nornir's monitored-workspace sweep (bug #10).
//!
//! A monitored workspace carries a per-workspace `poll` cadence
//! (`"60s"`/`"5m"`/`"2h"`/bare-seconds). This leaf owns the pure math that gates
//! a git fetch to that cadence instead of fetching every workspace on every fixed
//! tick: [`parse_interval`] parses the cadence string, [`is_workspace_due`]
//! decides whether a workspace is due on this tick, and [`latest_synced`] folds a
//! set of RFC-3339 member stamps into the most-recent sync time.
//!
//! Extracted from `nornir::monitor` per the shrink plan: warehouse-free,
//! registry-free, clock-injected — so it is fully testable and reusable by both
//! the nornir server sweep and the dwarves scheduler. The registry-typed wrapper
//! (`workspace_last_synced(&Workspace)`) stays in nornir and delegates to
//! [`latest_synced`] here.
use std::time::Duration;
/// Parse `"60s"`, `"5m"`, `"2h"`, or a bare seconds count; fall back to `default`.
pub fn parse_interval(s: &str, default: Duration) -> Duration {
let s = s.trim();
if s.is_empty() {
return default;
}
let (num, mult) = match s.chars().last() {
Some('s') => (&s[..s.len() - 1], 1),
Some('m') => (&s[..s.len() - 1], 60),
Some('h') => (&s[..s.len() - 1], 3600),
_ => (s, 1),
};
num.trim()
.parse::<u64>()
.ok()
.map(|n| Duration::from_secs(n.saturating_mul(mult)))
.unwrap_or(default)
}
/// The most-recent sync time across a set of RFC-3339 member stamps (`max`), or
/// `None` when none parse (empty/garbage stamps parse away). This is the pure
/// core of nornir's `workspace_last_synced`, which wraps it over a `Workspace`'s
/// members. Feeds [`is_workspace_due`].
pub fn latest_synced<'a, I>(stamps: I) -> Option<chrono::DateTime<chrono::Utc>>
where
I: IntoIterator<Item = &'a str>,
{
stamps
.into_iter()
.filter_map(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|d| d.with_timezone(&chrono::Utc))
.max()
}
/// Bug #10: is a monitored workspace due for a git fetch on this sweep tick?
///
/// The per-workspace `poll` cadence ("60s"/"5m"/"2h"/bare-seconds) was a DEAD
/// field — the sweep git-fetched every workspace on every fixed tick. This pure
/// predicate (testable, no clock/registry) gates the fetch: never-synced
/// (`last_synced == None`) is always due; otherwise due once `now - last_synced`
/// reaches the parsed poll interval. A future/negative stamp (clock skew) is
/// treated as due rather than starving the workspace.
pub fn is_workspace_due(
poll: &str,
last_synced: Option<chrono::DateTime<chrono::Utc>>,
now: chrono::DateTime<chrono::Utc>,
default: Duration,
) -> bool {
let Some(last) = last_synced else { return true };
let interval = parse_interval(poll, default);
match now.signed_duration_since(last).to_std() {
Ok(elapsed) => elapsed >= interval,
Err(_) => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn interval_parsing() {
let d = Duration::from_secs(60);
assert_eq!(parse_interval("", d), Duration::from_secs(60));
assert_eq!(parse_interval("30s", d), Duration::from_secs(30));
assert_eq!(parse_interval("5m", d), Duration::from_secs(300));
assert_eq!(parse_interval("2h", d), Duration::from_secs(7200));
assert_eq!(parse_interval("90", d), Duration::from_secs(90));
assert_eq!(parse_interval("garbage", d), Duration::from_secs(60));
}
/// Bug #10: the poll-cadence gate. Never-synced is always due; a recent sync
/// with a long poll is NOT due; a stale sync (or a short poll) IS due.
#[test]
fn is_workspace_due_honors_poll_cadence() {
use chrono::Utc;
let default = Duration::from_secs(60);
let now = Utc::now();
// Never synced → always due, regardless of poll.
assert!(is_workspace_due("1h", None, now, default));
// Synced 10s ago, poll = 1h → NOT due yet.
let recent = now - chrono::Duration::seconds(10);
assert!(!is_workspace_due("1h", Some(recent), now, default));
// Synced 2h ago, poll = 1h → due.
let stale = now - chrono::Duration::hours(2);
assert!(is_workspace_due("1h", Some(stale), now, default));
// poll = 1s with a 10s-old sync → due (short cadence).
assert!(is_workspace_due("1s", Some(recent), now, default));
// Empty poll falls back to the default (60s): a 10s-old sync is not due.
assert!(!is_workspace_due("", Some(recent), now, default));
// Future stamp (clock skew) → due rather than starving the workspace.
let future = now + chrono::Duration::hours(1);
assert!(is_workspace_due("1h", Some(future), now, default));
}
/// `latest_synced` takes the MAX stamp and ignores empty/garbage stamps.
#[test]
fn latest_synced_picks_latest_stamp() {
let got = latest_synced([
"2026-06-01T00:00:00Z",
"2026-06-10T00:00:00Z",
"", // never-synced member ignored
])
.unwrap();
assert_eq!(got.to_rfc3339(), "2026-06-10T00:00:00+00:00");
// Nothing parses → None.
assert!(latest_synced([""]).is_none());
}
}