mtag-cli 0.2.0

Organize music for self-built media libraries like Plex, Emby, and Jellyfin
Documentation
use std::{
    fmt, fs,
    path::{Path, PathBuf},
    str::FromStr,
};

use clap::ValueEnum;

use crate::{
    error::{MtagError, MtagResult},
    planner::CopyPlan,
};

/// Policy for handling a copy or move task whose destination already exists.
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum ConflictStrategy {
    /// Return an error when the destination exists.
    Fail,
    /// Leave the existing destination in place and skip the task.
    Skip,
    /// Replace the existing destination.
    Overwrite,
    /// Keep the existing destination and write to the next `name (N).ext` path.
    Rename,
}

impl fmt::Display for ConflictStrategy {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        let value = match self {
            Self::Fail => "fail",
            Self::Skip => "skip",
            Self::Overwrite => "overwrite",
            Self::Rename => "rename",
        };
        formatter.write_str(value)
    }
}

impl FromStr for ConflictStrategy {
    type Err = MtagError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value {
            "fail" => Ok(Self::Fail),
            "skip" => Ok(Self::Skip),
            "overwrite" => Ok(Self::Overwrite),
            "rename" => Ok(Self::Rename),
            _ => Err(MtagError::InvalidConflictStrategy {
                value: value.to_string(),
            }),
        }
    }
}

/// Selects whether execution copies source files or moves them after a successful copy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ExecutionMode {
    /// Copy source files and leave originals untouched.
    Copy,
    /// Copy source files, then remove each source after its destination is written.
    Move,
}

/// Runtime options for applying a [`CopyPlan`].
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ExecutionOptions {
    /// Policy used when a destination file already exists.
    pub conflict_strategy: ConflictStrategy,
    /// Whether files are copied or moved.
    pub mode: ExecutionMode,
    /// When true, compute the same summary without writing or removing files.
    pub dry_run: bool,
}

impl Default for ExecutionOptions {
    fn default() -> Self {
        Self {
            conflict_strategy: ConflictStrategy::Fail,
            mode: ExecutionMode::Copy,
            dry_run: false,
        }
    }
}

/// Counts produced by [`execute_plan`].
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ExecutionSummary {
    /// Number of tasks present in the input plan.
    pub planned: usize,
    /// Number of files copied.
    pub copied: usize,
    /// Number of files moved.
    pub moved: usize,
    /// Number of tasks skipped because of [`ConflictStrategy::Skip`].
    pub skipped: usize,
    /// Number of tasks redirected to a `name (N).ext` destination.
    pub renamed: usize,
}

/// Applies a prepared copy plan to the filesystem.
///
/// In dry-run mode, destination conflict handling is still evaluated, but no directories
/// or files are written.
///
/// # Errors
///
/// Returns [`MtagError::DestinationExists`] when the conflict policy is
/// [`ConflictStrategy::Fail`] and a destination already exists. Returns
/// [`MtagError::FileOperation`] when directory creation, copying, or source removal fails.
pub fn execute_plan(plan: &CopyPlan, options: &ExecutionOptions) -> MtagResult<ExecutionSummary> {
    let mut summary = ExecutionSummary {
        planned: plan.tasks.len(),
        ..ExecutionSummary::default()
    };

    for task in &plan.tasks {
        let destination = resolve_destination(&task.to, options.conflict_strategy, &mut summary)?;
        let Some(destination) = destination else {
            summary.skipped += 1;
            continue;
        };

        if options.dry_run {
            continue;
        }

        if let Some(parent) = destination.parent() {
            fs::create_dir_all(parent).map_err(|source| MtagError::FileOperation {
                operation: "create directory for",
                from: task.from.clone(),
                to: destination.clone(),
                source,
            })?;
        }

        match options.mode {
            ExecutionMode::Copy => {
                copy_file(&task.from, &destination)?;
                summary.copied += 1;
            }
            ExecutionMode::Move => {
                copy_file(&task.from, &destination)?;
                fs::remove_file(&task.from).map_err(|source| MtagError::FileOperation {
                    operation: "remove source after moving",
                    from: task.from.clone(),
                    to: destination.clone(),
                    source,
                })?;
                summary.moved += 1;
            }
        }
    }

    Ok(summary)
}

fn copy_file(from: &Path, to: &Path) -> MtagResult<()> {
    fs::copy(from, to).map_err(|source| MtagError::FileOperation {
        operation: "copy",
        from: from.to_path_buf(),
        to: to.to_path_buf(),
        source,
    })?;
    Ok(())
}

fn resolve_destination(
    destination: &Path,
    conflict_strategy: ConflictStrategy,
    summary: &mut ExecutionSummary,
) -> MtagResult<Option<PathBuf>> {
    if !destination.exists() {
        return Ok(Some(destination.to_path_buf()));
    }

    match conflict_strategy {
        ConflictStrategy::Fail => Err(MtagError::DestinationExists {
            path: destination.to_path_buf(),
        }),
        ConflictStrategy::Skip => Ok(None),
        ConflictStrategy::Overwrite => Ok(Some(destination.to_path_buf())),
        ConflictStrategy::Rename => {
            summary.renamed += 1;
            Ok(Some(next_available_path(destination)?))
        }
    }
}

fn next_available_path(destination: &Path) -> MtagResult<PathBuf> {
    let parent = destination.parent().unwrap_or_else(|| Path::new(""));
    let file_stem = destination
        .file_stem()
        .ok_or_else(|| MtagError::MissingFileName {
            path: destination.to_path_buf(),
        })?
        .to_string_lossy();
    let extension = destination.extension().map(|extension| {
        let mut value = String::from(".");
        value.push_str(&extension.to_string_lossy());
        value
    });

    for index in 1.. {
        let mut file_name = format!("{file_stem} ({index})");
        if let Some(extension) = &extension {
            file_name.push_str(extension);
        }
        let candidate = parent.join(file_name);
        if !candidate.exists() {
            return Ok(candidate);
        }
    }

    unreachable!("unbounded rename search always returns or loops")
}