Skip to main content

acorde_io/
report.rs

1use acorde_core::Score;
2use serde::{Deserialize, Serialize};
3
4/// Version of the serialized import/export report contract.
5pub const REPORT_SCHEMA_VERSION: u32 = 1;
6
7fn default_report_schema_version() -> u32 {
8    REPORT_SCHEMA_VERSION
9}
10
11/// Severity of an interchange diagnostic.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum DiagnosticSeverity {
14    Info,
15    Warning,
16    Error,
17}
18
19/// A source-grounded note about an import or export decision.
20#[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/// Result of importing a notation document, including conversion diagnostics.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ImportReport {
61    /// Version of the serialized report shape.
62    #[serde(default = "default_report_schema_version")]
63    pub schema_version: u32,
64    /// Stable lowercase format identifier for the source document.
65    #[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/// Result of exporting a notation document, including conversion diagnostics.
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct ExportReport<T> {
114    /// Version of the serialized report shape.
115    #[serde(default = "default_report_schema_version")]
116    pub schema_version: u32,
117    /// Stable lowercase format identifier for the output document.
118    #[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}