Skip to main content

pomelo_audit/
report.rs

1//! Report types and rendering: the [`Status`] / [`Check`] / [`DataAuditReport`]
2//! shapes serialized for `--json`, the [`render_table`] human view, and the
3//! small formatting/date helpers the checks share.
4
5use serde::Serialize;
6use serde_json::{json, Value};
7
8/// A single check's verdict. Ordered `Ok < Warn < Fail` so the report's overall
9/// status is the max across checks.
10#[derive(Serialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
11#[serde(rename_all = "UPPERCASE")]
12pub enum Status {
13    Ok,
14    Warn,
15    Fail,
16}
17
18/// One named check: a status, a one-line human summary, and structured details.
19#[derive(Serialize)]
20pub struct Check {
21    pub name: &'static str,
22    pub status: Status,
23    pub summary: String,
24    #[serde(skip_serializing_if = "Value::is_null")]
25    pub details: Value,
26}
27
28impl Check {
29    pub(crate) fn new(
30        name: &'static str,
31        status: Status,
32        summary: impl Into<String>,
33        details: Value,
34    ) -> Self {
35        Check {
36            name,
37            status,
38            summary: summary.into(),
39            details,
40        }
41    }
42}
43
44/// The full audit report — serialized directly for `--json`.
45#[derive(Serialize)]
46pub struct DataAuditReport {
47    pub data_dir: String,
48    pub from: i32,
49    pub to: i32,
50    pub symbol_count: usize,
51    pub overall: Status,
52    pub checks: Vec<Check>,
53}
54
55/// Render the report as a compact human-readable table (the CLI's default output).
56pub fn render_table(report: &DataAuditReport) -> String {
57    let mut out = String::new();
58    out.push_str(&format!(
59        "data-audit: {}  [{}..{}]  {} symbols\n",
60        report.data_dir, report.from, report.to, report.symbol_count
61    ));
62    out.push_str(&format!("overall: {}\n\n", status_str(report.overall)));
63    for c in &report.checks {
64        out.push_str(&format!(
65            "[{:>4}] {:<16} {}\n",
66            status_str(c.status),
67            c.name,
68            c.summary
69        ));
70    }
71    out
72}
73
74pub(crate) fn status_str(s: Status) -> &'static str {
75    match s {
76        Status::Ok => "OK",
77        Status::Warn => "WARN",
78        Status::Fail => "FAIL",
79    }
80}
81
82/// At most 20 sample names, as a JSON array (keeps the report bounded).
83pub(crate) fn sample(names: &[&str]) -> Vec<String> {
84    names.iter().take(20).map(|s| s.to_string()).collect()
85}
86
87pub(crate) fn range_or_null(first: i32, last: i32) -> Value {
88    if first == i32::MAX {
89        Value::Null
90    } else {
91        json!({ "first_day": first, "last_day": last })
92    }
93}
94
95/// Whether `day` (YYYYMMDD) is the last calendar day of its month — every fiscal
96/// period-end is a month-end, so a filing date landing here is the lookahead smell.
97pub(crate) fn is_month_end(day: i32) -> bool {
98    let (y, m, d) = (day / 10000, (day / 100) % 100, day % 100);
99    d == days_in_month(y, m)
100}
101
102fn days_in_month(y: i32, m: i32) -> i32 {
103    match m {
104        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
105        4 | 6 | 9 | 11 => 30,
106        2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
107        2 => 28,
108        _ => 0,
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn month_end_and_days_in_month() {
118        assert!(is_month_end(20240229)); // leap February
119        assert!(is_month_end(20230228)); // non-leap February
120        assert!(!is_month_end(20240228)); // 28th is not month-end in a leap year
121        assert!(is_month_end(20240131));
122        assert!(is_month_end(20240430));
123        assert!(!is_month_end(20240415));
124        assert_eq!(days_in_month(2024, 2), 29);
125        assert_eq!(days_in_month(2023, 2), 28);
126        assert_eq!(days_in_month(2000, 2), 29); // divisible by 400
127        assert_eq!(days_in_month(1900, 2), 28); // divisible by 100, not 400
128        assert_eq!(days_in_month(2024, 4), 30);
129        assert_eq!(days_in_month(2024, 7), 31);
130        assert_eq!(days_in_month(2024, 13), 0);
131    }
132
133    #[test]
134    fn sample_and_range_helpers() {
135        assert_eq!(sample(&["a", "b"]), vec!["a".to_string(), "b".to_string()]);
136        assert!(range_or_null(i32::MAX, i32::MIN).is_null());
137        assert!(!range_or_null(20240101, 20240102).is_null());
138        assert_eq!(status_str(Status::Ok), "OK");
139        assert_eq!(status_str(Status::Warn), "WARN");
140        assert_eq!(status_str(Status::Fail), "FAIL");
141    }
142}