use std::{fs, path::PathBuf};
use mtag_cli::{
executor::{execute_plan, ConflictStrategy, ExecutionMode, ExecutionOptions},
metadata::TrackMetadata,
planner::{build_copy_plan, sanitize_path_component, OrganizationOptions},
scanner::scan_audio_files,
};
use tempfile::tempdir;
fn write_file(path: &std::path::Path, content: &[u8]) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
fn metadata(path: PathBuf, artist: &str, album: &str) -> TrackMetadata {
TrackMetadata {
source_path: path,
artist: Some(artist.to_string()),
album: Some(album.to_string()),
album_artist: None,
disc: None,
track: None,
title: None,
}
}
#[test]
fn scanner_returns_error_when_music_root_is_missing() {
let missing_root = PathBuf::from("/tmp/mtag-cli-definitely-missing-root");
let err = scan_audio_files(&missing_root).unwrap_err();
assert!(err.to_string().contains("scan music folder"));
}
#[test]
fn scanner_returns_only_audio_files_recursively() {
let temp = tempdir().unwrap();
let audio_path = temp.path().join("nested/song.mp3");
let text_path = temp.path().join("nested/readme.txt");
write_file(&audio_path, b"ID3\x04\x00\x00\x00\x00\x00\x00\x00");
write_file(&text_path, b"not audio");
let files = scan_audio_files(temp.path()).unwrap();
assert_eq!(files, vec![audio_path]);
}
#[test]
fn sanitize_path_component_replaces_unsafe_path_characters() {
assert_eq!(sanitize_path_component("AC/DC: Live?"), "AC_DC_ Live_");
assert_eq!(sanitize_path_component(".."), "_");
}
#[test]
fn planner_uses_album_artist_and_adds_lrc_companion() {
let temp = tempdir().unwrap();
let source = temp.path().join("source/01.mp3");
let lyric = temp.path().join("source/01.lrc");
let target = temp.path().join("target");
write_file(&source, b"audio");
write_file(&lyric, b"lyric");
let mut track = metadata(source.clone(), "Track Artist", "Live: 2026?");
track.album_artist = Some("AC/DC".to_string());
let plan = build_copy_plan(&[track], &target, &OrganizationOptions::default()).unwrap();
assert_eq!(plan.tasks.len(), 2);
assert_eq!(
plan.tasks[0].to,
target.join("AC_DC").join("Live_ 2026_").join("01.mp3")
);
assert_eq!(plan.tasks[1].from, lyric);
assert_eq!(
plan.tasks[1].to,
target.join("AC_DC").join("Live_ 2026_").join("01.lrc")
);
}
#[test]
fn planner_infers_various_artists_for_same_folder_compilation() {
let temp = tempdir().unwrap();
let source_a = temp.path().join("mix/01.mp3");
let source_b = temp.path().join("mix/02.mp3");
write_file(&source_a, b"audio");
write_file(&source_b, b"audio");
let target = temp.path().join("target");
let tracks = vec![
metadata(source_a, "Artist A", "Road Mix"),
metadata(source_b, "Artist B", "Road Mix"),
];
let plan = build_copy_plan(&tracks, &target, &OrganizationOptions::default()).unwrap();
assert_eq!(
plan.tasks[0].to,
target
.join("Various Artists")
.join("Road Mix")
.join("01.mp3")
);
assert_eq!(
plan.tasks[1].to,
target
.join("Various Artists")
.join("Road Mix")
.join("02.mp3")
);
}
#[test]
fn planner_does_not_merge_same_album_name_from_different_folders() {
let temp = tempdir().unwrap();
let source_a = temp.path().join("alpha/01.mp3");
let source_b = temp.path().join("beta/01.mp3");
write_file(&source_a, b"audio");
write_file(&source_b, b"audio");
let target = temp.path().join("target");
let tracks = vec![
metadata(source_a, "Alpha", "Greatest Hits"),
metadata(source_b, "Beta", "Greatest Hits"),
];
let plan = build_copy_plan(&tracks, &target, &OrganizationOptions::default()).unwrap();
assert_eq!(
plan.tasks[0].to,
target.join("Alpha").join("Greatest Hits").join("01.mp3")
);
assert_eq!(
plan.tasks[1].to,
target.join("Beta").join("Greatest Hits").join("01.mp3")
);
}
#[test]
fn executor_skips_existing_destination_when_policy_is_skip() {
let temp = tempdir().unwrap();
let source = temp.path().join("source/song.mp3");
let target = temp.path().join("target/song.mp3");
write_file(&source, b"new");
write_file(&target, b"existing");
let track = metadata(source, "Artist", "Album");
let plan = build_copy_plan(
&[track],
temp.path().join("target").as_path(),
&OrganizationOptions::flat(),
)
.unwrap();
let summary = execute_plan(
&plan,
&ExecutionOptions {
conflict_strategy: ConflictStrategy::Skip,
mode: ExecutionMode::Copy,
dry_run: false,
},
)
.unwrap();
assert_eq!(summary.skipped, 1);
assert_eq!(fs::read(target).unwrap(), b"existing");
}
#[test]
fn executor_fails_existing_destination_when_policy_is_fail() {
let temp = tempdir().unwrap();
let source = temp.path().join("source/song.mp3");
let target = temp.path().join("target/song.mp3");
write_file(&source, b"new");
write_file(&target, b"existing");
let track = metadata(source, "Artist", "Album");
let plan = build_copy_plan(
&[track],
temp.path().join("target").as_path(),
&OrganizationOptions::flat(),
)
.unwrap();
let err = execute_plan(
&plan,
&ExecutionOptions {
conflict_strategy: ConflictStrategy::Fail,
mode: ExecutionMode::Copy,
dry_run: false,
},
)
.unwrap_err();
assert!(err.to_string().contains("destination already exists"));
}
#[test]
fn executor_overwrites_existing_destination_when_policy_is_overwrite() {
let temp = tempdir().unwrap();
let source = temp.path().join("source/song.mp3");
let target = temp.path().join("target/song.mp3");
write_file(&source, b"new");
write_file(&target, b"existing");
let track = metadata(source, "Artist", "Album");
let plan = build_copy_plan(
&[track],
temp.path().join("target").as_path(),
&OrganizationOptions::flat(),
)
.unwrap();
let summary = execute_plan(
&plan,
&ExecutionOptions {
conflict_strategy: ConflictStrategy::Overwrite,
mode: ExecutionMode::Copy,
dry_run: false,
},
)
.unwrap();
assert_eq!(summary.copied, 1);
assert_eq!(fs::read(target).unwrap(), b"new");
}
#[test]
fn executor_renames_existing_destination_when_policy_is_rename() {
let temp = tempdir().unwrap();
let source = temp.path().join("source/song.mp3");
let target = temp.path().join("target/song.mp3");
write_file(&source, b"new");
write_file(&target, b"existing");
let track = metadata(source, "Artist", "Album");
let plan = build_copy_plan(
&[track],
temp.path().join("target").as_path(),
&OrganizationOptions::flat(),
)
.unwrap();
let summary = execute_plan(
&plan,
&ExecutionOptions {
conflict_strategy: ConflictStrategy::Rename,
mode: ExecutionMode::Copy,
dry_run: false,
},
)
.unwrap();
assert_eq!(summary.copied, 1);
assert_eq!(
fs::read(temp.path().join("target/song (1).mp3")).unwrap(),
b"new"
);
}
#[test]
fn executor_move_mode_removes_source_after_copy() {
let temp = tempdir().unwrap();
let source = temp.path().join("source/song.mp3");
let target = temp.path().join("target/song.mp3");
write_file(&source, b"new");
let track = metadata(source.clone(), "Artist", "Album");
let plan = build_copy_plan(
&[track],
temp.path().join("target").as_path(),
&OrganizationOptions::flat(),
)
.unwrap();
let summary = execute_plan(
&plan,
&ExecutionOptions {
conflict_strategy: ConflictStrategy::Fail,
mode: ExecutionMode::Move,
dry_run: false,
},
)
.unwrap();
assert_eq!(summary.moved, 1);
assert!(!source.exists());
assert_eq!(fs::read(target).unwrap(), b"new");
}
#[test]
fn executor_dry_run_does_not_write_files() {
let temp = tempdir().unwrap();
let source = temp.path().join("source/song.mp3");
let target = temp.path().join("target/song.mp3");
write_file(&source, b"new");
let track = metadata(source, "Artist", "Album");
let plan = build_copy_plan(
&[track],
temp.path().join("target").as_path(),
&OrganizationOptions::flat(),
)
.unwrap();
let summary = execute_plan(
&plan,
&ExecutionOptions {
conflict_strategy: ConflictStrategy::Fail,
mode: ExecutionMode::Copy,
dry_run: true,
},
)
.unwrap();
assert_eq!(summary.planned, 1);
assert!(!target.exists());
}