Skip to main content

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}