cobre_io/report.rs
1//! Structured validation report for programmatic consumption.
2//!
3//! [`generate_report`] converts a [`ValidationContext`] into a
4//! [`ValidationReport`] that can be serialized to JSON for the CLI,
5//! TUI, or MCP server.
6//!
7//! # Examples
8//!
9//! ```
10//! use cobre_io::validation::{ErrorKind, ValidationContext};
11//! use cobre_io::{generate_report};
12//!
13//! let mut ctx = ValidationContext::new();
14//! ctx.add_error(ErrorKind::FileNotFound, "system/hydros.json", None::<&str>, "file missing");
15//! ctx.add_warning(ErrorKind::UnusedEntity, "system/thermals.json", Some("T1"), "inactive");
16//!
17//! let report = generate_report(&ctx);
18//! assert_eq!(report.error_count, 1);
19//! assert_eq!(report.warning_count, 1);
20//!
21//! let json = report.to_json().unwrap();
22//! assert!(json.contains("error_count"));
23//! ```
24
25use serde::Serialize;
26
27use crate::LoadError;
28use crate::validation::ValidationContext;
29
30// ── ReportEntry ───────────────────────────────────────────────────────────────
31
32/// A single diagnostic entry in a [`ValidationReport`].
33#[derive(Debug, Clone, Serialize)]
34pub struct ReportEntry {
35 /// `Debug` representation of the `ErrorKind` variant (e.g., `"FileNotFound"`).
36 pub kind: String,
37 /// Path to the file where the problem was detected, as a string.
38 pub file: String,
39 /// Optional identifier of the entity involved (e.g., `"hydro_042"`).
40 pub entity: Option<String>,
41 /// Human-readable description of the problem.
42 pub message: String,
43}
44
45// ── ValidationReport ─────────────────────────────────────────────────────────
46
47/// A structured summary of all validation diagnostics collected by a
48/// [`ValidationContext`].
49///
50/// Produced by [`generate_report`] and serializable to JSON via [`to_json`].
51///
52/// [`to_json`]: ValidationReport::to_json
53#[derive(Debug, Clone, Serialize)]
54pub struct ValidationReport {
55 /// Total number of error-severity diagnostics.
56 pub error_count: usize,
57 /// Total number of warning-severity diagnostics.
58 pub warning_count: usize,
59 /// All error-severity diagnostics.
60 pub errors: Vec<ReportEntry>,
61 /// All warning-severity diagnostics.
62 pub warnings: Vec<ReportEntry>,
63}
64
65impl ValidationReport {
66 /// Serialize this report to a pretty-printed JSON string.
67 ///
68 /// # Errors
69 ///
70 /// Returns [`LoadError::ParseError`] with path `"<report>"` if `serde_json`
71 /// fails to serialize the report. This should not occur in practice given
72 /// the types used in [`ValidationReport`].
73 ///
74 /// # Examples
75 ///
76 /// ```
77 /// use cobre_io::validation::{ErrorKind, ValidationContext};
78 /// use cobre_io::generate_report;
79 ///
80 /// let ctx = ValidationContext::new();
81 /// let report = generate_report(&ctx);
82 /// let json = report.to_json().unwrap();
83 /// assert!(json.contains("error_count"));
84 /// assert!(json.contains("errors"));
85 /// assert!(json.contains("warnings"));
86 /// ```
87 pub fn to_json(&self) -> Result<String, LoadError> {
88 serde_json::to_string_pretty(self)
89 .map_err(|e| LoadError::parse("<report>", format!("JSON serialization failed: {e}")))
90 }
91}
92
93// ── generate_report ───────────────────────────────────────────────────────────
94
95/// Convert a [`ValidationContext`] into a [`ValidationReport`].
96///
97/// Borrows `ctx` so the caller can still call
98/// [`ValidationContext::into_result`] afterward.
99///
100/// # Examples
101///
102/// ```
103/// use cobre_io::validation::{ErrorKind, ValidationContext};
104/// use cobre_io::generate_report;
105///
106/// let mut ctx = ValidationContext::new();
107/// ctx.add_error(ErrorKind::FileNotFound, "system/hydros.json", None::<&str>, "missing");
108/// ctx.add_error(ErrorKind::ParseError, "stages.json", None::<&str>, "malformed");
109/// ctx.add_warning(ErrorKind::UnusedEntity, "system/thermals.json", Some("T1"), "inactive");
110///
111/// let report = generate_report(&ctx);
112/// assert_eq!(report.error_count, 2);
113/// assert_eq!(report.warning_count, 1);
114/// ```
115#[must_use]
116pub fn generate_report(ctx: &ValidationContext) -> ValidationReport {
117 let errors: Vec<ReportEntry> = ctx
118 .errors()
119 .into_iter()
120 .map(|entry| ReportEntry {
121 kind: format!("{:?}", entry.kind),
122 file: entry.file.display().to_string(),
123 entity: entry.entity.clone(),
124 message: entry.message.clone(),
125 })
126 .collect();
127
128 let warnings: Vec<ReportEntry> = ctx
129 .warnings()
130 .into_iter()
131 .map(|entry| ReportEntry {
132 kind: format!("{:?}", entry.kind),
133 file: entry.file.display().to_string(),
134 entity: entry.entity.clone(),
135 message: entry.message.clone(),
136 })
137 .collect();
138
139 let error_count = errors.len();
140 let warning_count = warnings.len();
141
142 ValidationReport {
143 error_count,
144 warning_count,
145 errors,
146 warnings,
147 }
148}
149
150// ── Tests ─────────────────────────────────────────────────────────────────────
151
152#[cfg(test)]
153#[allow(clippy::unwrap_used)]
154mod tests {
155 use super::*;
156 use crate::validation::{ErrorKind, ValidationContext};
157
158 fn make_context_with_errors_and_warnings() -> ValidationContext {
159 let mut ctx = ValidationContext::new();
160 ctx.add_error(
161 ErrorKind::FileNotFound,
162 "system/hydros.json",
163 None::<&str>,
164 "required file is missing",
165 );
166 ctx.add_error(
167 ErrorKind::ParseError,
168 "stages.json",
169 Some("stage_001"),
170 "malformed JSON at line 42",
171 );
172 ctx.add_warning(
173 ErrorKind::UnusedEntity,
174 "system/thermals.json",
175 Some("T1"),
176 "max_generation=0 for all stages",
177 );
178 ctx
179 }
180
181 #[test]
182 fn test_generate_report_errors_and_warnings() {
183 let ctx = make_context_with_errors_and_warnings();
184 let report = generate_report(&ctx);
185
186 assert_eq!(report.error_count, 2);
187 assert_eq!(report.warning_count, 1);
188 assert_eq!(report.errors.len(), 2);
189 assert_eq!(report.warnings.len(), 1);
190 }
191
192 #[test]
193 fn test_generate_report_empty_context() {
194 let ctx = ValidationContext::new();
195 let report = generate_report(&ctx);
196
197 assert_eq!(report.error_count, 0);
198 assert_eq!(report.warning_count, 0);
199 assert!(report.errors.is_empty());
200 assert!(report.warnings.is_empty());
201 }
202
203 #[test]
204 fn test_report_to_json_valid() {
205 let ctx = make_context_with_errors_and_warnings();
206 let report = generate_report(&ctx);
207 let json = report.to_json().unwrap();
208
209 assert!(json.contains("\"error_count\""));
210 assert!(json.contains("\"errors\""));
211 assert!(json.contains("\"warnings\""));
212 assert!(json.contains("\"warning_count\""));
213 }
214
215 #[test]
216 fn test_report_entry_fields() {
217 let mut ctx = ValidationContext::new();
218 ctx.add_error(
219 ErrorKind::FileNotFound,
220 "system/hydros.json",
221 Some("hydro_042"),
222 "required file is missing",
223 );
224 let report = generate_report(&ctx);
225
226 assert_eq!(report.errors.len(), 1);
227 let entry = &report.errors[0];
228
229 assert_eq!(entry.kind, "FileNotFound");
230 assert!(entry.file.contains("system/hydros.json"));
231 assert_eq!(entry.entity.as_deref(), Some("hydro_042"));
232 assert_eq!(entry.message, "required file is missing");
233 }
234
235 #[test]
236 fn test_generate_report_does_not_consume_context() {
237 let mut ctx = ValidationContext::new();
238 ctx.add_error(
239 ErrorKind::FileNotFound,
240 "system/hydros.json",
241 None::<&str>,
242 "missing",
243 );
244
245 let report = generate_report(&ctx);
246 assert_eq!(report.error_count, 1);
247
248 assert!(ctx.into_result().is_err());
249 }
250}