use antigen_macros::presents;
use serde::{Deserialize, Serialize};
use super::AuditHint;
use crate::scan::ScanReport;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecurrentAudit {
pub declaration: crate::scan::RecurrentDeclaration,
pub hints: Vec<AuditHint>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RecurrentAuditReport {
pub audits: Vec<RecurrentAudit>,
pub clean_count: usize,
pub concern_count: usize,
}
impl RecurrentAuditReport {
#[must_use]
pub const fn all_clean(&self) -> bool {
self.concern_count == 0
}
}
const CHRONIC_REVIEW_HORIZON_DAYS: i64 = 365;
#[must_use]
pub fn audit_recurrent(report: &ScanReport) -> RecurrentAuditReport {
let acted_on: std::collections::HashSet<&str> = report
.immunities
.iter()
.map(|i| i.antigen_type.as_str())
.chain(report.presentations.iter().map(|p| p.antigen_type.as_str()))
.collect();
let itch_antigen_types: std::collections::HashSet<&str> = report
.recurrent_declarations
.iter()
.filter(|d| d.kind == crate::scan::RecurrentKind::Itch)
.filter_map(|d| d.antigen_type.as_deref())
.collect();
let parent_of: std::collections::HashMap<&str, Vec<&str>> = {
let mut m: std::collections::HashMap<&str, Vec<&str>> = std::collections::HashMap::new();
for e in &report.lineage_edges {
m.entry(e.child.as_str())
.or_default()
.push(e.parent.as_str());
}
m
};
let mut audits: Vec<RecurrentAudit> = Vec::new();
for decl in &report.recurrent_declarations {
let hints = evaluate_recurrent_hints(decl, &acted_on, &itch_antigen_types, &parent_of);
audits.push(RecurrentAudit {
declaration: decl.clone(),
hints,
});
}
let mut clean_count = 0usize;
let mut concern_count = 0usize;
for a in &audits {
if a.hints.is_empty() {
clean_count += 1;
} else {
concern_count += 1;
}
}
RecurrentAuditReport {
audits,
clean_count,
concern_count,
}
}
pub fn is_version_tag(s: &str) -> bool {
let had_v_prefix = s.starts_with(['v', 'V']);
let core = if had_v_prefix { &s[1..] } else { s };
let numeric_core: &str = core.split(['-', '+']).next().unwrap_or("");
if numeric_core.is_empty() {
return false;
}
let mut component_count = 0usize;
for part in numeric_core.split('.') {
if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
component_count += 1;
}
had_v_prefix || component_count >= 2
}
fn ancestors_of<'a>(
antigen: &'a str,
parent_of: &std::collections::HashMap<&'a str, Vec<&'a str>>,
) -> std::collections::HashSet<&'a str> {
let mut acc: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut stack: Vec<&str> = parent_of.get(antigen).cloned().unwrap_or_default();
while let Some(parent) = stack.pop() {
if acc.insert(parent) {
if let Some(grandparents) = parent_of.get(parent) {
stack.extend(grandparents.iter().copied());
}
}
}
acc
}
#[presents(AuditHintWithNoUpstreamPreconditionCheck)]
fn evaluate_recurrent_hints(
decl: &crate::scan::RecurrentDeclaration,
acted_on: &std::collections::HashSet<&str>,
itch_antigen_types: &std::collections::HashSet<&str>,
parent_of: &std::collections::HashMap<&str, Vec<&str>>,
) -> Vec<AuditHint> {
use crate::scan::RecurrentKind;
let mut hints = Vec::new();
match decl.kind {
RecurrentKind::Itch => {
if decl.antigen_type.is_none() {
hints.push(AuditHint::ItchNoticedNotAnchored);
}
},
RecurrentKind::RecurrenceAnchor => {
if let Some(antigen) = decl.antigen_type.as_deref() {
let ancestors = ancestors_of(antigen, parent_of);
let in_lineage = |itch: &str| itch == antigen || ancestors.contains(itch);
let has_valid_from_itches = !decl.from_itches.is_empty()
&& decl.from_itches.iter().any(|itch| {
in_lineage(itch.as_str()) && itch_antigen_types.contains(itch.as_str())
});
let has_implicit_itch = itch_antigen_types.contains(antigen);
if !has_valid_from_itches && !has_implicit_itch {
hints.push(AuditHint::RecurrenceAnchorNoItchPrecondition);
}
if !acted_on.contains(antigen) {
hints.push(AuditHint::RecurrenceThresholdReachedNoAction);
}
}
},
RecurrentKind::Crystallize => {
if decl.antigen_type.is_none() && decl.from_itches.is_empty() {
hints.push(AuditHint::CrystallizeWithoutAntigen);
}
},
RecurrentKind::Chronic => {
if decl.managed_by.is_none() {
hints.push(AuditHint::ChronicSignalUnmanaged);
}
if let Some(since) = decl.since.as_deref() {
if let Ok(since_date) = chrono::NaiveDate::parse_from_str(since, "%Y-%m-%d") {
let age = chrono::Utc::now().date_naive() - since_date;
if age.num_days() > CHRONIC_REVIEW_HORIZON_DAYS {
hints.push(AuditHint::ChronicSignalPastReviewDate);
}
} else if !is_version_tag(since) {
hints.push(AuditHint::ChronicSinceNotADate);
}
}
},
RecurrentKind::Saturate => {
if decl.contributing_to.is_none() {
hints.push(AuditHint::SaturateNoAnchor);
}
},
RecurrentKind::Strand => {
if decl.anchored_by.is_empty() {
hints.push(AuditHint::StrandNoAnchors);
}
},
}
hints
}