1use crate::FrenError;
13use regex::Regex;
14use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16use std::sync::OnceLock;
17
18const IGNORE_FILES_ON_MERGE: &[&str] = &[".DS_Store"];
19
20pub fn merge_directories(
29 target: &Path,
30 sources: &[&Path],
31 dry_run: bool,
32) -> Result<MergeReport, FrenError> {
33 if !target.is_dir() {
34 return Err(FrenError::InvalidInput(format!(
35 "target is not a directory: {}",
36 target.display()
37 )));
38 }
39
40 let mut moved: Vec<MergeMove> = Vec::new();
41 let mut planned_destinations: HashSet<PathBuf> = HashSet::new();
44
45 for &source in sources {
46 if !source.is_dir() {
47 return Err(FrenError::InvalidInput(format!(
48 "source is not a directory: {}",
49 source.display()
50 )));
51 }
52
53 for path in walk_files(source)? {
54 let stem = path
55 .file_stem()
56 .map(|s| s.to_string_lossy().into_owned())
57 .unwrap_or_default();
58 if IGNORE_FILES_ON_MERGE.iter().any(|n| *n == stem) {
59 continue;
60 }
61 let rel = path.strip_prefix(source).map_err(|_| {
62 FrenError::InvalidInput(format!(
63 "path {} not under source {}",
64 path.display(),
65 source.display()
66 ))
67 })?;
68 let dest_naive = target.join(rel);
69 let dest = if dry_run {
70 unique_file_name_avoiding(&dest_naive, &planned_destinations)
71 } else {
72 unique_file_name(&dest_naive)
73 };
74 planned_destinations.insert(dest.clone());
75 moved.push(MergeMove {
76 from: path.clone(),
77 to: dest.clone(),
78 });
79 if !dry_run {
80 if let Some(parent) = dest.parent() {
81 std::fs::create_dir_all(parent).map_err(|source| FrenError::Io {
82 path: parent.to_path_buf(),
83 source,
84 })?;
85 }
86 std::fs::rename(&path, &dest).map_err(|source| FrenError::Io {
87 path: path.clone(),
88 source,
89 })?;
90 }
91 }
92 }
93
94 Ok(MergeReport { moved })
95}
96
97fn unique_file_name_avoiding(path: &Path, taken: &HashSet<PathBuf>) -> PathBuf {
101 let mut current = path.to_path_buf();
102 while current.exists() || taken.contains(¤t) {
103 let stem = current
104 .file_stem()
105 .map(|s| s.to_string_lossy().into_owned())
106 .unwrap_or_default();
107 let ext = current
108 .extension()
109 .map(|e| e.to_string_lossy().into_owned());
110
111 let (orig_stem, next_idx) = match unique_re().captures(&stem) {
112 Some(caps) => {
113 #[allow(clippy::expect_used)]
114 let orig = caps
115 .name("orig")
116 .expect("regex named group orig")
117 .as_str()
118 .to_string();
119 let idx_next = caps
120 .name("idx")
121 .and_then(|m| m.as_str().parse::<u32>().ok())
122 .map_or(1, |n| n + 1);
123 (orig, idx_next)
124 }
125 None => (stem, 0),
126 };
127 let suffix_num = if next_idx == 0 {
128 String::new()
129 } else {
130 next_idx.to_string()
131 };
132 let new_stem = format!("{orig_stem}_Copy{suffix_num}");
133 let new_name = match &ext {
134 Some(e) if !e.is_empty() => format!("{new_stem}.{e}"),
135 _ => new_stem,
136 };
137 current = current.with_file_name(new_name);
138 }
139 current
140}
141
142#[derive(Debug, Clone)]
144pub struct MergeMove {
145 pub from: PathBuf,
147 pub to: PathBuf,
149}
150
151#[derive(Debug, Clone, Default)]
153pub struct MergeReport {
154 pub moved: Vec<MergeMove>,
156}
157
158fn walk_files(root: &Path) -> Result<Vec<PathBuf>, FrenError> {
159 let mut out = Vec::new();
160 walk_inner(root, &mut out)?;
161 out.sort();
162 Ok(out)
163}
164
165fn walk_inner(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), FrenError> {
166 let entries = std::fs::read_dir(dir).map_err(|source| FrenError::Io {
167 path: dir.to_path_buf(),
168 source,
169 })?;
170 for entry in entries {
171 let entry = entry.map_err(|source| FrenError::Io {
172 path: dir.to_path_buf(),
173 source,
174 })?;
175 let p = entry.path();
176 let ft = entry.file_type().map_err(|source| FrenError::Io {
177 path: p.clone(),
178 source,
179 })?;
180 if ft.is_dir() {
181 walk_inner(&p, out)?;
182 } else if ft.is_file() {
183 out.push(p);
184 }
185 }
187 Ok(())
188}
189
190fn unique_re() -> &'static Regex {
191 static RE: OnceLock<Regex> = OnceLock::new();
192 RE.get_or_init(|| {
193 #[allow(clippy::expect_used)]
195 Regex::new(r"(?i)^(?P<orig>.+)_copy(?P<idx>\d+)?$").expect("static unique regex compiles")
196 })
197}
198
199pub fn unique_file_name(path: &Path) -> PathBuf {
202 let mut current = path.to_path_buf();
203 while current.exists() {
204 let stem = current
205 .file_stem()
206 .map(|s| s.to_string_lossy().into_owned())
207 .unwrap_or_default();
208 let ext = current
209 .extension()
210 .map(|e| e.to_string_lossy().into_owned());
211
212 let (orig_stem, next_idx) = match unique_re().captures(&stem) {
213 Some(caps) => {
214 #[allow(clippy::expect_used)]
215 let orig = caps
216 .name("orig")
217 .expect("regex named group orig")
218 .as_str()
219 .to_string();
220 let idx_next = caps
221 .name("idx")
222 .and_then(|m| m.as_str().parse::<u32>().ok())
223 .map_or(1, |n| n + 1);
224 (orig, idx_next)
225 }
226 None => (stem, 0),
227 };
228
229 let suffix_num = if next_idx == 0 {
230 String::new()
231 } else {
232 next_idx.to_string()
233 };
234 let new_stem = format!("{orig_stem}_Copy{suffix_num}");
235 let new_name = match &ext {
236 Some(e) if !e.is_empty() => format!("{new_stem}.{e}"),
237 _ => new_stem,
238 };
239 current = current.with_file_name(new_name);
240 }
241 current
242}