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> {
if let Some(stale) = state.and_then(|s| s.next_due) {
if stale < today && !skip_covers(control, schedule, stale) {
return Some(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 ov in schedule
.overrides
.iter()
.filter(|o| o.control_id == control.id)
{
if let Some(d) = ov.due {
if d >= today {
candidates.push(DatedCandidate {
date: d,
reason: DueReason::OverrideDue,
note: ov.note.clone().or_else(|| ov.reason.clone()),
precedence: 1,
});
}
}
}
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 cadence_due = match control.cadence {
Cadence::Continuous => None,
Cadence::Weekly => {
let wd = weekday_override
.or(control.weekday)
.or(config_default_weekday)
.unwrap_or(Weekday::Monday);
Some(next_weekly(today, wd, state.and_then(|s| s.next_due)))
}
Cadence::Monthly => Some(next_business_day(today, monthly_anchor(today))),
Cadence::Quarterly => Some(next_business_day(today, quarterly_anchor(today))),
Cadence::SemiAnnual => Some(next_business_day(today, semiannual_anchor(today))),
Cadence::Annual => Some(next_annual(today, control.due_by.as_deref())),
};
if let Some(d) = cadence_due {
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),
}
}
fn next_weekly(today: NaiveDate, weekday: Weekday, last_next_due: Option<NaiveDate>) -> NaiveDate {
if let Some(d) = last_next_due {
if d >= today {
return d;
}
}
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()
}