mtag-cli 0.2.0

Organize music for self-built media libraries like Plex, Emby, and Jellyfin
Documentation
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());
}