use crate::entity::{Entity, FieldValue, Label};
use crate::parser::ParsedCase;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Error,
Warning,
Info,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Error => write!(f, "error"),
Self::Warning => write!(f, "warning"),
Self::Info => write!(f, "info"),
}
}
}
#[derive(Debug)]
pub struct Finding {
pub severity: Severity,
pub message: String,
}
#[derive(Debug, Clone)]
pub struct Thresholds {
pub investigation_months: u32,
pub trial_months: u32,
pub appeal_months: u32,
}
impl Default for Thresholds {
fn default() -> Self {
Self {
investigation_months: 6,
trial_months: 12,
appeal_months: 12,
}
}
}
pub fn check_case(
case: &ParsedCase,
entities: &[Entity],
thresholds: &Thresholds,
today: (i32, u32, u32),
) -> Vec<Finding> {
let mut findings = Vec::new();
let Some(status) = case.status.as_deref() else {
return findings;
};
let events: Vec<&Entity> = entities
.iter()
.filter(|e| e.label == Label::Event)
.collect();
if events.is_empty() {
findings.push(Finding {
severity: Severity::Error,
message: format!("status is '{status}' but case has no events"),
});
return findings;
}
let event_types = collect_event_types(&events);
let latest_date = find_latest_date(&events);
check_age_rules(status, latest_date, thresholds, today, &mut findings);
check_mismatch_rules(status, &event_types, &mut findings);
findings
}
fn check_age_rules(
status: &str,
latest_date: Option<(i32, u32, u32)>,
thresholds: &Thresholds,
today: (i32, u32, u32),
findings: &mut Vec<Finding>,
) {
let Some((y, m, d)) = latest_date else {
return;
};
let months_ago = months_between((y, m, d), today);
let (threshold, applies) = match status {
"under_investigation" => (thresholds.investigation_months, true),
"trial" | "open" => (thresholds.trial_months, true),
"appeal" => (thresholds.appeal_months, true),
_ => (0, false),
};
if applies && months_ago > threshold {
findings.push(Finding {
severity: Severity::Warning,
message: format!(
"status is '{status}' and latest event is {months_ago} months ago \
(threshold: {threshold} months)"
),
});
}
}
fn check_mismatch_rules(
status: &str,
event_types: &std::collections::HashSet<String>,
findings: &mut Vec<Finding>,
) {
if status == "trial" && !has_any(event_types, VERDICT_TYPES) {
findings.push(Finding {
severity: Severity::Info,
message: "status is 'trial' but no verdict/sentencing/conviction/acquittal event found"
.to_string(),
});
}
if status == "convicted"
&& !has_any(event_types, &["verdict", "sentencing", "conviction"])
{
findings.push(Finding {
severity: Severity::Warning,
message: "status is 'convicted' but no verdict/sentencing/conviction event found"
.to_string(),
});
}
if status == "acquitted" && !has_any(event_types, &["verdict", "acquittal"]) {
findings.push(Finding {
severity: Severity::Warning,
message: "status is 'acquitted' but no verdict/acquittal event found".to_string(),
});
}
if status == "pardoned"
&& !has_any(event_types, &["verdict", "sentencing", "conviction", "pardon"])
{
findings.push(Finding {
severity: Severity::Warning,
message:
"status is 'pardoned' but no verdict/sentencing/conviction/pardon event found"
.to_string(),
});
}
}
const VERDICT_TYPES: &[&str] = &["verdict", "sentencing", "conviction", "acquittal"];
fn has_any(set: &std::collections::HashSet<String>, values: &[&str]) -> bool {
values.iter().any(|v| set.contains(*v))
}
fn collect_event_types(events: &[&Entity]) -> std::collections::HashSet<String> {
let mut types = std::collections::HashSet::new();
for e in events {
if let Some((_, FieldValue::Single(val))) =
e.fields.iter().find(|(k, _)| k == "event_type")
{
let normalized = val
.strip_prefix("custom:")
.unwrap_or(val)
.to_lowercase()
.replace(' ', "_");
types.insert(normalized);
}
}
types
}
fn find_latest_date(events: &[&Entity]) -> Option<(i32, u32, u32)> {
let mut latest: Option<(i32, u32, u32)> = None;
for e in events {
if let Some((_, FieldValue::Single(val))) =
e.fields.iter().find(|(k, _)| k == "occurred_at")
&& let Some(parsed) = parse_date(val)
{
latest = Some(match latest {
None => parsed,
Some(prev) => {
if date_cmp(parsed, prev) == std::cmp::Ordering::Greater {
parsed
} else {
prev
}
}
});
}
}
latest
}
fn parse_date(s: &str) -> Option<(i32, u32, u32)> {
let parts: Vec<&str> = s.split('-').collect();
match parts.len() {
1 => {
let y = parts[0].parse::<i32>().ok()?;
Some((y, 12, 31)) }
2 => {
let y = parts[0].parse::<i32>().ok()?;
let m = parts[1].parse::<u32>().ok()?;
if !(1..=12).contains(&m) {
return None;
}
Some((y, m, 28)) }
3 => {
let y = parts[0].parse::<i32>().ok()?;
let m = parts[1].parse::<u32>().ok()?;
let d = parts[2].parse::<u32>().ok()?;
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
Some((y, m, d))
}
_ => None,
}
}
fn date_cmp(a: (i32, u32, u32), b: (i32, u32, u32)) -> std::cmp::Ordering {
a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2))
}
fn months_between(from: (i32, u32, u32), to: (i32, u32, u32)) -> u32 {
let year_diff = to.0 - from.0;
let month_diff = i64::from(to.1) - i64::from(from.1);
let total = i64::from(year_diff) * 12 + month_diff;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
if total < 0 { 0 } else { total as u32 }
}
#[cfg(test)]
mod tests {
use super::*;
fn make_event(name: &str, event_type: &str, occurred_at: &str) -> Entity {
Entity {
name: name.to_string(),
label: Label::Event,
fields: vec![
(
"event_type".to_string(),
FieldValue::Single(event_type.to_string()),
),
(
"occurred_at".to_string(),
FieldValue::Single(occurred_at.to_string()),
),
],
id: Some("01TEST00000000000000000000".to_string()),
line: 1,
tags: Vec::new(),
slug: None,
}
}
fn make_case(status: &str) -> ParsedCase {
ParsedCase {
id: Some("01TEST00000000000000000000".to_string()),
sources: Vec::new(),
title: "Test Case".to_string(),
summary: "Test summary.".to_string(),
sections: Vec::new(),
case_type: Some("corruption".to_string()),
status: Some(status.to_string()),
amounts: None,
tags: Vec::new(),
tagline: None,
related_cases: Vec::new(),
involved: Vec::new(),
}
}
fn today() -> (i32, u32, u32) {
(2026, 4, 21)
}
#[test]
fn no_events_is_error() {
let case = make_case("convicted");
let findings = check_case(&case, &[], &Thresholds::default(), today());
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Error);
assert!(findings[0].message.contains("no events"));
}
#[test]
fn investigation_stale() {
let case = make_case("under_investigation");
let events = vec![make_event("Raid", "raid", "2025-06-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Warning);
assert!(findings[0].message.contains("under_investigation"));
assert!(findings[0].message.contains("10 months ago"));
}
#[test]
fn investigation_fresh() {
let case = make_case("under_investigation");
let events = vec![make_event("Raid", "raid", "2026-02-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert!(findings.is_empty());
}
#[test]
fn trial_stale() {
let case = make_case("trial");
let events = vec![make_event("Indictment", "indictment", "2024-12-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert!(findings.iter().any(|f| f.severity == Severity::Warning));
assert!(findings.iter().any(|f| f.severity == Severity::Info));
}
#[test]
fn trial_with_verdict_not_missing() {
let case = make_case("trial");
let events = vec![
make_event("Indictment", "indictment", "2026-03-01"),
make_event("Verdict", "conviction", "2026-04-01"),
];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert!(findings.is_empty());
}
#[test]
fn convicted_without_verdict() {
let case = make_case("convicted");
let events = vec![make_event("Arrest", "arrest", "2026-01-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Warning);
assert!(findings[0].message.contains("convicted"));
}
#[test]
fn convicted_with_sentencing() {
let case = make_case("convicted");
let events = vec![make_event("Sentencing", "sentencing", "2026-01-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert!(findings.is_empty());
}
#[test]
fn acquitted_without_verdict() {
let case = make_case("acquitted");
let events = vec![make_event("Trial", "trial", "2026-01-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("acquitted"));
}
#[test]
fn pardoned_without_conviction() {
let case = make_case("pardoned");
let events = vec![make_event("Arrest", "arrest", "2026-01-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("pardoned"));
}
#[test]
fn pardoned_with_pardon_event() {
let case = make_case("pardoned");
let events = vec![make_event("Pardon", "pardon", "2026-01-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert!(findings.is_empty());
}
#[test]
fn custom_thresholds() {
let case = make_case("under_investigation");
let events = vec![make_event("Raid", "raid", "2026-01-01")];
let thresholds = Thresholds {
investigation_months: 2,
..Thresholds::default()
};
let findings = check_case(&case, &events, &thresholds, today());
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("3 months ago"));
}
#[test]
fn no_status_no_findings() {
let mut case = make_case("convicted");
case.status = None;
let events = vec![make_event("Arrest", "arrest", "2020-01-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert!(findings.is_empty());
}
#[test]
fn partial_date_year_only() {
let case = make_case("under_investigation");
let events = vec![make_event("Raid", "raid", "2025")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert!(findings.is_empty()); }
#[test]
fn open_case_stale() {
let case = make_case("open");
let events = vec![make_event("Filing", "investigation_opened", "2024-01-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("open"));
}
#[test]
fn months_between_same_date() {
assert_eq!(months_between((2026, 4, 21), (2026, 4, 21)), 0);
}
#[test]
fn months_between_year_diff() {
assert_eq!(months_between((2024, 4, 1), (2026, 4, 21)), 24);
}
#[test]
fn parse_date_formats() {
assert_eq!(parse_date("2025"), Some((2025, 12, 31)));
assert_eq!(parse_date("2025-06"), Some((2025, 6, 28)));
assert_eq!(parse_date("2025-06-15"), Some((2025, 6, 15)));
assert_eq!(parse_date("invalid"), None);
assert_eq!(parse_date("2025-13"), None);
}
}