use std::collections::HashSet;
use chrono::{Datelike, Duration, NaiveDate};
use serde::{Deserialize, Serialize};
use crate::model::{
Cadence, Control, Inventory, LoadedRegistry, ResolvedSystem, Schedule, Scope, StateEntry,
Weekday,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DueReason {
Cadence,
OverrideDue,
OverrideInsert,
OverrideWeekday,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DueResolution {
pub date: NaiveDate,
pub reason: DueReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
pub fn next_due(
control: &Control,
schedule: &Schedule,
state: Option<&StateEntry>,
today: NaiveDate,
config_default_weekday: Option<Weekday>,
) -> Option<NaiveDate> {
next_due_with_reason(control, schedule, state, today, config_default_weekday).map(|r| r.date)
}
pub fn next_due_with_reason(
control: &Control,
schedule: &Schedule,
state: Option<&StateEntry>,
today: NaiveDate,
config_default_weekday: Option<Weekday>,
) -> Option<DueResolution> {
let weekday_override_entry = schedule
.overrides
.iter()
.find(|o| o.control_id == control.id && o.weekday.is_some());
let weekday_override = weekday_override_entry.and_then(|o| o.weekday);
let weekday_note =
weekday_override_entry.and_then(|o| o.note.clone().or_else(|| o.reason.clone()));
let effective_weekday = effective_weekday(control, schedule, config_default_weekday);
let pins: Vec<(NaiveDate, Option<String>)> = schedule
.overrides
.iter()
.filter(|o| o.control_id == control.id)
.filter_map(|o| {
o.due
.map(|d| (d, o.note.clone().or_else(|| o.reason.clone())))
})
.collect();
let pin_defers = |from: NaiveDate, p: NaiveDate| -> bool {
from <= p
&& match nominal_firing_after(control, effective_weekday, from) {
Some(nf) => nf > p,
None => true,
}
};
if let Some(stale) = state.and_then(|s| s.next_due) {
if stale < today && !skip_covers(control, schedule, stale) {
let rescheduled = pins
.iter()
.any(|(p, _)| *p >= today && pin_defers(stale, *p));
if !rescheduled {
let missed_pin = pins
.iter()
.filter(|(p, _)| *p < today && pin_defers(stale, *p))
.min_by_key(|(p, _)| *p);
return Some(match missed_pin {
Some((p, note)) => DueResolution {
date: *p,
reason: DueReason::OverrideDue,
note: note
.clone()
.or_else(|| Some("due date passed without a completed run".into())),
},
None => DueResolution {
date: stale,
reason: DueReason::Cadence,
note: Some("due date passed without a completed run".into()),
},
});
}
}
}
let skip_today = skip_covers(control, schedule, today);
let mut candidates: Vec<DatedCandidate> = Vec::new();
for ov in schedule
.overrides
.iter()
.filter(|o| o.control_id == control.id)
{
if let Some(insert) = &ov.insert {
if insert.run_at >= today {
candidates.push(DatedCandidate {
date: insert.run_at,
reason: DueReason::OverrideInsert,
note: ov
.note
.clone()
.or_else(|| insert.reason.clone())
.or_else(|| ov.reason.clone()),
precedence: 0,
});
}
}
}
for (p, note) in &pins {
if *p >= today {
candidates.push(DatedCandidate {
date: *p,
reason: DueReason::OverrideDue,
note: note.clone(),
precedence: 1,
});
}
}
let cached_next = state.and_then(|s| s.next_due).filter(|d| *d >= today);
let cadence_due = match control.cadence {
Cadence::Continuous => None,
_ => cached_next.or_else(|| {
Some(match control.cadence {
Cadence::Continuous => unreachable!("handled above"),
Cadence::Weekly => next_weekly(today, effective_weekday),
Cadence::Monthly => next_business_day(today, monthly_anchor(today)),
Cadence::Quarterly => next_business_day(today, quarterly_anchor(today)),
Cadence::SemiAnnual => next_business_day(today, semiannual_anchor(today)),
Cadence::Annual => next_annual(today, control.due_by.as_deref()),
})
}),
};
let deferred = |d: NaiveDate| pins.iter().any(|(p, _)| *p >= today && pin_defers(d, *p));
if let Some(d) = cadence_due.filter(|d| !deferred(*d)) {
let weekday_active =
matches!(control.cadence, Cadence::Weekly) && weekday_override.is_some();
let (reason, note, precedence) = if weekday_active {
(DueReason::OverrideWeekday, weekday_note.clone(), 2u8)
} else {
(DueReason::Cadence, None, 3u8)
};
candidates.push(DatedCandidate {
date: d,
reason,
note,
precedence,
});
}
let winner = candidates
.iter()
.min_by(|a, b| a.date.cmp(&b.date).then(a.precedence.cmp(&b.precedence)))
.cloned();
let winner = winner?;
if skip_today && winner.reason == DueReason::Cadence {
return candidates
.into_iter()
.filter(|c| c.reason == DueReason::OverrideInsert)
.min_by_key(|c| c.date)
.map(Into::into);
}
Some(winner.into())
}
#[derive(Debug, Clone)]
struct DatedCandidate {
date: NaiveDate,
reason: DueReason,
note: Option<String>,
precedence: u8,
}
impl From<DatedCandidate> for DueResolution {
fn from(c: DatedCandidate) -> Self {
DueResolution {
date: c.date,
reason: c.reason,
note: c.note,
}
}
}
fn skip_covers(control: &Control, schedule: &Schedule, date: NaiveDate) -> bool {
schedule
.overrides
.iter()
.filter(|o| o.control_id == control.id)
.any(|o| {
if let Some(skip) = &o.skip {
if let Some(q) = &skip.quarter {
return quarter_string(date) == *q;
}
if let Some(y) = skip.year {
return date.year() == y;
}
}
false
})
}
pub fn is_overdue(control: &Control, due: NaiveDate, today: NaiveDate) -> bool {
today > due + grace(control.cadence)
}
pub fn grace(cadence: Cadence) -> Duration {
match cadence {
Cadence::Continuous => Duration::days(0),
Cadence::Weekly => Duration::days(3),
Cadence::Monthly => Duration::days(7),
Cadence::Quarterly => Duration::days(14),
Cadence::SemiAnnual => Duration::days(21),
Cadence::Annual => Duration::days(30),
}
}
pub fn next_firing_after(
control: &Control,
schedule: &Schedule,
after: NaiveDate,
config_default_weekday: Option<Weekday>,
) -> Option<NaiveDate> {
let effective_weekday = effective_weekday(control, schedule, config_default_weekday);
let mut d = nominal_firing_after(control, effective_weekday, after)?;
for _ in 0..256 {
if !skip_covers(control, schedule, d) {
return Some(d);
}
d = nominal_firing_after(control, effective_weekday, d)?;
}
Some(d)
}
fn effective_weekday(
control: &Control,
schedule: &Schedule,
config_default_weekday: Option<Weekday>,
) -> Weekday {
schedule
.overrides
.iter()
.find(|o| o.control_id == control.id && o.weekday.is_some())
.and_then(|o| o.weekday)
.or(control.weekday)
.or(config_default_weekday)
.unwrap_or(Weekday::Monday)
}
fn nominal_firing_after(control: &Control, weekday: Weekday, d: NaiveDate) -> Option<NaiveDate> {
let after = d + Duration::days(1);
match control.cadence {
Cadence::Continuous => None,
Cadence::Weekly => Some(next_weekly(after, weekday)),
Cadence::Monthly => {
let this = first_business_day(monthly_anchor(after));
Some(if this > d {
this
} else {
let (y, m) = if after.month() == 12 {
(after.year() + 1, 1)
} else {
(after.year(), after.month() + 1)
};
first_business_day(NaiveDate::from_ymd_opt(y, m, 1).unwrap())
})
}
Cadence::Quarterly => {
let this = first_business_day(quarterly_anchor(after));
Some(if this > d {
this
} else {
let anchor = quarterly_anchor(after);
let (y, m) = if anchor.month() == 10 {
(anchor.year() + 1, 1)
} else {
(anchor.year(), anchor.month() + 3)
};
first_business_day(NaiveDate::from_ymd_opt(y, m, 1).unwrap())
})
}
Cadence::SemiAnnual => {
let this = first_business_day(semiannual_anchor(after));
Some(if this > d {
this
} else {
let anchor = semiannual_anchor(after);
let (y, m) = if anchor.month() == 7 {
(anchor.year() + 1, 1)
} else {
(anchor.year(), 7)
};
first_business_day(NaiveDate::from_ymd_opt(y, m, 1).unwrap())
})
}
Cadence::Annual => Some(next_annual(after, control.due_by.as_deref())),
}
}
fn first_business_day(anchor: NaiveDate) -> NaiveDate {
let mut d = anchor;
while matches!(d.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
d += Duration::days(1);
}
d
}
fn next_weekly(today: NaiveDate, weekday: Weekday) -> NaiveDate {
let target = weekday.to_chrono().num_days_from_monday() as i64;
let cur = today.weekday().num_days_from_monday() as i64;
let mut delta = target - cur;
if delta < 0 {
delta += 7;
}
today + Duration::days(delta)
}
fn monthly_anchor(today: NaiveDate) -> NaiveDate {
NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap()
}
fn quarterly_anchor(today: NaiveDate) -> NaiveDate {
let q_first = match today.month() {
1..=3 => 1,
4..=6 => 4,
7..=9 => 7,
_ => 10,
};
NaiveDate::from_ymd_opt(today.year(), q_first, 1).unwrap()
}
fn semiannual_anchor(today: NaiveDate) -> NaiveDate {
let m = if today.month() <= 6 { 1 } else { 7 };
NaiveDate::from_ymd_opt(today.year(), m, 1).unwrap()
}
fn next_annual(today: NaiveDate, due_by: Option<&str>) -> NaiveDate {
if let Some(due) = due_by {
if let Some(d) = parse_due_by(due, today.year()) {
if d >= today {
return d;
}
return parse_due_by(due, today.year() + 1).unwrap_or(d);
}
}
NaiveDate::from_ymd_opt(today.year(), 12, 31).unwrap_or(today)
}
fn parse_due_by(s: &str, year: i32) -> Option<NaiveDate> {
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
return Some(d);
}
let mut parts = s.splitn(2, '-');
let month = parts.next()?;
let day: u32 = parts.next()?.parse().ok()?;
let m = match month.to_lowercase().as_str() {
"january" | "jan" => 1,
"february" | "feb" => 2,
"march" | "mar" => 3,
"april" | "apr" => 4,
"may" => 5,
"june" | "jun" => 6,
"july" | "jul" => 7,
"august" | "aug" => 8,
"september" | "sep" => 9,
"october" | "oct" => 10,
"november" | "nov" => 11,
"december" | "dec" => 12,
_ => return None,
};
NaiveDate::from_ymd_opt(year, m, day)
}
fn next_business_day(today: NaiveDate, anchor: NaiveDate) -> NaiveDate {
let mut d = anchor.max(today);
while matches!(d.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
d += Duration::days(1);
}
if d < today {
return today;
}
d
}
fn quarter_string(date: NaiveDate) -> String {
let q = (date.month() - 1) / 3 + 1;
format!("{:04}-q{}", date.year(), q)
}
pub fn resolve_scope(
control: &Control,
inventory: &Inventory,
run_date: NaiveDate,
) -> Vec<ResolvedSystem> {
match &control.scope {
None => Vec::new(),
Some(Scope::Inline(inline)) => inline
.inline
.iter()
.map(|e| ResolvedSystem {
name: e.name.clone(),
kind: e.kind.clone(),
tags: e.tags.clone(),
extras: Default::default(),
})
.collect(),
Some(Scope::Inventory(spec)) => {
let entries = inventory.entries(&spec.kind);
let want_tags: HashSet<&str> = spec.has_tags.iter().map(String::as_str).collect();
let control_excludes: HashSet<&str> =
spec.excludes.iter().map(String::as_str).collect();
let all = spec.all.unwrap_or(false);
let mut out: Vec<ResolvedSystem> = entries
.iter()
.filter(|e| e.is_active_on(run_date))
.filter(|e| {
if all {
true
} else {
let entry_tags: HashSet<&str> = e.tags.iter().map(String::as_str).collect();
want_tags.iter().all(|t| entry_tags.contains(t))
}
})
.filter(|e| !control_excludes.contains(e.name.as_str()))
.filter(|e| !e.excludes.iter().any(|s| s == &control.skill))
.map(|e| ResolvedSystem {
name: e.name.clone(),
kind: spec.kind.clone(),
tags: e.tags.clone(),
extras: e.extras.clone(),
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
}
}
#[derive(Debug, Clone)]
pub struct DueRow {
pub control_id: String,
pub cadence: Cadence,
pub next_due: Option<NaiveDate>,
pub overdue: bool,
}
pub fn due_rows(reg: &LoadedRegistry, today: NaiveDate) -> Vec<DueRow> {
let mut rows: Vec<DueRow> = reg
.controls
.values()
.map(|c| {
let state = reg.state.controls.get(&c.id);
let next = next_due(
c,
®.schedule,
state,
today,
reg.config.weekly_default_weekday,
);
let overdue = next.map(|d| is_overdue(c, d, today)).unwrap_or(false);
DueRow {
control_id: c.id.clone(),
cadence: c.cadence,
next_due: next,
overdue,
}
})
.collect();
rows.sort_by(|a, b| match (a.next_due, b.next_due) {
(Some(x), Some(y)) => (x, &a.control_id).cmp(&(y, &b.control_id)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.control_id.cmp(&b.control_id),
});
rows
}
pub fn due_within(reg: &LoadedRegistry, today: NaiveDate, window_days: i64) -> Vec<DueRow> {
let cutoff = today + Duration::days(window_days);
due_rows(reg, today)
.into_iter()
.filter(|r| match r.next_due {
Some(d) => d <= cutoff,
None => false,
})
.collect()
}