use serde::Serialize;
use crate::song::SongError;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Error,
Warning,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Severity::Error => f.write_str("error"),
Severity::Warning => f.write_str("warning"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Diagnostic {
pub code: &'static str,
pub severity: Severity,
pub path: String,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remediation: Option<String>,
}
impl Diagnostic {
pub fn error(code: &'static str, path: impl Into<String>, message: impl Into<String>) -> Self {
Diagnostic {
code,
severity: Severity::Error,
path: path.into(),
message: message.into(),
remediation: None,
}
}
pub fn warning(
code: &'static str,
path: impl Into<String>,
message: impl Into<String>,
) -> Self {
Diagnostic {
code,
severity: Severity::Warning,
path: path.into(),
message: message.into(),
remediation: None,
}
}
pub fn with_remediation(mut self, remediation: impl Into<String>) -> Self {
self.remediation = Some(remediation.into());
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CompileError(pub Vec<Diagnostic>);
impl CompileError {
pub fn one(d: Diagnostic) -> Self {
CompileError(vec![d])
}
pub fn push(&mut self, d: Diagnostic) {
self.0.push(d);
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn errors(&self) -> impl Iterator<Item = &Diagnostic> {
self.0.iter().filter(|d| d.severity == Severity::Error)
}
pub fn has_errors(&self) -> bool {
self.0.iter().any(|d| d.severity == Severity::Error)
}
}
impl std::fmt::Display for CompileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (i, d) in self.0.iter().enumerate() {
if i > 0 {
f.write_str("\n")?;
}
write!(f, "{} {} {}: {}", d.code, d.severity, d.path, d.message)?;
if let Some(r) = &d.remediation {
write!(f, " ({r})")?;
}
}
Ok(())
}
}
impl std::error::Error for CompileError {}
impl From<&SongError> for Diagnostic {
fn from(e: &SongError) -> Self {
let (code, path, remediation) = match e {
SongError::Empty => (
"T1000",
"tracks",
"add at least one track (Song::add_track or Song::add)",
),
SongError::UnknownTrack(_) => (
"T1001",
"arrangement",
"add a track with this name or fix the placement's track field",
),
SongError::UnknownPattern(_) => (
"T1002",
"arrangement",
"add a pattern with this name or fix the placement's pattern field",
),
SongError::Compile(_) => (
"T1099",
"doc",
"fix the underlying document error and recompile",
),
};
Diagnostic::error(code, path, e.to_string()).with_remediation(remediation)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_is_one_line_per_diagnostic() {
let d = Diagnostic::error(
"T1001",
"arrangement",
"arrangement references unknown track 'nope'",
)
.with_remediation("add a track with this name or fix the placement's track field");
assert_eq!(
CompileError::one(d).to_string(),
"T1001 error arrangement: arrangement references unknown track 'nope' \
(add a track with this name or fix the placement's track field)"
);
assert_eq!(
CompileError::one(Diagnostic::error("T1000", "tracks", "song has no tracks"))
.to_string(),
"T1000 error tracks: song has no tracks"
);
let mut e = CompileError::one(Diagnostic::error("T1000", "tracks", "song has no tracks"));
e.push(Diagnostic::warning(
"T1500",
"master",
"reverb blocks streaming",
));
assert_eq!(
e.to_string(),
"T1000 error tracks: song has no tracks\nT1500 warning master: reverb blocks streaming"
);
}
#[test]
fn severity_filters_and_decides_failure() {
let mut e = CompileError::default();
assert!(e.is_empty());
assert!(!e.has_errors());
e.push(Diagnostic::warning(
"T1500",
"master",
"reverb blocks streaming",
));
assert!(!e.has_errors(), "warnings alone never fail a compile");
assert_eq!(e.errors().count(), 0);
e.push(Diagnostic::error("T1000", "tracks", "song has no tracks"));
assert!(e.has_errors());
assert_eq!(e.errors().count(), 1);
assert_eq!(e.errors().next().unwrap().code, "T1000");
}
#[test]
fn song_error_codes_are_stable() {
let empty = Diagnostic::from(&SongError::Empty);
assert_eq!(empty.code, "T1000");
assert_eq!(empty.path, "tracks");
assert_eq!(empty.message, "song has no tracks");
assert!(empty.remediation.is_some());
let track = Diagnostic::from(&SongError::UnknownTrack("nope".into()));
assert_eq!(track.code, "T1001");
assert_eq!(track.path, "arrangement");
assert!(
track.message.contains("nope"),
"the track name is in the message: {}",
track.message
);
assert_eq!(
track.remediation.as_deref(),
Some("add a track with this name or fix the placement's track field")
);
let pattern = Diagnostic::from(&SongError::UnknownPattern("ghost".into()));
assert_eq!(pattern.code, "T1002");
assert_eq!(pattern.path, "arrangement");
assert!(pattern.message.contains("ghost"));
let compile = Diagnostic::from(&SongError::Compile("bad doc".into()));
assert_eq!(compile.code, "T1099");
assert_eq!(compile.message, "bad doc");
for d in [empty, track, pattern, compile] {
assert_eq!(d.severity, Severity::Error);
assert!(d.remediation.is_some());
}
}
#[test]
fn serde_skips_absent_remediation_and_lowercases_severity() {
let bare = serde_json::to_value(Diagnostic::error("T1000", "tracks", "song has no tracks"))
.unwrap();
assert_eq!(
bare,
serde_json::json!({
"code": "T1000",
"severity": "error",
"path": "tracks",
"message": "song has no tracks",
}),
"no remediation key at all when None"
);
let full = serde_json::to_value(
Diagnostic::warning("T1500", "master", "reverb blocks streaming")
.with_remediation("render offline instead"),
)
.unwrap();
assert_eq!(full["severity"], "warning");
assert_eq!(full["remediation"], "render offline instead");
}
}