1use serde::{Deserialize, Serialize};
5
6#[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#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ReportEvent {
27 pub severity: Severity,
28 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#[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 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 pub fn count_severity(&self, sev: Severity) -> usize {
114 self.events.iter().filter(|e| e.severity == sev).count()
115 }
116
117 pub fn is_healthy(&self) -> bool {
119 self.count_severity(Severity::Error) == 0
120 }
121
122 pub fn has_warnings(&self) -> bool {
124 self.count_severity(Severity::Warning) > 0
125 }
126
127 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 pub fn to_json(&self) -> serde_json::Value {
164 serde_json::to_value(self).unwrap_or_default()
165 }
166
167 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
179pub 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}