1use acorde_core::Score;
2use serde::{Deserialize, Serialize};
3
4pub const REPORT_SCHEMA_VERSION: u32 = 1;
6
7fn default_report_schema_version() -> u32 {
8 REPORT_SCHEMA_VERSION
9}
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum DiagnosticSeverity {
14 Info,
15 Warning,
16 Error,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct Diagnostic {
22 pub code: String,
23 pub severity: DiagnosticSeverity,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub source_location: Option<String>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub preserved_value: Option<String>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub loss_reason: Option<String>,
30}
31
32impl Diagnostic {
33 pub fn info(code: impl Into<String>, preserved_value: impl Into<String>) -> Self {
34 Self {
35 code: code.into(),
36 severity: DiagnosticSeverity::Info,
37 source_location: None,
38 preserved_value: Some(preserved_value.into()),
39 loss_reason: None,
40 }
41 }
42
43 pub fn warning(code: impl Into<String>, loss_reason: impl Into<String>) -> Self {
44 Self {
45 code: code.into(),
46 severity: DiagnosticSeverity::Warning,
47 source_location: None,
48 preserved_value: None,
49 loss_reason: Some(loss_reason.into()),
50 }
51 }
52
53 pub fn is_loss(&self) -> bool {
54 self.loss_reason.is_some()
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ImportReport {
61 #[serde(default = "default_report_schema_version")]
63 pub schema_version: u32,
64 #[serde(default)]
66 pub format: String,
67 pub score: Score,
68 #[serde(default)]
69 pub diagnostics: Vec<Diagnostic>,
70}
71
72impl ImportReport {
73 pub fn new(score: Score) -> Self {
74 Self {
75 schema_version: REPORT_SCHEMA_VERSION,
76 format: "unknown".to_string(),
77 score,
78 diagnostics: Vec::new(),
79 }
80 }
81
82 pub fn for_format(score: Score, format: impl Into<String>) -> Self {
83 Self {
84 format: format.into(),
85 ..Self::new(score)
86 }
87 }
88
89 pub fn warning_count(&self) -> usize {
90 self.diagnostics
91 .iter()
92 .filter(|diagnostic| diagnostic.severity == DiagnosticSeverity::Warning)
93 .count()
94 }
95
96 pub fn error_count(&self) -> usize {
97 self.diagnostics
98 .iter()
99 .filter(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
100 .count()
101 }
102
103 pub fn loss_count(&self) -> usize {
104 self.diagnostics
105 .iter()
106 .filter(|diagnostic| diagnostic.is_loss())
107 .count()
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct ExportReport<T> {
114 #[serde(default = "default_report_schema_version")]
116 pub schema_version: u32,
117 #[serde(default)]
119 pub format: String,
120 pub output: T,
121 #[serde(default)]
122 pub diagnostics: Vec<Diagnostic>,
123}
124
125impl<T> ExportReport<T> {
126 pub fn new(output: T) -> Self {
127 Self {
128 schema_version: REPORT_SCHEMA_VERSION,
129 format: "unknown".to_string(),
130 output,
131 diagnostics: Vec::new(),
132 }
133 }
134
135 pub fn for_format(output: T, format: impl Into<String>) -> Self {
136 Self {
137 format: format.into(),
138 ..Self::new(output)
139 }
140 }
141
142 pub fn warning_count(&self) -> usize {
143 self.diagnostics
144 .iter()
145 .filter(|diagnostic| diagnostic.severity == DiagnosticSeverity::Warning)
146 .count()
147 }
148
149 pub fn error_count(&self) -> usize {
150 self.diagnostics
151 .iter()
152 .filter(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
153 .count()
154 }
155
156 pub fn loss_count(&self) -> usize {
157 self.diagnostics
158 .iter()
159 .filter(|diagnostic| diagnostic.is_loss())
160 .count()
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn diagnostic_round_trips_with_optional_fields() {
170 let mut diagnostic = Diagnostic::info("musicxml.title", "A score");
171 diagnostic.source_location = Some("/score-work/title".to_string());
172 let json = serde_json::to_string(&diagnostic).expect("diagnostic serializes");
173 let restored: Diagnostic = serde_json::from_str(&json).expect("diagnostic parses");
174 assert_eq!(restored, diagnostic);
175 }
176
177 #[test]
178 fn reports_default_to_no_loss() {
179 let report = ImportReport::new(Score::default());
180 assert_eq!(report.schema_version, REPORT_SCHEMA_VERSION);
181 assert_eq!(report.format, "unknown");
182 assert!(report.diagnostics.is_empty());
183 let output = ExportReport::new("output");
184 assert_eq!(output.schema_version, REPORT_SCHEMA_VERSION);
185 assert_eq!(output.format, "unknown");
186 assert!(output.diagnostics.is_empty());
187 }
188
189 #[test]
190 fn legacy_export_report_defaults_to_current_schema() {
191 let restored: ExportReport<String> =
192 serde_json::from_str(r#"{"format":"musicxml","output":"output","diagnostics":[]}"#)
193 .expect("legacy report parses");
194 assert_eq!(restored.schema_version, REPORT_SCHEMA_VERSION);
195 }
196
197 #[test]
198 fn report_counts_classify_diagnostics() {
199 let mut report = ImportReport::new(Score::default());
200 report.diagnostics.push(Diagnostic::info("kept", "value"));
201 report
202 .diagnostics
203 .push(Diagnostic::warning("lost", "not represented"));
204 assert_eq!(report.warning_count(), 1);
205 assert_eq!(report.error_count(), 0);
206 assert_eq!(report.loss_count(), 1);
207
208 let mut export = ExportReport::new("output");
209 export
210 .diagnostics
211 .push(Diagnostic::warning("lost", "not represented"));
212 assert_eq!(export.warning_count(), 1);
213 assert_eq!(export.loss_count(), 1);
214 }
215}