use acorde_core::{
Command, FingeringSelectionPolicy, PlaybackOptions, Score, ScoreEngine, SetTabPositionCmd,
TabPosition,
};
use acorde_io::{Diagnostic, DiagnosticSeverity, ImportReport};
use clap::{Parser, Subcommand, ValueEnum};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const MAX_PLAYBACK_JSON_BYTES: usize = 64 * 1024 * 1024;
const RENDER_REPORT_SCHEMA_VERSION: u32 = 1;
const PRINT_REPORT_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Copy, Debug, ValueEnum)]
enum PrintPresetArg {
A4Score,
LetterScore,
A4Part,
LetterPart,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum PrintFinalPagePolicyArg {
AllowSingleSystem,
Balance,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum PrintNotationBreakPolicyArg {
Preserve,
KeepVoltaTogether,
KeepRepeatsTogether,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum PrintPickupPolicyArg {
Auto,
Preserve,
DetectFirstMeasure,
}
struct PrintReportOptions<'a> {
preset: PrintPresetArg,
part: Option<usize>,
measures_per_system: usize,
systems_per_page: Option<usize>,
title_page: bool,
running_title: Option<&'a str>,
header_text: Option<&'a str>,
footer_text: Option<&'a str>,
page_number_in_footer: bool,
show_part_names: bool,
fail_on_issues: bool,
scale: f32,
first_system_measures: Option<usize>,
final_page_policy: PrintFinalPagePolicyArg,
notation_break_policy: PrintNotationBreakPolicyArg,
pickup_policy: PrintPickupPolicyArg,
}
#[derive(Debug, Serialize)]
struct PrintReportSummary {
print_report_schema_version: u32,
import_report_schema_version: u32,
score_schema_version: u32,
input_format: String,
input_path: String,
import_warning_count: usize,
import_error_count: usize,
import_loss_count: usize,
import_diagnostics: Vec<acorde_io::Diagnostic>,
renderer_issues: Vec<acorde_render_svg::RenderPreflightIssue>,
layout: acorde_layout::PrintLayoutResult,
}
#[derive(Parser)]
#[command(
name = "score",
about = "Music score format conversion and inspection tool"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Convert {
input: PathBuf,
output: PathBuf,
},
Render {
input: PathBuf,
output: PathBuf,
#[arg(long, default_value_t = 900.0)]
width: f32,
#[arg(long, default_value_t = 24.0)]
staff_size: f32,
#[arg(long, default_value_t = 4)]
measures_per_system: usize,
#[arg(long)]
no_interactive: bool,
},
RenderReport {
input: PathBuf,
output: PathBuf,
#[arg(long, default_value_t = 900.0)]
width: f32,
#[arg(long, default_value_t = 24.0)]
staff_size: f32,
#[arg(long, default_value_t = 4)]
measures_per_system: usize,
#[arg(long)]
no_interactive: bool,
#[arg(long)]
fail_on_issues: bool,
},
PrintReport {
input: PathBuf,
#[arg(long, value_enum, default_value_t = PrintPresetArg::A4Score)]
preset: PrintPresetArg,
#[arg(long)]
part: Option<usize>,
#[arg(long, default_value_t = 4)]
measures_per_system: usize,
#[arg(long)]
systems_per_page: Option<usize>,
#[arg(long)]
title_page: bool,
#[arg(long)]
running_title: Option<String>,
#[arg(long)]
header_text: Option<String>,
#[arg(long)]
footer_text: Option<String>,
#[arg(long)]
page_number_in_footer: bool,
#[arg(long)]
no_part_names: bool,
#[arg(long)]
fail_on_issues: bool,
#[arg(long, default_value_t = 1.0)]
scale: f32,
#[arg(long)]
first_system_measures: Option<usize>,
#[arg(long, value_enum, default_value_t = PrintFinalPagePolicyArg::AllowSingleSystem)]
final_page_policy: PrintFinalPagePolicyArg,
#[arg(long, value_enum, default_value_t = PrintNotationBreakPolicyArg::Preserve)]
notation_break_policy: PrintNotationBreakPolicyArg,
#[arg(long, value_enum, default_value_t = PrintPickupPolicyArg::Auto)]
pickup_policy: PrintPickupPolicyArg,
},
Info {
input: PathBuf,
},
Validate {
input: PathBuf,
},
Report {
input: PathBuf,
},
Preflight {
input: PathBuf,
#[arg(long)]
fail_on_issues: bool,
},
Analyze {
input: PathBuf,
},
Benchmark {
manifest: PathBuf,
#[arg(long)]
fail_on_mismatch: bool,
#[arg(long)]
expected_fingerprint: Option<String>,
},
Extract {
input: PathBuf,
output: PathBuf,
#[arg(short, long)]
part: usize,
},
Transpose {
input: PathBuf,
output: PathBuf,
#[arg(short, long)]
semitones: i8,
},
Normalize {
input: PathBuf,
output: PathBuf,
},
TabPosition {
input: PathBuf,
output: PathBuf,
#[arg(long)]
part: usize,
#[arg(long, default_value_t = 0)]
staff: usize,
#[arg(long)]
measure: usize,
#[arg(long, default_value_t = 0)]
voice: usize,
#[arg(long)]
note: usize,
#[arg(long, conflicts_with = "clear")]
string: Option<u8>,
#[arg(long, conflicts_with = "clear")]
fret: Option<u8>,
#[arg(long, conflicts_with_all = ["string", "fret"])]
clear: bool,
},
AutoTab {
input: PathBuf,
output: PathBuf,
},
AutoTabReport {
input: PathBuf,
output: PathBuf,
},
TabPerformanceReport {
input: PathBuf,
#[arg(long)]
bpm: Option<u16>,
#[arg(long)]
fail_on_diagnostics: bool,
},
PlaybackReport {
input: PathBuf,
#[arg(long)]
bpm: Option<u16>,
#[arg(long, requires = "loop_end")]
loop_start: Option<usize>,
#[arg(long, requires = "loop_start")]
loop_end: Option<usize>,
},
PlaybackCompare {
expected: PathBuf,
actual: PathBuf,
#[arg(long, default_value_t = 0.005)]
start_tolerance: f64,
#[arg(long, default_value_t = 0.005)]
duration_tolerance: f64,
#[arg(long)]
fail_on_mismatch: bool,
},
FingeringReport {
input: PathBuf,
#[arg(long, default_value = "source-order")]
policy: String,
},
ExportReport {
input: PathBuf,
output: PathBuf,
},
CompatibilityReport {
source: PathBuf,
candidate: PathBuf,
#[arg(long)]
fail_on_differences: bool,
#[arg(long)]
fail_on_loss: bool,
},
}
fn main() {
let cli = Cli::parse();
let result = match &cli.command {
Commands::Convert { input, output } => cmd_convert(input, output),
Commands::Render {
input,
output,
width,
staff_size,
measures_per_system,
no_interactive,
} => cmd_render(
input,
output,
*width,
*staff_size,
*measures_per_system,
!*no_interactive,
),
Commands::RenderReport {
input,
output,
width,
staff_size,
measures_per_system,
no_interactive,
fail_on_issues,
} => cmd_render_report(
input,
output,
*width,
*staff_size,
*measures_per_system,
!*no_interactive,
*fail_on_issues,
),
Commands::PrintReport {
input,
preset,
part,
measures_per_system,
systems_per_page,
title_page,
running_title,
header_text,
footer_text,
page_number_in_footer,
no_part_names,
fail_on_issues,
scale,
first_system_measures,
final_page_policy,
notation_break_policy,
pickup_policy,
} => cmd_print_report(
input,
PrintReportOptions {
preset: *preset,
part: *part,
measures_per_system: *measures_per_system,
systems_per_page: *systems_per_page,
title_page: *title_page,
running_title: running_title.as_deref(),
header_text: header_text.as_deref(),
footer_text: footer_text.as_deref(),
page_number_in_footer: *page_number_in_footer,
show_part_names: !*no_part_names,
fail_on_issues: *fail_on_issues,
scale: *scale,
first_system_measures: *first_system_measures,
final_page_policy: *final_page_policy,
notation_break_policy: *notation_break_policy,
pickup_policy: *pickup_policy,
},
),
Commands::Info { input } => cmd_info(input),
Commands::Validate { input } => cmd_validate(input),
Commands::Report { input } => cmd_report(input),
Commands::Preflight {
input,
fail_on_issues,
} => cmd_preflight(input, *fail_on_issues),
Commands::Analyze { input } => cmd_analyze(input),
Commands::Benchmark {
manifest,
fail_on_mismatch,
expected_fingerprint,
} => cmd_benchmark(manifest, *fail_on_mismatch, expected_fingerprint.as_deref()),
Commands::Extract {
input,
output,
part,
} => cmd_extract(input, output, *part),
Commands::Transpose {
input,
output,
semitones,
} => cmd_transpose(input, output, *semitones),
Commands::Normalize { input, output } => cmd_normalize(input, output),
Commands::TabPosition {
input,
output,
part,
staff,
measure,
voice,
note,
string,
fret,
clear,
} => cmd_tab_position(
input, output, *part, *staff, *measure, *voice, *note, *string, *fret, *clear,
),
Commands::AutoTab { input, output } => cmd_auto_tab(input, output),
Commands::AutoTabReport { input, output } => cmd_auto_tab_report(input, output),
Commands::TabPerformanceReport {
input,
bpm,
fail_on_diagnostics,
} => cmd_tab_performance_report(input, *bpm, *fail_on_diagnostics),
Commands::PlaybackReport {
input,
bpm,
loop_start,
loop_end,
} => cmd_playback_report(input, *bpm, *loop_start, *loop_end),
Commands::PlaybackCompare {
expected,
actual,
start_tolerance,
duration_tolerance,
fail_on_mismatch,
} => cmd_playback_compare(
expected,
actual,
*start_tolerance,
*duration_tolerance,
*fail_on_mismatch,
),
Commands::FingeringReport { input, policy } => cmd_fingering_report(input, policy),
Commands::ExportReport { input, output } => cmd_export_report(input, output),
Commands::CompatibilityReport {
source,
candidate,
fail_on_differences,
fail_on_loss,
} => cmd_compatibility_report(source, candidate, *fail_on_differences, *fail_on_loss),
};
if let Err(e) = result {
eprintln!("error: {e}");
std::process::exit(1);
}
}
fn parse_score(path: &Path) -> Result<Score, String> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let data = std::fs::read(path).map_err(|e| format!("cannot read '{}': {e}", path.display()))?;
match ext.as_str() {
"xml" | "musicxml" => {
let xml = String::from_utf8(data)
.map_err(|e| format!("invalid UTF-8 in '{}': {e}", path.display()))?;
acorde_io::parse_musicxml(&xml).map_err(|e| e.to_string())
}
"mxl" => acorde_io::parse_mxl(&data).map_err(|e| e.to_string()),
"mid" | "midi" => acorde_io::parse_midi(&data).map_err(|e| e.to_string()),
"abc" => {
let text = String::from_utf8(data)
.map_err(|e| format!("invalid UTF-8 in '{}': {e}", path.display()))?;
acorde_io::parse_abc(&text).map_err(|e| e.to_string())
}
"mei" => {
let text = String::from_utf8(data)
.map_err(|e| format!("invalid UTF-8 in '{}': {e}", path.display()))?;
acorde_io::parse_mei(&text).map_err(|e| e.to_string())
}
"mscz" => acorde_io::parse_mscz(&data).map_err(|e| e.to_string()),
"mscx" => {
let xml = String::from_utf8(data)
.map_err(|e| format!("invalid UTF-8 in '{}': {e}", path.display()))?;
acorde_io::parse_mscx(&xml).map_err(|e| e.to_string())
}
other => Err(format!("unsupported input format: '.{other}'")),
}
}
fn parse_report(path: &Path) -> Result<ImportReport, String> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let data = std::fs::read(path).map_err(|e| format!("cannot read '{}': {e}", path.display()))?;
match ext.as_str() {
"xml" | "musicxml" => {
let text = String::from_utf8(data).map_err(|e| format!("invalid UTF-8: {e}"))?;
acorde_io::parse_musicxml_with_report(&text).map_err(|e| e.to_string())
}
"mxl" => acorde_io::parse_mxl_with_report(&data).map_err(|e| e.to_string()),
"mid" | "midi" => acorde_io::parse_midi_with_report(&data).map_err(|e| e.to_string()),
"abc" => {
let text = String::from_utf8(data).map_err(|e| format!("invalid UTF-8: {e}"))?;
acorde_io::parse_abc_with_report(&text).map_err(|e| e.to_string())
}
"mei" => {
let text = String::from_utf8(data).map_err(|e| format!("invalid UTF-8: {e}"))?;
acorde_io::parse_mei_with_report(&text).map_err(|e| e.to_string())
}
"mscz" => acorde_io::parse_mscz_with_report(&data).map_err(|e| e.to_string()),
"mscx" => {
let text = String::from_utf8(data).map_err(|e| format!("invalid UTF-8: {e}"))?;
acorde_io::parse_mscx_with_report(&text).map_err(|e| e.to_string())
}
other => Err(format!("unsupported input format: '.{other}'")),
}
}
fn cmd_report(input: &Path) -> Result<(), String> {
let report = parse_report(input)?;
let json = serde_json::to_string_pretty(&report)
.map_err(|e| format!("report serialization failed: {e}"))?;
println!("{json}");
Ok(())
}
fn cmd_preflight(input: &Path, fail_on_issues: bool) -> Result<(), String> {
let score = parse_score(input)?;
let issues = acorde_render_svg::render_preflight(&score);
serde_json::to_writer_pretty(std::io::stdout(), &issues)
.map_err(|e| format!("preflight serialization failed: {e}"))?;
println!();
if fail_on_issues && !issues.is_empty() {
return Err(format!(
"renderer preflight found {} issue(s)",
issues.len()
));
}
Ok(())
}
fn cmd_render(
input: &Path,
output: &Path,
width: f32,
staff_size: f32,
measures_per_system: usize,
interactive: bool,
) -> Result<(), String> {
let score = parse_score(input)?;
let options = acorde_render_svg::SvgRenderOptions {
width,
staff_size,
measures_per_system,
interactive,
};
let svg = acorde_render_svg::render_svg(&score, &options)
.map_err(|e| format!("SVG rendering failed: {e}"))?;
std::fs::write(output, svg).map_err(|e| format!("cannot write '{}': {e}", output.display()))?;
println!("rendered '{}' to '{}'", input.display(), output.display());
Ok(())
}
#[derive(Debug, Serialize)]
struct RenderReportSummary {
render_report_schema_version: u32,
schema_version: u32,
input_format: String,
input_path: String,
output_path: String,
rendered: bool,
svg_byte_count: usize,
svg_fingerprint: Option<String>,
render_error: Option<String>,
import_warning_count: usize,
import_error_count: usize,
import_loss_count: usize,
import_diagnostics: Vec<acorde_io::Diagnostic>,
renderer_issues: Vec<acorde_render_svg::RenderPreflightIssue>,
}
fn render_report_summary(
input: &Path,
output: &Path,
width: f32,
staff_size: f32,
measures_per_system: usize,
interactive: bool,
) -> Result<RenderReportSummary, String> {
let import = parse_report(input)?;
let renderer_issues = acorde_render_svg::render_preflight(&import.score);
let options = acorde_render_svg::SvgRenderOptions {
width,
staff_size,
measures_per_system,
interactive,
};
let (rendered, svg_byte_count, svg_fingerprint, render_error) =
match acorde_render_svg::render_svg(&import.score, &options) {
Ok(svg) => {
let byte_count = svg.len();
let fingerprint = bytes_fingerprint(svg.as_bytes());
std::fs::write(output, svg)
.map_err(|e| format!("cannot write '{}': {e}", output.display()))?;
(true, byte_count, Some(fingerprint), None)
}
Err(error) => (false, 0, None, Some(error.to_string())),
};
let import_warning_count = import.warning_count();
let import_error_count = import.error_count();
let import_loss_count = import.loss_count();
Ok(RenderReportSummary {
render_report_schema_version: RENDER_REPORT_SCHEMA_VERSION,
schema_version: import.schema_version,
input_format: import.format,
input_path: input.display().to_string(),
output_path: output.display().to_string(),
rendered,
svg_byte_count,
svg_fingerprint,
render_error,
import_warning_count,
import_error_count,
import_loss_count,
import_diagnostics: import.diagnostics,
renderer_issues,
})
}
fn cmd_render_report(
input: &Path,
output: &Path,
width: f32,
staff_size: f32,
measures_per_system: usize,
interactive: bool,
fail_on_issues: bool,
) -> Result<(), String> {
let report = render_report_summary(
input,
output,
width,
staff_size,
measures_per_system,
interactive,
)?;
let has_issues = !report.import_diagnostics.is_empty()
|| !report.renderer_issues.is_empty()
|| report.render_error.is_some();
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| format!("render report serialization failed: {e}"))?
);
if fail_on_issues && has_issues {
return Err("render report found import or renderer issue(s)".to_string());
}
Ok(())
}
fn cmd_print_report(input: &Path, options: PrintReportOptions<'_>) -> Result<(), String> {
let import = parse_report(input)?;
let config = build_print_config(&options)?;
let layout = acorde_layout::compute_print_layout(&import.score, &config)
.map_err(|e| format!("print layout failed: {e}"))?;
let renderer_issues = acorde_render_svg::render_preflight(&import.score);
let has_issues = !import.diagnostics.is_empty() || !renderer_issues.is_empty();
let import_warning_count = import.warning_count();
let import_error_count = import.error_count();
let import_loss_count = import.loss_count();
let report = PrintReportSummary {
print_report_schema_version: PRINT_REPORT_SCHEMA_VERSION,
import_report_schema_version: import.schema_version,
score_schema_version: import.score.schema_version,
input_format: import.format,
input_path: input.display().to_string(),
import_warning_count,
import_error_count,
import_loss_count,
import_diagnostics: import.diagnostics,
renderer_issues,
layout,
};
serde_json::to_writer_pretty(std::io::stdout(), &report)
.map_err(|e| format!("print report serialization failed: {e}"))?;
println!();
if options.fail_on_issues && has_issues {
return Err("print report found import or renderer issue(s)".to_string());
}
Ok(())
}
fn build_print_config(
options: &PrintReportOptions<'_>,
) -> Result<acorde_layout::PrintConfig, String> {
let preset = match options.preset {
PrintPresetArg::A4Score if options.part.is_some() => {
return Err("--part requires an extracted-part preset".to_string());
}
PrintPresetArg::LetterScore if options.part.is_some() => {
return Err("--part requires an extracted-part preset".to_string());
}
PrintPresetArg::A4Score => acorde_layout::PrintPreset::A4Score,
PrintPresetArg::LetterScore => acorde_layout::PrintPreset::LetterScore,
PrintPresetArg::A4Part => acorde_layout::PrintPreset::A4Part {
part_index: options
.part
.ok_or("--part is required for --preset a4-part")?,
},
PrintPresetArg::LetterPart => acorde_layout::PrintPreset::LetterPart {
part_index: options
.part
.ok_or("--part is required for --preset letter-part")?,
},
};
let mut config = preset.config_with_title_page(options.title_page);
config.measures_per_system = options.measures_per_system;
config.systems_per_page = options.systems_per_page;
config.scale = options.scale;
config.first_system_measures = options.first_system_measures;
config.final_page_policy = match options.final_page_policy {
PrintFinalPagePolicyArg::AllowSingleSystem => {
acorde_layout::FinalPagePolicy::AllowSingleSystem
}
PrintFinalPagePolicyArg::Balance => acorde_layout::FinalPagePolicy::Balance,
};
config.notation_break_policy = match options.notation_break_policy {
PrintNotationBreakPolicyArg::Preserve => acorde_layout::NotationBreakPolicy::Preserve,
PrintNotationBreakPolicyArg::KeepVoltaTogether => {
acorde_layout::NotationBreakPolicy::KeepVoltaTogether
}
PrintNotationBreakPolicyArg::KeepRepeatsTogether => {
acorde_layout::NotationBreakPolicy::KeepRepeatsTogether
}
};
config.pickup_policy = match options.pickup_policy {
PrintPickupPolicyArg::Auto => acorde_layout::PickupPolicy::Auto,
PrintPickupPolicyArg::Preserve => acorde_layout::PickupPolicy::Preserve,
PrintPickupPolicyArg::DetectFirstMeasure => acorde_layout::PickupPolicy::DetectFirstMeasure,
};
config.publication.running_title = options.running_title.map(str::to_owned);
config.publication.header_text = options.header_text.map(str::to_owned);
config.publication.footer_text = options.footer_text.map(str::to_owned);
config.publication.page_number_in_footer = options.page_number_in_footer;
config.publication.show_part_names = options.show_part_names;
Ok(config)
}
fn cmd_analyze(input: &Path) -> Result<(), String> {
let score = parse_score(input)?;
let analysis = acorde_analysis::analyze_score(&score);
let json = serde_json::to_string_pretty(&analysis)
.map_err(|e| format!("analysis serialization failed: {e}"))?;
println!("{json}");
Ok(())
}
#[derive(Debug, Serialize, Deserialize)]
struct BenchmarkManifest {
schema_version: u32,
corpus_id: String,
corpus_version: String,
license: String,
cases: Vec<BenchmarkManifestCase>,
}
#[derive(Debug, Serialize, Deserialize)]
struct BenchmarkManifestCase {
name: String,
input: PathBuf,
coverage: Vec<String>,
provenance: String,
#[serde(default)]
expected: acorde_analysis::BenchmarkExpectation,
}
#[derive(Debug, Serialize)]
struct BenchmarkCorpusMetadata {
schema_version: u32,
corpus_id: String,
corpus_version: String,
license: String,
fingerprint: String,
cases: Vec<BenchmarkCorpusCaseMetadata>,
}
#[derive(Debug, Serialize)]
struct BenchmarkCorpusCaseMetadata {
name: String,
coverage: Vec<String>,
provenance: String,
}
#[derive(Debug, Serialize)]
struct BenchmarkOutput {
corpus: BenchmarkCorpusMetadata,
report: acorde_analysis::BenchmarkSuiteReport,
}
fn cmd_benchmark(
manifest: &Path,
fail_on_mismatch: bool,
expected_fingerprint: Option<&str>,
) -> Result<(), String> {
let text = std::fs::read_to_string(manifest)
.map_err(|e| format!("cannot read '{}': {e}", manifest.display()))?;
let manifest_data: BenchmarkManifest = serde_json::from_str(&text)
.map_err(|e| format!("invalid benchmark manifest '{}': {e}", manifest.display()))?;
let base_dir = manifest.parent().unwrap_or_else(|| Path::new("."));
let fingerprint = benchmark_fingerprint(&manifest_data, base_dir)?;
let mut scores = Vec::with_capacity(manifest_data.cases.len());
for case in &manifest_data.cases {
scores.push(parse_score(&base_dir.join(&case.input))?);
}
let cases: Vec<_> = manifest_data
.cases
.iter()
.zip(scores.iter())
.map(|(case, score)| acorde_analysis::BenchmarkCase {
name: &case.name,
score,
expected: case.expected,
})
.collect();
let report = acorde_analysis::run_benchmark_suite(&cases);
drop(cases);
let output = BenchmarkOutput {
corpus: BenchmarkCorpusMetadata {
schema_version: manifest_data.schema_version,
corpus_id: manifest_data.corpus_id,
corpus_version: manifest_data.corpus_version,
license: manifest_data.license,
fingerprint,
cases: manifest_data
.cases
.into_iter()
.map(|case| BenchmarkCorpusCaseMetadata {
name: case.name,
coverage: case.coverage,
provenance: case.provenance,
})
.collect(),
},
report,
};
if let Some(expected) = expected_fingerprint
&& expected != output.corpus.fingerprint
{
return Err(format!(
"benchmark fingerprint mismatch: expected '{expected}', found '{}'",
output.corpus.fingerprint
));
}
let failed_case_count = output.report.failed_case_count;
let json = serde_json::to_string_pretty(&output)
.map_err(|e| format!("benchmark serialization failed: {e}"))?;
println!("{json}");
if fail_on_mismatch && failed_case_count > 0 {
return Err(format!(
"benchmark failed: {} of {} case(s) contain mismatches",
failed_case_count, output.report.case_count
));
}
Ok(())
}
fn benchmark_fingerprint(manifest: &BenchmarkManifest, base_dir: &Path) -> Result<String, String> {
let manifest_bytes = serde_json::to_vec(manifest)
.map_err(|e| format!("benchmark manifest serialization failed: {e}"))?;
let mut hash = 0xcbf29ce484222325_u64;
for byte in manifest_bytes {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x100000001b3);
}
for case in &manifest.cases {
let input_path = base_dir.join(&case.input);
let bytes = std::fs::read(&input_path)
.map_err(|e| format!("cannot read '{}': {e}", input_path.display()))?;
for byte in bytes {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x100000001b3);
}
}
Ok(format!("fnv1a64-{hash:016x}"))
}
fn bytes_fingerprint(bytes: &[u8]) -> String {
let mut hash = 0xcbf29ce484222325_u64;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
format!("fnv1a64-{hash:016x}")
}
fn write_score(score: &Score, output: &Path) -> Result<(), String> {
let diagnostics = write_score_with_report(score, output)?;
print_conversion_diagnostics("export", &diagnostics);
Ok(())
}
fn write_score_with_report(score: &Score, output: &Path) -> Result<Vec<Diagnostic>, String> {
let ext = output
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"xml" | "musicxml" => {
let report =
acorde_io::serialize_musicxml_with_report(score).map_err(|e| e.to_string())?;
std::fs::write(output, report.output)
.map_err(|e| format!("cannot write '{}': {e}", output.display()))?;
Ok(report.diagnostics)
}
"mid" | "midi" => {
let report = acorde_io::serialize_midi_with_report(score).map_err(|e| e.to_string())?;
std::fs::write(output, report.output)
.map_err(|e| format!("cannot write '{}': {e}", output.display()))?;
Ok(report.diagnostics)
}
"abc" => {
let report = acorde_io::serialize_abc_with_report(score).map_err(|e| e.to_string())?;
std::fs::write(output, report.output)
.map_err(|e| format!("cannot write '{}': {e}", output.display()))?;
Ok(report.diagnostics)
}
"mei" => {
let report = acorde_io::serialize_mei_with_report(score).map_err(|e| e.to_string())?;
std::fs::write(output, report.output)
.map_err(|e| format!("cannot write '{}': {e}", output.display()))?;
Ok(report.diagnostics)
}
"mscx" => {
let report = acorde_io::serialize_mscx_with_report(score).map_err(|e| e.to_string())?;
std::fs::write(output, report.output)
.map_err(|e| format!("cannot write '{}': {e}", output.display()))?;
Ok(report.diagnostics)
}
"mscz" => {
let report = acorde_io::serialize_mscz_with_report(score).map_err(|e| e.to_string())?;
std::fs::write(output, report.output)
.map_err(|e| format!("cannot write '{}': {e}", output.display()))?;
Ok(report.diagnostics)
}
other => Err(format!("unsupported output format: '.{other}'")),
}
}
fn print_conversion_diagnostics(phase: &str, diagnostics: &[Diagnostic]) {
for diagnostic in diagnostics {
let severity = match diagnostic.severity {
DiagnosticSeverity::Info => "info",
DiagnosticSeverity::Warning => "warning",
DiagnosticSeverity::Error => "error",
};
let location = diagnostic
.source_location
.as_deref()
.map(|value| format!(" at {value}"))
.unwrap_or_default();
let reason = diagnostic
.loss_reason
.as_deref()
.or(diagnostic.preserved_value.as_deref())
.unwrap_or("no additional detail");
eprintln!(
"{phase} diagnostic [{severity}] {}{location}: {reason}",
diagnostic.code
);
}
}
fn cmd_convert(input: &Path, output: &Path) -> Result<(), String> {
let import = parse_report(input)?;
print_conversion_diagnostics("import", &import.diagnostics);
let export_diagnostics = write_score_with_report(&import.score, output)?;
print_conversion_diagnostics("export", &export_diagnostics);
println!("wrote '{}'", output.display());
Ok(())
}
fn cmd_info(input: &Path) -> Result<(), String> {
let score = parse_score(input)?;
let stats = score.statistics();
let ts = &score.settings.time_signature;
println!("title: {}", score.metadata.title);
println!("parts: {}", stats.part_count);
println!("measures: {}", stats.measure_count);
println!(
"notes: {} (rests: {})",
stats.note_count, stats.rest_count
);
println!("tempo: {} BPM", score.settings.tempo_bpm);
println!("time: {}/{}", ts.numerator, ts.denominator);
println!("duration: {:.1}s (estimate)", stats.estimated_duration_secs);
if !score.metadata.composer.is_empty() {
println!("composer: {}", score.metadata.composer);
}
Ok(())
}
fn cmd_validate(input: &Path) -> Result<(), String> {
let score = parse_score(input)?;
let report = acorde_core::validate(&score);
for w in &report.warnings {
match w {
acorde_core::ValidationWarning::IncompleteBar {
part,
staff,
measure,
expected_beats,
actual_beats,
} => eprintln!(
"warning: part {} staff {} measure {}: incomplete bar ({:.2}/{:.2} beats)",
part + 1,
staff + 1,
measure + 1,
actual_beats,
expected_beats
),
acorde_core::ValidationWarning::OverlappingVolta { part, staff } => eprintln!(
"warning: part {} staff {}: overlapping volta brackets",
part + 1,
staff + 1
),
acorde_core::ValidationWarning::EmptyPart { part } => {
eprintln!("warning: part {} has no notes", part + 1)
}
acorde_core::ValidationWarning::DuplicateRehearsalMark { mark } => {
eprintln!("warning: rehearsal mark '{}' appears more than once", mark)
}
acorde_core::ValidationWarning::MeasureRepeatContentDiffers {
part,
staff,
measure,
source,
} => eprintln!(
"warning: part {} staff {} measure {}: measure repeat differs from measure {}",
part + 1,
staff + 1,
measure + 1,
source + 1
),
}
}
if report.errors.is_empty() {
println!("OK: '{}'", input.display());
Ok(())
} else {
for e in &report.errors {
match e {
acorde_core::ValidationError::EmptyScore => {
eprintln!("score has no parts")
}
acorde_core::ValidationError::PartWithoutStaves { part } => {
eprintln!("part {} has no staves", part + 1)
}
acorde_core::ValidationError::StaffWithoutMeasures { part, staff } => {
eprintln!("part {} staff {} has no measures", part + 1, staff + 1)
}
acorde_core::ValidationError::MeasureCountMismatch {
part,
staff,
expected,
found,
} => eprintln!(
"part {} staff {}: expected {} measures, found {}",
part + 1,
staff + 1,
expected,
found
),
acorde_core::ValidationError::InvalidTimeSignature {
part,
staff,
measure,
numerator,
denominator,
} => eprintln!(
"part {} staff {} measure {}: invalid time signature {}/{}",
part + 1,
staff + 1,
measure + 1,
numerator,
denominator
),
acorde_core::ValidationError::InvalidLyricVerse {
part,
staff,
measure,
voice,
note,
verse,
} => eprintln!(
"part {} staff {} measure {} voice {} note {}: invalid lyric verse {}",
part + 1,
staff + 1,
measure + 1,
voice + 1,
note + 1,
verse
),
acorde_core::ValidationError::InvalidMeasureRepeat {
part,
staff,
measure,
count,
} => eprintln!(
"part {} staff {} measure {}: invalid {}-measure repeat",
part + 1,
staff + 1,
measure + 1,
count
),
acorde_core::ValidationError::InvalidMeasureLength {
part,
staff,
measure,
numerator,
denominator,
} => eprintln!(
"part {} staff {} measure {}: invalid measure length {}/{}",
part + 1,
staff + 1,
measure + 1,
numerator,
denominator
),
acorde_core::ValidationError::BeatCount {
part,
staff,
measure,
voice,
expected_beats,
found_beats,
} => eprintln!(
"part {} staff {} measure {} voice {}: expected {:.2} beats, found {:.2}",
part + 1,
staff + 1,
measure + 1,
voice + 1,
expected_beats,
found_beats
),
acorde_core::ValidationError::OutOfRange {
part_index,
staff_index,
measure_index,
note_index,
pitch_midi,
instrument_range,
} => eprintln!(
"part {} staff {} measure {} note {}: pitch MIDI {} out of instrument range {}–{}",
part_index + 1,
staff_index + 1,
measure_index + 1,
note_index + 1,
pitch_midi,
instrument_range.0,
instrument_range.1
),
acorde_core::ValidationError::InvalidTablature {
part,
staff,
reason,
} => eprintln!(
"part {} staff {}: invalid tablature metadata: {:?}",
part + 1,
staff + 1,
reason
),
acorde_core::ValidationError::InvalidStaffPresentation {
part,
staff,
reason,
} => eprintln!(
"part {} staff {}: invalid staff presentation: {:?}",
part + 1,
staff + 1,
reason
),
acorde_core::ValidationError::InvalidInstrumentDefinition { part, reason } => {
eprintln!(
"part {}: invalid instrument definition: {:?}",
part + 1,
reason
)
}
acorde_core::ValidationError::InvalidPercussionInstrument {
part,
instrument,
id,
reason,
} => eprintln!(
"part {} percussion instrument {} ('{}'): invalid definition: {:?}",
part + 1,
instrument + 1,
id,
reason
),
acorde_core::ValidationError::InvalidScoreView { index, id, reason } => {
eprintln!(
"score view {} ('{}'): invalid definition: {:?}",
index + 1,
id,
reason
)
}
acorde_core::ValidationError::InvalidScoreStyleOverride { property, value } => {
eprintln!(
"score style default {:?}: value {} must be finite and within 0.05..=64",
property, value
)
}
acorde_core::ValidationError::InvalidObjectStyleOverride { index, reason } => {
eprintln!(
"object style override {}: invalid definition: {:?}",
index + 1,
reason
)
}
acorde_core::ValidationError::TabPositionOutOfRange {
part,
staff,
measure,
voice,
note,
string,
lines,
} => eprintln!(
"part {} staff {} measure {} voice {} note {}: tablature string {} exceeds {} lines",
part + 1,
staff + 1,
measure + 1,
voice + 1,
note + 1,
string,
lines
),
acorde_core::ValidationError::MicrotoneOutOfRange {
part,
staff,
measure,
voice,
note,
pitch,
microtone_cents,
} => eprintln!(
"part {} staff {} measure {} voice {} note {} pitch {}: microtone cents {} is outside -99..99",
part + 1,
staff + 1,
measure + 1,
voice + 1,
note + 1,
pitch + 1,
microtone_cents
),
acorde_core::ValidationError::InvalidGuitarBendCurve {
part,
staff,
measure,
voice,
note,
reason,
} => eprintln!(
"part {} staff {} measure {} voice {} note {}: invalid guitar bend curve ({reason:?})",
part + 1,
staff + 1,
measure + 1,
voice + 1,
note + 1
),
acorde_core::ValidationError::InvalidHarmonyRange {
part,
staff,
measure,
voice,
note,
end,
} => eprintln!(
"part {} staff {} measure {} voice {} note {}: harmony range ends at missing note {}:{}:{}:{}:{}",
part + 1,
staff + 1,
measure + 1,
voice + 1,
note + 1,
end.part + 1,
end.staff + 1,
end.measure + 1,
end.voice + 1,
end.note + 1
),
acorde_core::ValidationError::InvalidSpannerId { index, id } => eprintln!(
"notation spanner {} has an empty stable id ('{}')",
index + 1,
id
),
acorde_core::ValidationError::DuplicateSpannerId {
first,
duplicate,
id,
} => eprintln!(
"notation spanners {} and {} share stable id '{}'",
first + 1,
duplicate + 1,
id
),
acorde_core::ValidationError::InvalidSpannerEndpoint {
index,
id,
kind,
endpoint,
address,
} => eprintln!(
"notation spanner {} ('{}', {:?}) has a missing {:?} endpoint at {}:{}:{}:{}:{}",
index + 1,
id,
kind,
endpoint,
address.part + 1,
address.staff + 1,
address.measure + 1,
address.voice + 1,
address.note + 1
),
}
}
std::process::exit(1);
}
}
#[allow(clippy::too_many_arguments)]
fn cmd_tab_position(
input: &Path,
output: &Path,
part: usize,
staff: usize,
measure: usize,
voice: usize,
note: usize,
string: Option<u8>,
fret: Option<u8>,
clear: bool,
) -> Result<(), String> {
let mut score = parse_score(input)?;
let position = if clear {
None
} else {
let string = string.ok_or("--string is required unless --clear is used")?;
let fret = fret.ok_or("--fret is required unless --clear is used")?;
if string == 0 {
return Err("--string is one-based and must be at least 1".to_string());
}
Some(TabPosition { string, fret })
};
let command = Command::SetTabPosition(SetTabPositionCmd {
part_index: part,
staff_index: staff,
measure_index: measure,
voice,
note_index: note,
position,
});
let mut engine = ScoreEngine::new();
engine
.try_replace_score(score)
.map_err(|e| format!("cannot load score: {e}"))?;
engine
.apply(command)
.map_err(|e| format!("cannot edit score: {e}"))?;
score = engine.score;
write_score(&score, output)?;
println!("updated tablature position in '{}'", output.display());
Ok(())
}
fn cmd_auto_tab(input: &Path, output: &Path) -> Result<(), String> {
let mut score = parse_score(input)?;
let assigned = acorde_core::optimize_tablature_positions(&mut score);
write_score(&score, output)?;
println!(
"assigned optimized tablature positions for {} note(s) to '{}'",
assigned,
output.display()
);
Ok(())
}
#[derive(Debug, Serialize)]
struct AutoTabReport {
assigned_notes: usize,
chord_count: usize,
positioned_notes: usize,
unpositioned_notes: usize,
total_fret: u32,
maximum_fret: u8,
output: String,
}
fn cmd_auto_tab_report(input: &Path, output: &Path) -> Result<(), String> {
let mut score = parse_score(input)?;
let assigned_notes = acorde_core::optimize_tablature_positions(&mut score);
let mut report = AutoTabReport {
assigned_notes,
chord_count: 0,
positioned_notes: 0,
unpositioned_notes: 0,
total_fret: 0,
maximum_fret: 0,
output: output.display().to_string(),
};
for part in &score.parts {
for staff in &part.staves {
if staff.tablature.is_none() {
continue;
}
for measure in &staff.measures {
for voice in &measure.voices {
for note in voice {
if note.is_rest || note.pitches.is_empty() {
continue;
}
report.chord_count += if note.pitches.len() > 1 { 1 } else { 0 };
let positions = if !note.tab_positions.is_empty() {
note.tab_positions.as_slice()
} else {
note.tab_position.as_slice()
};
if positions.is_empty() {
report.unpositioned_notes += 1;
} else {
report.positioned_notes += 1;
for position in positions {
report.total_fret += u32::from(position.fret);
report.maximum_fret = report.maximum_fret.max(position.fret);
}
}
}
}
}
}
}
write_score(&score, output)?;
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| format!("tablature report serialization failed: {e}"))?
);
Ok(())
}
fn cmd_tab_performance_report(
input: &Path,
bpm: Option<u16>,
fail_on_diagnostics: bool,
) -> Result<(), String> {
let score = parse_score(input)?;
let options = PlaybackOptions {
bpm_override: bpm,
..PlaybackOptions::default()
};
let report = acorde_core::project_tablature_performance(&score, &options)
.map_err(|e| format!("tablature performance projection failed: {e}"))?;
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| format!("tablature performance report serialization failed: {e}"))?
);
if fail_on_diagnostics && !report.diagnostics.is_empty() {
return Err(format!(
"tablature performance report found {} diagnostic(s)",
report.diagnostics.len()
));
}
Ok(())
}
fn cmd_playback_report(
input: &Path,
bpm: Option<u16>,
loop_start: Option<usize>,
loop_end: Option<usize>,
) -> Result<(), String> {
let score = parse_score(input)?;
let events = playback_report_events(&score, bpm, loop_start, loop_end)?;
println!(
"{}",
serde_json::to_string_pretty(&events)
.map_err(|e| format!("playback report serialization failed: {e}"))?
);
Ok(())
}
fn playback_report_events(
score: &Score,
bpm: Option<u16>,
loop_start: Option<usize>,
loop_end: Option<usize>,
) -> Result<Vec<acorde_core::PlaybackEvent>, String> {
if let (Some(start), Some(end)) = (loop_start, loop_end) {
if start > end {
return Err("--loop-start must not exceed --loop-end".to_string());
}
}
let options = PlaybackOptions {
bpm_override: bpm,
loop_region: loop_start.zip(loop_end),
..PlaybackOptions::default()
};
acorde_core::to_playback_events_bounded(score, &options)
.map_err(|e| format!("playback report generation failed: {e}"))
}
fn read_playback_events(path: &Path) -> Result<Vec<acorde_core::PlaybackEvent>, String> {
let metadata =
std::fs::metadata(path).map_err(|e| format!("cannot inspect '{}': {e}", path.display()))?;
if metadata.len() > MAX_PLAYBACK_JSON_BYTES as u64 {
return Err(format!(
"playback event JSON '{}' exceeds {} bytes",
path.display(),
MAX_PLAYBACK_JSON_BYTES
));
}
let data = std::fs::read(path).map_err(|e| format!("cannot read '{}': {e}", path.display()))?;
if data.len() > MAX_PLAYBACK_JSON_BYTES {
return Err(format!(
"playback event JSON '{}' exceeds {} bytes",
path.display(),
MAX_PLAYBACK_JSON_BYTES
));
}
let text = String::from_utf8(data).map_err(|e| {
format!(
"invalid UTF-8 in playback event JSON '{}': {e}",
path.display()
)
})?;
serde_json::from_str(&text)
.map_err(|e| format!("invalid playback event JSON '{}': {e}", path.display()))
}
fn cmd_playback_compare(
expected_path: &Path,
actual_path: &Path,
start_tolerance: f64,
duration_tolerance: f64,
fail_on_mismatch: bool,
) -> Result<(), String> {
let expected = read_playback_events(expected_path)?;
let actual = read_playback_events(actual_path)?;
let report =
playback_comparison_report(&expected, &actual, start_tolerance, duration_tolerance)?;
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| format!("playback comparison serialization failed: {e}"))?
);
if fail_on_mismatch && !report.within_tolerance {
return Err(format!(
"playback comparison found {} mismatch(es)",
report.mismatches.len()
));
}
Ok(())
}
fn playback_comparison_report(
expected: &[acorde_core::PlaybackEvent],
actual: &[acorde_core::PlaybackEvent],
start_tolerance: f64,
duration_tolerance: f64,
) -> Result<acorde_core::PlaybackTimingReport, String> {
let tolerance = acorde_core::PlaybackTimingTolerance {
start_secs: start_tolerance,
duration_secs: duration_tolerance,
};
acorde_core::compare_playback_timing(expected, actual, &tolerance)
.map_err(|e| format!("playback comparison failed: {e}"))
}
#[derive(Debug, Serialize)]
struct FingeringReportEntry {
part: usize,
staff: usize,
measure: usize,
voice: usize,
note: usize,
candidates: Vec<u8>,
selected: Option<u8>,
}
fn parse_fingering_policy(value: &str) -> Result<FingeringSelectionPolicy, String> {
match value {
"source-order" | "source" => Ok(FingeringSelectionPolicy::SourceOrder),
"lowest" | "lowest-number" => Ok(FingeringSelectionPolicy::LowestNumber),
"highest" | "highest-number" => Ok(FingeringSelectionPolicy::HighestNumber),
_ => Err(format!(
"unknown fingering policy '{value}'; expected source-order, lowest, or highest"
)),
}
}
fn cmd_fingering_report(input: &Path, policy: &str) -> Result<(), String> {
let score = parse_score(input)?;
let policy = parse_fingering_policy(policy)?;
let mut entries = Vec::new();
for (part_index, part) in score.parts.iter().enumerate() {
for (staff_index, staff) in part.staves.iter().enumerate() {
for (measure_index, measure) in staff.measures.iter().enumerate() {
for (voice_index, voice) in measure.voices.iter().enumerate() {
for (note_index, note) in voice.iter().enumerate() {
let candidates = if note.fingerings.is_empty() {
note.fingering.into_iter().collect()
} else {
note.fingerings.clone()
};
if candidates.is_empty() {
continue;
}
entries.push(FingeringReportEntry {
part: part_index,
staff: staff_index,
measure: measure_index,
voice: voice_index,
note: note_index,
candidates,
selected: note.select_fingering(policy),
});
}
}
}
}
}
println!(
"{}",
serde_json::to_string_pretty(&entries)
.map_err(|e| format!("fingering report serialization failed: {e}"))?
);
Ok(())
}
fn cmd_extract(input: &Path, output: &Path, part_index: usize) -> Result<(), String> {
let score = parse_score(input)?;
let extracted = score.extract_part(part_index).ok_or_else(|| {
format!(
"part index {} out of range (score has {} part(s))",
part_index,
score.parts.len()
)
})?;
write_score(&extracted, output)?;
println!("extracted part {} to '{}'", part_index, output.display());
Ok(())
}
fn cmd_transpose(input: &Path, output: &Path, semitones: i8) -> Result<(), String> {
let score = parse_score(input)?;
let transposed = acorde_core::transpose(&score, semitones);
write_score(&transposed, output)?;
println!(
"transposed {} semitone(s) to '{}'",
semitones,
output.display()
);
Ok(())
}
fn cmd_normalize(input: &Path, output: &Path) -> Result<(), String> {
let score = parse_score(input)?;
let validation = acorde_core::validate(&score);
if !validation.errors.is_empty() {
return Err(format!(
"cannot normalize structurally invalid score: {} error(s)",
validation.errors.len()
));
}
write_score(&score, output)?;
println!("normalized '{}' to '{}'", input.display(), output.display());
Ok(())
}
#[derive(Debug, Serialize)]
struct ExportReportSummary {
schema_version: u32,
format: String,
output_path: String,
byte_count: usize,
warning_count: usize,
error_count: usize,
loss_count: usize,
diagnostics: Vec<acorde_io::Diagnostic>,
}
fn cmd_export_report(input: &Path, output: &Path) -> Result<(), String> {
let score = parse_score(input)?;
let ext = output
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let (format, bytes, diagnostics, schema_version) = match ext.as_str() {
"xml" | "musicxml" => {
let report =
acorde_io::serialize_musicxml_with_report(&score).map_err(|e| e.to_string())?;
(
report.format,
report.output.into_bytes(),
report.diagnostics,
report.schema_version,
)
}
"mid" | "midi" => {
let report =
acorde_io::serialize_midi_with_report(&score).map_err(|e| e.to_string())?;
(
report.format,
report.output,
report.diagnostics,
report.schema_version,
)
}
"abc" => {
let report = acorde_io::serialize_abc_with_report(&score).map_err(|e| e.to_string())?;
(
report.format,
report.output.into_bytes(),
report.diagnostics,
report.schema_version,
)
}
"mei" => {
let report = acorde_io::serialize_mei_with_report(&score).map_err(|e| e.to_string())?;
(
report.format,
report.output.into_bytes(),
report.diagnostics,
report.schema_version,
)
}
"mscx" => {
let report =
acorde_io::serialize_mscx_with_report(&score).map_err(|e| e.to_string())?;
(
report.format,
report.output.into_bytes(),
report.diagnostics,
report.schema_version,
)
}
"mscz" => {
let report =
acorde_io::serialize_mscz_with_report(&score).map_err(|e| e.to_string())?;
(
report.format,
report.output,
report.diagnostics,
report.schema_version,
)
}
other => return Err(format!("unsupported output format: '.{other}'")),
};
let byte_count = bytes.len();
std::fs::write(output, bytes)
.map_err(|e| format!("cannot write '{}': {e}", output.display()))?;
let summary = ExportReportSummary {
schema_version,
format,
output_path: output.display().to_string(),
warning_count: diagnostics
.iter()
.filter(|d| d.severity == acorde_io::DiagnosticSeverity::Warning)
.count(),
error_count: diagnostics
.iter()
.filter(|d| d.severity == acorde_io::DiagnosticSeverity::Error)
.count(),
loss_count: diagnostics.iter().filter(|d| d.is_loss()).count(),
diagnostics,
byte_count,
};
println!(
"{}",
serde_json::to_string_pretty(&summary)
.map_err(|e| format!("report serialization failed: {e}"))?
);
Ok(())
}
const COMPATIBILITY_REPORT_CONTRACT_VERSION: u16 = 1;
#[derive(Debug, Serialize)]
struct CompatibilityReport {
contract_version: u16,
tool_version: String,
schema_version: u32,
source_format: String,
candidate_format: String,
source_path: String,
candidate_path: String,
source_fingerprint: String,
candidate_fingerprint: String,
change_count: usize,
semantic_equivalent: bool,
analysis_changed_categories: Vec<acorde_analysis::AnalysisCategory>,
analysis_equivalent: bool,
lossless: bool,
changes: Vec<acorde_core::ScoreChange>,
source_warning_count: usize,
source_error_count: usize,
source_loss_count: usize,
source_diagnostics: Vec<acorde_io::Diagnostic>,
candidate_warning_count: usize,
candidate_error_count: usize,
candidate_loss_count: usize,
candidate_diagnostics: Vec<acorde_io::Diagnostic>,
}
fn cmd_compatibility_report(
source: &Path,
candidate: &Path,
fail_on_differences: bool,
fail_on_loss: bool,
) -> Result<(), String> {
let report = build_compatibility_report(source, candidate)?;
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| format!("compatibility report serialization failed: {e}"))?
);
if fail_on_differences && (!report.semantic_equivalent || !report.analysis_equivalent) {
return Err(format!(
"compatibility report found {} semantic difference(s) and {} analysis category change(s)",
report.change_count,
report.analysis_changed_categories.len()
));
}
if fail_on_loss && report.source_loss_count + report.candidate_loss_count > 0 {
return Err(format!(
"compatibility report found {} information-loss diagnostic(s)",
report.source_loss_count + report.candidate_loss_count
));
}
Ok(())
}
fn build_compatibility_report(
source: &Path,
candidate: &Path,
) -> Result<CompatibilityReport, String> {
let source_fingerprint = file_fingerprint(source)?;
let candidate_fingerprint = file_fingerprint(candidate)?;
let source_report = parse_report(source)?;
let candidate_report = parse_report(candidate)?;
let changes = acorde_core::diff(&source_report.score, &candidate_report.score);
let source_analysis = acorde_analysis::analyze_score(&source_report.score);
let candidate_analysis = acorde_analysis::analyze_score(&candidate_report.score);
let analysis_diff = acorde_analysis::diff_analysis(&source_analysis, &candidate_analysis);
let analysis_equivalent = analysis_diff.is_empty();
let analysis_changed_categories = analysis_diff.changed_categories;
Ok(CompatibilityReport {
contract_version: COMPATIBILITY_REPORT_CONTRACT_VERSION,
tool_version: env!("CARGO_PKG_VERSION").to_string(),
schema_version: source_report.schema_version,
source_format: source_report.format.clone(),
candidate_format: candidate_report.format.clone(),
source_path: source.display().to_string(),
candidate_path: candidate.display().to_string(),
source_fingerprint,
candidate_fingerprint,
change_count: changes.len(),
semantic_equivalent: changes.is_empty(),
analysis_changed_categories,
analysis_equivalent,
lossless: changes.is_empty()
&& source_report.loss_count() + candidate_report.loss_count() == 0,
changes,
source_warning_count: source_report.warning_count(),
source_error_count: source_report.error_count(),
source_loss_count: source_report.loss_count(),
source_diagnostics: source_report.diagnostics,
candidate_warning_count: candidate_report.warning_count(),
candidate_error_count: candidate_report.error_count(),
candidate_loss_count: candidate_report.loss_count(),
candidate_diagnostics: candidate_report.diagnostics,
})
}
fn file_fingerprint(path: &Path) -> Result<String, String> {
let bytes =
std::fs::read(path).map_err(|e| format!("cannot read '{}': {e}", path.display()))?;
Ok(bytes_fingerprint(&bytes))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures")
.join(name)
}
#[test]
fn playback_report_is_deterministic_and_respects_measure_range() {
let score = parse_score(&fixture("simple.musicxml")).expect("fixture parses");
let all =
playback_report_events(&score, Some(120), None, None).expect("full report succeeds");
let partial = playback_report_events(&score, Some(120), Some(0), Some(0))
.expect("partial report succeeds");
let repeated = playback_report_events(&score, Some(120), None, None)
.expect("repeated report succeeds");
assert_eq!(all, repeated);
assert!(!all.is_empty());
assert!(!partial.is_empty());
assert!(partial.len() <= all.len());
assert!(partial.iter().all(|event| event.time_beats >= 0.0));
}
#[test]
fn playback_report_rejects_reversed_measure_range() {
let score = parse_score(&fixture("simple.musicxml")).expect("fixture parses");
let error = playback_report_events(&score, None, Some(1), Some(0))
.expect_err("reversed range must fail");
assert!(error.contains("--loop-start must not exceed --loop-end"));
}
#[test]
fn render_command_writes_deterministic_interactive_svg() {
let output =
std::env::temp_dir().join(format!("acorde-cli-render-{}.svg", std::process::id()));
cmd_render(&fixture("simple.musicxml"), &output, 900.0, 24.0, 4, true)
.expect("render command succeeds");
let svg = std::fs::read_to_string(&output).expect("render output exists");
assert!(svg.starts_with("<svg"));
assert!(svg.contains("data-note-addr"));
std::fs::remove_file(output).expect("temporary render output is removable");
}
#[test]
fn convert_path_returns_export_loss_diagnostics() {
let import = parse_report(&fixture("interchange_harm_analysis.mei"))
.expect("MEI fixture report succeeds");
let output = std::env::temp_dir().join(format!(
"acorde-cli-convert-diagnostics-{}.musicxml",
std::process::id()
));
let diagnostics =
write_score_with_report(&import.score, &output).expect("MusicXML conversion succeeds");
assert!(diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "musicxml.export-unsupported-mei-harmony-type"));
std::fs::remove_file(output).expect("temporary conversion output is removable");
}
#[test]
fn render_report_preserves_import_and_renderer_boundaries() {
let output = std::env::temp_dir().join(format!(
"acorde-cli-render-report-{}.svg",
std::process::id()
));
let report =
render_report_summary(&fixture("simple.musicxml"), &output, 900.0, 24.0, 4, true)
.expect("render report succeeds");
assert_eq!(report.render_report_schema_version, 1);
assert_eq!(report.input_format, "musicxml");
assert_eq!(report.import_warning_count, 0);
assert_eq!(report.import_error_count, 0);
assert_eq!(report.import_loss_count, 0);
assert!(report.import_diagnostics.is_empty());
assert!(report.renderer_issues.is_empty());
assert!(report.svg_byte_count > 0);
assert!(report.svg_fingerprint.is_some());
std::fs::remove_file(output).expect("temporary render report output is removable");
}
#[test]
fn render_report_fingerprint_is_stable_for_same_input_and_options() {
let first_output = std::env::temp_dir().join(format!(
"acorde-cli-render-report-fingerprint-first-{}.svg",
std::process::id()
));
let second_output = std::env::temp_dir().join(format!(
"acorde-cli-render-report-fingerprint-second-{}.svg",
std::process::id()
));
let first = render_report_summary(
&fixture("simple.musicxml"),
&first_output,
900.0,
24.0,
4,
true,
)
.expect("first render report succeeds");
let second = render_report_summary(
&fixture("simple.musicxml"),
&second_output,
900.0,
24.0,
4,
true,
)
.expect("second render report succeeds");
assert_eq!(first.svg_byte_count, second.svg_byte_count);
assert_eq!(first.svg_fingerprint, second.svg_fingerprint);
std::fs::remove_file(first_output).expect("first temporary output is removable");
std::fs::remove_file(second_output).expect("second temporary output is removable");
}
#[test]
fn render_report_retains_rejected_renderer_diagnostics() {
let output = std::env::temp_dir().join(format!(
"acorde-cli-render-report-rejected-{}.svg",
std::process::id()
));
let report = render_report_summary(
&fixture("render_preflight_unsupported.musicxml"),
&output,
900.0,
24.0,
4,
true,
)
.expect("diagnostic report succeeds even when rendering is rejected");
assert!(!report.rendered);
assert_eq!(report.svg_byte_count, 0);
assert!(report.render_error.is_some());
assert!(!report.renderer_issues.is_empty());
assert!(!output.exists());
}
#[test]
fn render_report_covers_declared_local_input_formats() {
let cases = [
("simple.musicxml", "musicxml"),
("sample.abc", "abc"),
("interchange_subset.mei", "mei"),
("interchange_subset.mscx", "mscx"),
("4_steps_in_31-et_on_c.mid", "midi"),
];
for (index, (name, format)) in cases.iter().enumerate() {
let output = std::env::temp_dir().join(format!(
"acorde-cli-render-format-{}-{}.svg",
std::process::id(),
index
));
let report = render_report_summary(&fixture(name), &output, 900.0, 24.0, 4, true)
.unwrap_or_else(|error| panic!("{name} report failed: {error}"));
assert_eq!(report.input_format, *format);
assert!(report.rendered, "{name} should render: {report:?}");
assert!(report.render_error.is_none());
assert!(report.svg_byte_count > 0);
std::fs::remove_file(output).expect("temporary format output is removable");
}
}
#[test]
fn playback_compare_reports_tolerance_and_rejects_invalid_tolerance() {
let score = parse_score(&fixture("simple.musicxml")).expect("fixture parses");
let expected = playback_report_events(&score, Some(120), None, None)
.expect("expected schedule succeeds");
let mut actual = expected.clone();
actual[0].time_secs += 0.01;
let report = playback_comparison_report(&expected, &actual, 0.005, 0.005)
.expect("comparison succeeds");
assert!(!report.within_tolerance);
assert_eq!(report.matched_events, expected.len() - 1);
assert!(playback_comparison_report(&expected, &actual, -0.001, 0.005).is_err());
}
#[test]
fn compatibility_report_records_tool_and_input_evidence() {
let input = fixture("simple.musicxml");
let report = build_compatibility_report(&input, &input)
.expect("identical fixture compatibility report succeeds");
assert_eq!(
report.contract_version,
COMPATIBILITY_REPORT_CONTRACT_VERSION
);
assert_eq!(report.tool_version, env!("CARGO_PKG_VERSION"));
assert!(report.source_fingerprint.starts_with("fnv1a64-"));
assert_eq!(report.source_fingerprint, report.candidate_fingerprint);
assert!(report.semantic_equivalent);
}
#[test]
fn print_report_config_preserves_preset_and_publication_policies() {
let config = build_print_config(&PrintReportOptions {
preset: PrintPresetArg::LetterPart,
part: Some(2),
measures_per_system: 3,
systems_per_page: Some(4),
title_page: true,
running_title: Some("Suite"),
header_text: Some("Header"),
footer_text: Some("Footer"),
page_number_in_footer: true,
show_part_names: false,
fail_on_issues: false,
scale: 1.1,
first_system_measures: Some(2),
final_page_policy: PrintFinalPagePolicyArg::Balance,
notation_break_policy: PrintNotationBreakPolicyArg::KeepVoltaTogether,
pickup_policy: PrintPickupPolicyArg::Preserve,
})
.expect("print config succeeds");
assert_eq!(config.paper_size, acorde_layout::PaperSize::Letter);
assert_eq!(
config.part_layout,
acorde_layout::PartLayoutPolicy::ExtractedPart { part_index: 2 }
);
assert_eq!(config.measures_per_system, 3);
assert_eq!(config.systems_per_page, Some(4));
assert_eq!(config.scale, 1.1);
assert_eq!(config.first_system_measures, Some(2));
assert_eq!(
config.final_page_policy,
acorde_layout::FinalPagePolicy::Balance
);
assert_eq!(
config.notation_break_policy,
acorde_layout::NotationBreakPolicy::KeepVoltaTogether
);
assert_eq!(config.pickup_policy, acorde_layout::PickupPolicy::Preserve);
assert!(config.publication.title_page);
assert_eq!(config.publication.running_title.as_deref(), Some("Suite"));
assert_eq!(config.publication.header_text.as_deref(), Some("Header"));
assert_eq!(config.publication.footer_text.as_deref(), Some("Footer"));
assert!(config.publication.page_number_in_footer);
assert!(!config.publication.show_part_names);
}
#[test]
fn print_report_config_rejects_part_on_full_score_preset() {
let error = build_print_config(&PrintReportOptions {
preset: PrintPresetArg::A4Score,
part: Some(0),
measures_per_system: 4,
systems_per_page: None,
title_page: false,
running_title: None,
header_text: None,
footer_text: None,
page_number_in_footer: false,
show_part_names: true,
fail_on_issues: false,
scale: 1.0,
first_system_measures: None,
final_page_policy: PrintFinalPagePolicyArg::AllowSingleSystem,
notation_break_policy: PrintNotationBreakPolicyArg::Preserve,
pickup_policy: PrintPickupPolicyArg::Auto,
})
.expect_err("full score must reject part selection");
assert!(error.contains("requires an extracted-part preset"));
}
}