Skip to main content

mtag_cli/
executor.rs

1use std::{
2    fmt, fs,
3    path::{Path, PathBuf},
4    str::FromStr,
5};
6
7use clap::ValueEnum;
8
9use crate::{
10    error::{MtagError, MtagResult},
11    planner::CopyPlan,
12};
13
14/// Policy for handling a copy or move task whose destination already exists.
15#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
16pub enum ConflictStrategy {
17    /// Return an error when the destination exists.
18    Fail,
19    /// Leave the existing destination in place and skip the task.
20    Skip,
21    /// Replace the existing destination.
22    Overwrite,
23    /// Keep the existing destination and write to the next `name (N).ext` path.
24    Rename,
25}
26
27impl fmt::Display for ConflictStrategy {
28    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
29        let value = match self {
30            Self::Fail => "fail",
31            Self::Skip => "skip",
32            Self::Overwrite => "overwrite",
33            Self::Rename => "rename",
34        };
35        formatter.write_str(value)
36    }
37}
38
39impl FromStr for ConflictStrategy {
40    type Err = MtagError;
41
42    fn from_str(value: &str) -> Result<Self, Self::Err> {
43        match value {
44            "fail" => Ok(Self::Fail),
45            "skip" => Ok(Self::Skip),
46            "overwrite" => Ok(Self::Overwrite),
47            "rename" => Ok(Self::Rename),
48            _ => Err(MtagError::InvalidConflictStrategy {
49                value: value.to_string(),
50            }),
51        }
52    }
53}
54
55/// Selects whether execution copies source files or moves them after a successful copy.
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum ExecutionMode {
58    /// Copy source files and leave originals untouched.
59    Copy,
60    /// Copy source files, then remove each source after its destination is written.
61    Move,
62}
63
64/// Runtime options for applying a [`CopyPlan`].
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub struct ExecutionOptions {
67    /// Policy used when a destination file already exists.
68    pub conflict_strategy: ConflictStrategy,
69    /// Whether files are copied or moved.
70    pub mode: ExecutionMode,
71    /// When true, compute the same summary without writing or removing files.
72    pub dry_run: bool,
73}
74
75impl Default for ExecutionOptions {
76    fn default() -> Self {
77        Self {
78            conflict_strategy: ConflictStrategy::Fail,
79            mode: ExecutionMode::Copy,
80            dry_run: false,
81        }
82    }
83}
84
85/// Counts produced by [`execute_plan`].
86#[derive(Clone, Debug, Default, Eq, PartialEq)]
87pub struct ExecutionSummary {
88    /// Number of tasks present in the input plan.
89    pub planned: usize,
90    /// Number of files copied.
91    pub copied: usize,
92    /// Number of files moved.
93    pub moved: usize,
94    /// Number of tasks skipped because of [`ConflictStrategy::Skip`].
95    pub skipped: usize,
96    /// Number of tasks redirected to a `name (N).ext` destination.
97    pub renamed: usize,
98}
99
100/// Applies a prepared copy plan to the filesystem.
101///
102/// In dry-run mode, destination conflict handling is still evaluated, but no directories
103/// or files are written.
104///
105/// # Errors
106///
107/// Returns [`MtagError::DestinationExists`] when the conflict policy is
108/// [`ConflictStrategy::Fail`] and a destination already exists. Returns
109/// [`MtagError::FileOperation`] when directory creation, copying, or source removal fails.
110pub fn execute_plan(plan: &CopyPlan, options: &ExecutionOptions) -> MtagResult<ExecutionSummary> {
111    let mut summary = ExecutionSummary {
112        planned: plan.tasks.len(),
113        ..ExecutionSummary::default()
114    };
115
116    for task in &plan.tasks {
117        let destination = resolve_destination(&task.to, options.conflict_strategy, &mut summary)?;
118        let Some(destination) = destination else {
119            summary.skipped += 1;
120            continue;
121        };
122
123        if options.dry_run {
124            continue;
125        }
126
127        if let Some(parent) = destination.parent() {
128            fs::create_dir_all(parent).map_err(|source| MtagError::FileOperation {
129                operation: "create directory for",
130                from: task.from.clone(),
131                to: destination.clone(),
132                source,
133            })?;
134        }
135
136        match options.mode {
137            ExecutionMode::Copy => {
138                copy_file(&task.from, &destination)?;
139                summary.copied += 1;
140            }
141            ExecutionMode::Move => {
142                copy_file(&task.from, &destination)?;
143                fs::remove_file(&task.from).map_err(|source| MtagError::FileOperation {
144                    operation: "remove source after moving",
145                    from: task.from.clone(),
146                    to: destination.clone(),
147                    source,
148                })?;
149                summary.moved += 1;
150            }
151        }
152    }
153
154    Ok(summary)
155}
156
157fn copy_file(from: &Path, to: &Path) -> MtagResult<()> {
158    fs::copy(from, to).map_err(|source| MtagError::FileOperation {
159        operation: "copy",
160        from: from.to_path_buf(),
161        to: to.to_path_buf(),
162        source,
163    })?;
164    Ok(())
165}
166
167fn resolve_destination(
168    destination: &Path,
169    conflict_strategy: ConflictStrategy,
170    summary: &mut ExecutionSummary,
171) -> MtagResult<Option<PathBuf>> {
172    if !destination.exists() {
173        return Ok(Some(destination.to_path_buf()));
174    }
175
176    match conflict_strategy {
177        ConflictStrategy::Fail => Err(MtagError::DestinationExists {
178            path: destination.to_path_buf(),
179        }),
180        ConflictStrategy::Skip => Ok(None),
181        ConflictStrategy::Overwrite => Ok(Some(destination.to_path_buf())),
182        ConflictStrategy::Rename => {
183            summary.renamed += 1;
184            Ok(Some(next_available_path(destination)?))
185        }
186    }
187}
188
189fn next_available_path(destination: &Path) -> MtagResult<PathBuf> {
190    let parent = destination.parent().unwrap_or_else(|| Path::new(""));
191    let file_stem = destination
192        .file_stem()
193        .ok_or_else(|| MtagError::MissingFileName {
194            path: destination.to_path_buf(),
195        })?
196        .to_string_lossy();
197    let extension = destination.extension().map(|extension| {
198        let mut value = String::from(".");
199        value.push_str(&extension.to_string_lossy());
200        value
201    });
202
203    for index in 1.. {
204        let mut file_name = format!("{file_stem} ({index})");
205        if let Some(extension) = &extension {
206            file_name.push_str(extension);
207        }
208        let candidate = parent.join(file_name);
209        if !candidate.exists() {
210            return Ok(candidate);
211        }
212    }
213
214    unreachable!("unbounded rename search always returns or loops")
215}