Skip to main content

fren_date/merge/
mod.rs

1//! Directory merge.
2//!
3//! Port of Python's `merge_directories()` from `src/fren.py`. For each
4//! source dir, walks recursively and moves each file into the target's
5//! mirrored subtree. If a target file already exists, appends a `_Copy`
6//! / `_Copy1` / `_Copy2` suffix to the stem until a free name is found.
7//!
8//! Uses the literal `_Copy` suffix for compatibility with the original
9//! Python tool's behavior. A configurable conflict template may replace
10//! this in the future.
11
12use 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
20/// Merge `sources` into `target`. Files already in `target` get a
21/// `_Copy{N}` suffix appended to their stem. Directories are created
22/// as needed. `.DS_Store` and similar metadata files are skipped.
23///
24/// `dry_run = true` walks and reports what would be moved without
25/// touching the filesystem.
26///
27/// Returns the count of files moved (or that would be moved in dry-run).
28pub 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    // For dry-run: track destinations chosen so far so successive
42    // would-be-moves don't all collide on the same path.
43    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
97/// Like [`unique_file_name`] but also avoids any path in `taken`. Used by
98/// dry-run mode where filesystem state doesn't yet reflect prior moves
99/// in the same batch.
100fn unique_file_name_avoiding(path: &Path, taken: &HashSet<PathBuf>) -> PathBuf {
101    let mut current = path.to_path_buf();
102    while current.exists() || taken.contains(&current) {
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/// Outcome of a single file move during a merge.
143#[derive(Debug, Clone)]
144pub struct MergeMove {
145    /// Source path.
146    pub from: PathBuf,
147    /// Final target path (after any `_Copy` suffix).
148    pub to: PathBuf,
149}
150
151/// Report from [`merge_directories`].
152#[derive(Debug, Clone, Default)]
153pub struct MergeReport {
154    /// Files moved (or that would be moved in dry-run).
155    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        // symlinks: skip silently; merge isn't designed to handle them
186    }
187    Ok(())
188}
189
190fn unique_re() -> &'static Regex {
191    static RE: OnceLock<Regex> = OnceLock::new();
192    RE.get_or_init(|| {
193        // Mirrors Python: r"(?P<original_stem>.+)_copy(?P<index>\d+)?" with IGNORECASE.
194        #[allow(clippy::expect_used)]
195        Regex::new(r"(?i)^(?P<orig>.+)_copy(?P<idx>\d+)?$").expect("static unique regex compiles")
196    })
197}
198
199/// Find a unique file name by appending `_Copy`, `_Copy1`, `_Copy2`, ... to
200/// the stem until the path no longer exists.
201pub 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}