Skip to main content

oxihuman_core/
report.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4use serde::{Deserialize, Serialize};
5
6/// Severity of a report event.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
8pub enum Severity {
9    Info,
10    Warning,
11    Error,
12}
13
14impl Severity {
15    pub fn label(&self) -> &'static str {
16        match self {
17            Severity::Info => "INFO",
18            Severity::Warning => "WARN",
19            Severity::Error => "ERROR",
20        }
21    }
22}
23
24/// A single report event.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ReportEvent {
27    pub severity: Severity,
28    /// e.g. "parser", "policy", "morph", "export"
29    pub category: String,
30    pub message: String,
31    pub detail: Option<String>,
32}
33
34impl ReportEvent {
35    pub fn info(category: impl Into<String>, message: impl Into<String>) -> Self {
36        Self {
37            severity: Severity::Info,
38            category: category.into(),
39            message: message.into(),
40            detail: None,
41        }
42    }
43
44    pub fn warning(category: impl Into<String>, message: impl Into<String>) -> Self {
45        Self {
46            severity: Severity::Warning,
47            category: category.into(),
48            message: message.into(),
49            detail: None,
50        }
51    }
52
53    pub fn error(category: impl Into<String>, message: impl Into<String>) -> Self {
54        Self {
55            severity: Severity::Error,
56            category: category.into(),
57            message: message.into(),
58            detail: None,
59        }
60    }
61
62    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
63        self.detail = Some(detail.into());
64        self
65    }
66}
67
68/// Complete pipeline audit report.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct PipelineReport {
71    pub events: Vec<ReportEvent>,
72    pub targets_loaded: usize,
73    pub targets_blocked: usize,
74    pub targets_failed: usize,
75    pub base_mesh_verts: usize,
76    pub base_mesh_faces: usize,
77    pub export_paths: Vec<String>,
78    /// Timestamp string (ISO 8601 UTC, computed at build time).
79    pub generated_at: String,
80}
81
82impl PipelineReport {
83    pub fn new() -> Self {
84        Self {
85            events: Vec::new(),
86            targets_loaded: 0,
87            targets_blocked: 0,
88            targets_failed: 0,
89            base_mesh_verts: 0,
90            base_mesh_faces: 0,
91            export_paths: Vec::new(),
92            generated_at: current_timestamp(),
93        }
94    }
95
96    pub fn add_event(&mut self, event: ReportEvent) {
97        self.events.push(event);
98    }
99
100    pub fn info(&mut self, category: &str, msg: &str) {
101        self.add_event(ReportEvent::info(category, msg));
102    }
103
104    pub fn warning(&mut self, category: &str, msg: &str) {
105        self.add_event(ReportEvent::warning(category, msg));
106    }
107
108    pub fn error(&mut self, category: &str, msg: &str) {
109        self.add_event(ReportEvent::error(category, msg));
110    }
111
112    /// Count events by severity.
113    pub fn count_severity(&self, sev: Severity) -> usize {
114        self.events.iter().filter(|e| e.severity == sev).count()
115    }
116
117    /// True if there are no Error-severity events.
118    pub fn is_healthy(&self) -> bool {
119        self.count_severity(Severity::Error) == 0
120    }
121
122    /// True if there are warnings.
123    pub fn has_warnings(&self) -> bool {
124        self.count_severity(Severity::Warning) > 0
125    }
126
127    /// Render as a human-readable text report.
128    pub fn to_text(&self) -> String {
129        let mut out = String::new();
130        out.push_str(&format!(
131            "OxiHuman Pipeline Report — {}\n",
132            self.generated_at
133        ));
134        out.push_str(&format!(
135            "  Targets: {} loaded, {} blocked, {} failed\n",
136            self.targets_loaded, self.targets_blocked, self.targets_failed
137        ));
138        out.push_str(&format!(
139            "  Base mesh: {} verts, {} faces\n",
140            self.base_mesh_verts, self.base_mesh_faces
141        ));
142        if !self.export_paths.is_empty() {
143            out.push_str(&format!("  Exports: {}\n", self.export_paths.join(", ")));
144        }
145        out.push_str(&format!(
146            "  Health: {} | Warnings: {} | Errors: {}\n",
147            if self.is_healthy() { "OK" } else { "FAIL" },
148            self.count_severity(Severity::Warning),
149            self.count_severity(Severity::Error)
150        ));
151        for e in &self.events {
152            out.push_str(&format!(
153                "  [{}] {}: {}\n",
154                e.severity.label(),
155                e.category,
156                e.message
157            ));
158        }
159        out
160    }
161
162    /// Serialize to JSON.
163    pub fn to_json(&self) -> serde_json::Value {
164        serde_json::to_value(self).unwrap_or_default()
165    }
166
167    /// Save as JSON to a file.
168    pub fn save_json(&self, path: &std::path::Path) -> anyhow::Result<()> {
169        std::fs::write(path, serde_json::to_string_pretty(&self.to_json())?).map_err(Into::into)
170    }
171}
172
173impl Default for PipelineReport {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179/// A builder for constructing a report incrementally during pipeline execution.
180pub struct ReportBuilder {
181    report: PipelineReport,
182}
183
184impl ReportBuilder {
185    pub fn new() -> Self {
186        Self {
187            report: PipelineReport::new(),
188        }
189    }
190
191    pub fn target_loaded(mut self, name: &str) -> Self {
192        self.report.targets_loaded += 1;
193        self.report.info("morph", &format!("loaded target: {name}"));
194        self
195    }
196
197    pub fn target_blocked(mut self, name: &str, reason: &str) -> Self {
198        self.report.targets_blocked += 1;
199        self.report
200            .warning("policy", &format!("blocked: {name} \u{2014} {reason}"));
201        self
202    }
203
204    pub fn target_failed(mut self, name: &str, err: &str) -> Self {
205        self.report.targets_failed += 1;
206        self.report
207            .error("parser", &format!("failed: {name} \u{2014} {err}"));
208        self
209    }
210
211    pub fn base_mesh(mut self, verts: usize, faces: usize) -> Self {
212        self.report.base_mesh_verts = verts;
213        self.report.base_mesh_faces = faces;
214        self.report
215            .info("mesh", &format!("base mesh: {verts} verts, {faces} faces"));
216        self
217    }
218
219    pub fn export(mut self, path: &str) -> Self {
220        self.report.export_paths.push(path.to_string());
221        self.report.info("export", &format!("exported: {path}"));
222        self
223    }
224
225    pub fn build(self) -> PipelineReport {
226        self.report
227    }
228}
229
230impl Default for ReportBuilder {
231    fn default() -> Self {
232        Self::new()
233    }
234}
235
236fn current_timestamp() -> String {
237    use std::time::{SystemTime, UNIX_EPOCH};
238    let secs = SystemTime::now()
239        .duration_since(UNIX_EPOCH)
240        .map(|d| d.as_secs())
241        .unwrap_or(0);
242    let (y, mo, d, h, mi, s) = unix_to_datetime(secs);
243    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
244}
245
246fn unix_to_datetime(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
247    let sec = (secs % 60) as u32;
248    let min = ((secs / 60) % 60) as u32;
249    let hour = ((secs / 3600) % 24) as u32;
250    let mut days = secs / 86400;
251    let mut year = 1970u64;
252    loop {
253        let dy: u64 =
254            if (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400) {
255                366
256            } else {
257                365
258            };
259        if days < dy {
260            break;
261        }
262        days -= dy;
263        year += 1;
264    }
265    let is_leap = (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400);
266    let months: [u64; 12] = [
267        31,
268        if is_leap { 29 } else { 28 },
269        31,
270        30,
271        31,
272        30,
273        31,
274        31,
275        30,
276        31,
277        30,
278        31,
279    ];
280    let mut month = 1u64;
281    for &ml in &months {
282        if days < ml {
283            break;
284        }
285        days -= ml;
286        month += 1;
287    }
288    (year as u32, month as u32, (days + 1) as u32, hour, min, sec)
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn new_report_is_healthy() {
297        assert!(PipelineReport::new().is_healthy());
298    }
299
300    #[test]
301    fn add_error_makes_unhealthy() {
302        let mut r = PipelineReport::new();
303        r.error("test", "something broke");
304        assert!(!r.is_healthy());
305    }
306
307    #[test]
308    fn count_severity_correct() {
309        let mut r = PipelineReport::new();
310        r.warning("a", "w1");
311        r.warning("b", "w2");
312        r.info("c", "i1");
313        assert_eq!(r.count_severity(Severity::Warning), 2);
314        assert_eq!(r.count_severity(Severity::Info), 1);
315        assert_eq!(r.count_severity(Severity::Error), 0);
316    }
317
318    #[test]
319    fn builder_target_loaded_increments() {
320        let report = ReportBuilder::new().target_loaded("x").build();
321        assert_eq!(report.targets_loaded, 1);
322    }
323
324    #[test]
325    fn builder_target_blocked() {
326        let report = ReportBuilder::new().target_blocked("y", "nsfw").build();
327        assert_eq!(report.targets_blocked, 1);
328        assert!(report.has_warnings());
329    }
330
331    #[test]
332    fn builder_build_is_healthy_after_loaded() {
333        let report = ReportBuilder::new().target_loaded("z").build();
334        assert!(report.is_healthy());
335    }
336
337    #[test]
338    fn to_text_contains_report() {
339        let report = PipelineReport::new();
340        assert!(report.to_text().contains("Pipeline Report"));
341    }
342
343    #[test]
344    fn to_json_has_targets_loaded() {
345        let report = PipelineReport::new();
346        let json = report.to_json();
347        assert!(json["targets_loaded"].as_u64().is_some());
348    }
349
350    #[test]
351    fn save_json_creates_file() {
352        let report = PipelineReport::new();
353        let path = std::path::Path::new("/tmp/oxihuman_report_test.json");
354        report.save_json(path).expect("save_json failed");
355        assert!(path.exists());
356    }
357
358    #[test]
359    fn timestamp_nonempty() {
360        let report = PipelineReport::new();
361        assert!(!report.generated_at.is_empty());
362    }
363}