Skip to main content

coreutils_rs/mv/
core.rs

1use std::fs;
2use std::io;
3use std::path::Path;
4
5#[cfg(unix)]
6use std::os::unix::fs::MetadataExt;
7
8/// Backup mode for destination files.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum BackupMode {
11    /// Simple backup: append suffix (default `~`).
12    Simple,
13    /// Numbered backup: append `.~N~`.
14    Numbered,
15    /// Existing: numbered if numbered backups exist, otherwise simple.
16    Existing,
17    /// Never make backups (same as not specifying --backup).
18    None,
19}
20
21/// Configuration for mv operations.
22#[derive(Debug, Clone)]
23pub struct MvConfig {
24    pub force: bool,
25    pub interactive: bool,
26    pub no_clobber: bool,
27    pub verbose: bool,
28    pub update: bool,
29    pub backup: Option<BackupMode>,
30    pub suffix: String,
31    pub target_directory: Option<String>,
32    pub no_target_directory: bool,
33    pub strip_trailing_slashes: bool,
34}
35
36impl Default for MvConfig {
37    fn default() -> Self {
38        Self {
39            force: false,
40            interactive: false,
41            no_clobber: false,
42            verbose: false,
43            update: false,
44            backup: None,
45            suffix: "~".to_string(),
46            target_directory: None,
47            no_target_directory: false,
48            strip_trailing_slashes: false,
49        }
50    }
51}
52
53/// Parse a backup control string (from --backup=CONTROL or VERSION_CONTROL env).
54pub fn parse_backup_mode(s: &str) -> Option<BackupMode> {
55    match s {
56        "none" | "off" => Some(BackupMode::None),
57        "simple" | "never" => Some(BackupMode::Simple),
58        "numbered" | "t" => Some(BackupMode::Numbered),
59        "existing" | "nil" => Some(BackupMode::Existing),
60        _ => Option::None,
61    }
62}
63
64/// Generate a backup file name for a given destination path.
65pub fn make_backup_name(dst: &Path, mode: &BackupMode, suffix: &str) -> std::path::PathBuf {
66    match mode {
67        BackupMode::Simple | BackupMode::None => {
68            let mut name = dst.as_os_str().to_os_string();
69            name.push(suffix);
70            std::path::PathBuf::from(name)
71        }
72        BackupMode::Numbered => make_numbered_backup(dst),
73        BackupMode::Existing => {
74            // If any numbered backup exists, use numbered; otherwise simple.
75            if has_numbered_backup(dst) {
76                make_numbered_backup(dst)
77            } else {
78                let mut name = dst.as_os_str().to_os_string();
79                name.push(suffix);
80                std::path::PathBuf::from(name)
81            }
82        }
83    }
84}
85
86/// Check if any numbered backup (e.g., `file.~1~`) exists for the given path.
87fn has_numbered_backup(path: &Path) -> bool {
88    let file_name = match path.file_name() {
89        Some(n) => n.to_string_lossy().to_string(),
90        None => return false,
91    };
92    let parent = path.parent().unwrap_or_else(|| Path::new("."));
93    if let Ok(entries) = fs::read_dir(parent) {
94        for entry in entries.flatten() {
95            let name = entry.file_name().to_string_lossy().to_string();
96            if name.starts_with(&format!("{}.~", file_name)) && name.ends_with('~') {
97                // Check that the middle part is a number
98                let middle = &name[file_name.len() + 2..name.len() - 1];
99                if middle.parse::<u64>().is_ok() {
100                    return true;
101                }
102            }
103        }
104    }
105    false
106}
107
108/// Create the next numbered backup name (e.g., `file.~1~`, `file.~2~`, ...).
109fn make_numbered_backup(path: &Path) -> std::path::PathBuf {
110    let mut n = 1u64;
111    loop {
112        let candidate = format!("{}.~{}~", path.display(), n);
113        let p = std::path::PathBuf::from(&candidate);
114        if !p.exists() {
115            return p;
116        }
117        n += 1;
118    }
119}
120
121/// Move a single file or directory from `src` to `dst`.
122///
123/// Tries `rename()` first (atomic, same filesystem). If that fails with
124/// `EXDEV` (cross-device), falls back to recursive copy + remove.
125pub fn mv_file(src: &Path, dst: &Path, config: &MvConfig) -> io::Result<()> {
126    // Check no_clobber / update
127    if dst.exists() {
128        if config.no_clobber {
129            return Ok(());
130        }
131        if config.update {
132            let src_time = fs::metadata(src)?.modified()?;
133            let dst_time = fs::metadata(dst)?.modified()?;
134            if src_time <= dst_time {
135                return Ok(());
136            }
137        }
138    }
139
140    // Handle backup
141    if dst.exists() {
142        if let Some(ref mode) = config.backup {
143            let backup_name = make_backup_name(dst, mode, &config.suffix);
144            fs::rename(dst, &backup_name)?;
145        }
146    }
147
148    // Try rename first (same filesystem, atomic)
149    match fs::rename(src, dst) {
150        Ok(()) => {
151            if config.verbose {
152                eprintln!("renamed '{}' -> '{}'", src.display(), dst.display());
153            }
154            Ok(())
155        }
156        Err(e) if e.raw_os_error() == Some(libc::EXDEV) => {
157            // Cross-filesystem: copy then remove
158            copy_recursive(src, dst)?;
159            remove_recursive(src)?;
160            if config.verbose {
161                eprintln!("renamed '{}' -> '{}'", src.display(), dst.display());
162            }
163            Ok(())
164        }
165        Err(e) => Err(e),
166    }
167}
168
169/// Strip trailing slashes from a path string, returning the cleaned string.
170pub fn strip_trailing_slashes(path: &str) -> &str {
171    let trimmed = path.trim_end_matches('/');
172    if trimmed.is_empty() { "/" } else { trimmed }
173}
174
175/// Preserve file metadata (permissions, timestamps, ownership) from `src` onto `dst`.
176/// Used during cross-device moves to maintain the original file attributes.
177fn preserve_metadata(src_meta: &fs::Metadata, dst: &Path) -> io::Result<()> {
178    // Preserve permissions
179    fs::set_permissions(dst, src_meta.permissions())?;
180
181    // Preserve timestamps
182    #[cfg(unix)]
183    {
184        let atime_spec = libc::timespec {
185            tv_sec: src_meta.atime(),
186            tv_nsec: src_meta.atime_nsec(),
187        };
188        let mtime_spec = libc::timespec {
189            tv_sec: src_meta.mtime(),
190            tv_nsec: src_meta.mtime_nsec(),
191        };
192        let times = [atime_spec, mtime_spec];
193        let c_path = std::ffi::CString::new(dst.as_os_str().as_encoded_bytes())
194            .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
195        // SAFETY: c_path is a valid NUL-terminated C string, times is a valid [timespec; 2].
196        let ret = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
197        if ret != 0 {
198            return Err(io::Error::last_os_error());
199        }
200    }
201
202    // Preserve ownership (requires root for chown)
203    #[cfg(unix)]
204    {
205        let c_path = std::ffi::CString::new(dst.as_os_str().as_encoded_bytes())
206            .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
207        // SAFETY: c_path is a valid NUL-terminated C string, uid/gid are valid u32 values.
208        let ret = unsafe { libc::lchown(c_path.as_ptr(), src_meta.uid(), src_meta.gid()) };
209        if ret != 0 {
210            let err = io::Error::last_os_error();
211            // Ownership preservation may fail for non-root; ignore EPERM.
212            if err.raw_os_error() != Some(libc::EPERM) {
213                return Err(err);
214            }
215        }
216    }
217
218    Ok(())
219}
220
221/// Recursively copy a file or directory from `src` to `dst`.
222fn copy_recursive(src: &Path, dst: &Path) -> io::Result<()> {
223    let metadata = fs::symlink_metadata(src)?;
224
225    if metadata.is_dir() {
226        fs::create_dir_all(dst)?;
227        for entry in fs::read_dir(src)? {
228            let entry = entry?;
229            let src_child = entry.path();
230            let dst_child = dst.join(entry.file_name());
231            copy_recursive(&src_child, &dst_child)?;
232        }
233        // Preserve directory metadata after contents are copied
234        preserve_metadata(&metadata, dst)?;
235    } else if metadata.file_type().is_symlink() {
236        let link_target = fs::read_link(src)?;
237        #[cfg(unix)]
238        {
239            std::os::unix::fs::symlink(&link_target, dst)?;
240        }
241        #[cfg(not(unix))]
242        {
243            // On non-Unix, try a regular copy as fallback
244            fs::copy(src, dst)?;
245        }
246        // Preserve symlink ownership (timestamps are not preserved for symlinks by design)
247        #[cfg(unix)]
248        {
249            let c_path = std::ffi::CString::new(dst.as_os_str().as_encoded_bytes())
250                .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
251            // SAFETY: c_path is a valid NUL-terminated C string, uid/gid are valid u32 values.
252            let ret = unsafe { libc::lchown(c_path.as_ptr(), metadata.uid(), metadata.gid()) };
253            if ret != 0 {
254                let err = io::Error::last_os_error();
255                if err.raw_os_error() != Some(libc::EPERM) {
256                    return Err(err);
257                }
258            }
259        }
260    } else {
261        fs::copy(src, dst)?;
262        preserve_metadata(&metadata, dst)?;
263    }
264
265    Ok(())
266}
267
268/// Recursively remove a file or directory.
269fn remove_recursive(path: &Path) -> io::Result<()> {
270    let metadata = fs::symlink_metadata(path)?;
271    if metadata.is_dir() {
272        fs::remove_dir_all(path)
273    } else {
274        fs::remove_file(path)
275    }
276}