use std::collections::{BTreeMap, HashMap};
use crate::songs::{Song, Songs};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Severity {
Warning,
Error,
}
#[derive(Debug, Clone)]
pub struct Issue {
pub severity: Severity,
pub category: &'static str,
pub song_name: String,
pub message: String,
}
#[derive(Debug, Clone, Default)]
pub struct VerificationReport {
pub issues: Vec<Issue>,
}
impl VerificationReport {
pub fn is_clean(&self) -> bool {
self.issues.is_empty()
}
pub fn has_errors(&self) -> bool {
self.issues.iter().any(|i| i.severity == Severity::Error)
}
pub fn merge(&mut self, other: VerificationReport) {
self.issues.extend(other.issues);
}
}
pub fn check_track_mappings(song: &Song, track_mappings: &HashMap<String, Vec<u16>>) -> Vec<Issue> {
song.tracks()
.iter()
.filter(|track| !track_mappings.contains_key(track.name()))
.map(|track| Issue {
severity: Severity::Warning,
category: "track-mappings",
song_name: song.name().to_string(),
message: format!("track \"{}\" has no entry in track_mappings", track.name()),
})
.collect()
}
pub fn check_all_track_mappings(
songs: &Songs,
track_mappings: &HashMap<String, Vec<u16>>,
) -> VerificationReport {
let mut report = VerificationReport::default();
for song in songs.sorted_list() {
report
.issues
.extend(check_track_mappings(&song, track_mappings));
}
report
}
pub fn warn_unmapped_tracks(song: &Song, track_mappings: &HashMap<String, Vec<u16>>) {
let unmapped: Vec<&str> = song
.tracks()
.iter()
.filter(|track| !track_mappings.contains_key(track.name()))
.map(|track| track.name())
.collect();
if !unmapped.is_empty() {
tracing::warn!(
song = song.name(),
tracks = ?unmapped,
"Song has {} track(s) with no track mapping; these tracks will be silent",
unmapped.len()
);
}
}
pub fn print_report(report: &VerificationReport, songs: &Songs) {
if report.is_clean() {
println!("\u{2705} All {} song(s) passed verification.", songs.len());
return;
}
let mut by_song: BTreeMap<&str, Vec<&Issue>> = BTreeMap::new();
for issue in &report.issues {
by_song.entry(&issue.song_name).or_default().push(issue);
}
let clean_count = songs
.sorted_list()
.iter()
.filter(|song| !by_song.contains_key(song.name()))
.count();
for (song_name, issues) in &by_song {
let has_errors = issues.iter().any(|i| i.severity == Severity::Error);
let icon = if has_errors {
"\u{274c}"
} else {
"\u{26a0}\u{fe0f} "
};
println!("{} {}", icon, song_name);
for issue in issues {
let severity_icon = match issue.severity {
Severity::Warning => "\u{26a0}\u{fe0f} ",
Severity::Error => "\u{274c}",
};
println!(
" {} [{}] {}",
severity_icon, issue.category, issue.message
);
}
}
if clean_count > 0 {
println!("\n\u{2705} {} song(s) passed all checks.", clean_count);
}
println!(
"\nSummary: {} issue(s) found across {} song(s).",
report.issues.len(),
by_song.len()
);
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn make_song(name: &str, track_names: &[&str]) -> Arc<Song> {
Arc::new(Song::new_for_test(name, track_names))
}
fn make_mappings(names: &[&str]) -> HashMap<String, Vec<u16>> {
names
.iter()
.enumerate()
.map(|(i, name)| (name.to_string(), vec![(i + 1) as u16]))
.collect()
}
#[test]
fn test_check_track_mappings_all_mapped() {
let song = make_song("test-song", &["guitar", "bass"]);
let mappings = make_mappings(&["guitar", "bass"]);
let issues = check_track_mappings(&song, &mappings);
assert!(issues.is_empty());
}
#[test]
fn test_check_track_mappings_some_unmapped() {
let song = make_song("test-song", &["guitar", "bass", "click"]);
let mappings = make_mappings(&["guitar", "bass"]);
let issues = check_track_mappings(&song, &mappings);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].song_name, "test-song");
assert!(issues[0].message.contains("click"));
assert_eq!(issues[0].severity, Severity::Warning);
assert_eq!(issues[0].category, "track-mappings");
}
#[test]
fn test_check_track_mappings_none_mapped() {
let song = make_song("test-song", &["guitar", "bass"]);
let mappings = HashMap::new();
let issues = check_track_mappings(&song, &mappings);
assert_eq!(issues.len(), 2);
}
#[test]
fn test_check_track_mappings_empty_song() {
let song = make_song("empty-song", &[]);
let mappings = make_mappings(&["guitar"]);
let issues = check_track_mappings(&song, &mappings);
assert!(issues.is_empty());
}
#[test]
fn test_check_track_mappings_extra_mappings_ok() {
let song = make_song("test-song", &["guitar"]);
let mappings = make_mappings(&["guitar", "bass", "click"]);
let issues = check_track_mappings(&song, &mappings);
assert!(issues.is_empty());
}
#[test]
fn test_check_all_track_mappings() {
let mut songs_map = HashMap::new();
songs_map.insert(
"song-a".to_string(),
make_song("song-a", &["guitar", "bass"]),
);
songs_map.insert(
"song-b".to_string(),
make_song("song-b", &["guitar", "click"]),
);
let songs = Songs::new(songs_map);
let mappings = make_mappings(&["guitar", "bass"]);
let report = check_all_track_mappings(&songs, &mappings);
assert_eq!(report.issues.len(), 1);
assert_eq!(report.issues[0].song_name, "song-b");
}
#[test]
fn test_verification_report_is_clean() {
let report = VerificationReport::default();
assert!(report.is_clean());
assert!(!report.has_errors());
}
#[test]
fn test_verification_report_has_errors() {
let mut report = VerificationReport::default();
report.issues.push(Issue {
severity: Severity::Warning,
category: "test",
song_name: "song".to_string(),
message: "warning".to_string(),
});
assert!(!report.has_errors());
report.issues.push(Issue {
severity: Severity::Error,
category: "test",
song_name: "song".to_string(),
message: "error".to_string(),
});
assert!(report.has_errors());
}
#[test]
fn test_verification_report_merge() {
let mut report_a = VerificationReport::default();
report_a.issues.push(Issue {
severity: Severity::Warning,
category: "a",
song_name: "song".to_string(),
message: "issue a".to_string(),
});
let mut report_b = VerificationReport::default();
report_b.issues.push(Issue {
severity: Severity::Error,
category: "b",
song_name: "song".to_string(),
message: "issue b".to_string(),
});
report_a.merge(report_b);
assert_eq!(report_a.issues.len(), 2);
assert!(report_a.has_errors());
}
fn make_songs(entries: &[(&str, &[&str])]) -> Songs {
let mut map = HashMap::new();
for (name, tracks) in entries {
map.insert(name.to_string(), make_song(name, tracks));
}
Songs::new(map)
}
#[test]
fn test_warn_unmapped_tracks_all_mapped() {
let song = make_song("test-song", &["guitar", "bass"]);
let mappings = make_mappings(&["guitar", "bass"]);
warn_unmapped_tracks(&song, &mappings);
}
#[test]
fn test_warn_unmapped_tracks_some_unmapped() {
let song = make_song("test-song", &["guitar", "bass", "click"]);
let mappings = make_mappings(&["guitar"]);
warn_unmapped_tracks(&song, &mappings);
}
#[test]
fn test_warn_unmapped_tracks_none_mapped() {
let song = make_song("test-song", &["guitar", "bass"]);
warn_unmapped_tracks(&song, &HashMap::new());
}
#[test]
fn test_warn_unmapped_tracks_empty_tracks() {
let song = make_song("test-song", &[]);
warn_unmapped_tracks(&song, &HashMap::new());
}
#[test]
fn test_print_report_clean() {
let songs = make_songs(&[("song-a", &["guitar"]), ("song-b", &["bass"])]);
let report = VerificationReport::default();
print_report(&report, &songs);
}
#[test]
fn test_print_report_warnings_only() {
let songs = make_songs(&[("song-a", &["guitar"])]);
let report = VerificationReport {
issues: vec![Issue {
severity: Severity::Warning,
category: "track-mappings",
song_name: "song-a".to_string(),
message: "track \"guitar\" has no mapping".to_string(),
}],
};
print_report(&report, &songs);
}
#[test]
fn test_print_report_errors_only() {
let songs = make_songs(&[("song-a", &["guitar"])]);
let report = VerificationReport {
issues: vec![Issue {
severity: Severity::Error,
category: "track-mappings",
song_name: "song-a".to_string(),
message: "track \"guitar\" has no mapping".to_string(),
}],
};
print_report(&report, &songs);
}
#[test]
fn test_print_report_mixed_songs() {
let songs = make_songs(&[("song-a", &["guitar"]), ("song-b", &["bass"])]);
let report = VerificationReport {
issues: vec![Issue {
severity: Severity::Warning,
category: "track-mappings",
song_name: "song-a".to_string(),
message: "unmapped track".to_string(),
}],
};
print_report(&report, &songs);
}
#[test]
fn test_print_report_mixed_severities_same_song() {
let songs = make_songs(&[("song-a", &["guitar", "bass"])]);
let report = VerificationReport {
issues: vec![
Issue {
severity: Severity::Warning,
category: "track-mappings",
song_name: "song-a".to_string(),
message: "warning issue".to_string(),
},
Issue {
severity: Severity::Error,
category: "track-mappings",
song_name: "song-a".to_string(),
message: "error issue".to_string(),
},
],
};
print_report(&report, &songs);
}
}