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#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
16pub enum ConflictStrategy {
17 Fail,
19 Skip,
21 Overwrite,
23 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum ExecutionMode {
58 Copy,
60 Move,
62}
63
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub struct ExecutionOptions {
67 pub conflict_strategy: ConflictStrategy,
69 pub mode: ExecutionMode,
71 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#[derive(Clone, Debug, Default, Eq, PartialEq)]
87pub struct ExecutionSummary {
88 pub planned: usize,
90 pub copied: usize,
92 pub moved: usize,
94 pub skipped: usize,
96 pub renamed: usize,
98}
99
100pub 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}