Skip to main content

trail/actions/
fs_ops.rs

1//! Filesystem mutation operations: rename, move, duplicate, delete, create.
2//!
3//! Higher-risk module — destructive operations always go through the
4//! confirmation flow the spec requires. All functions operate relative to a
5//! given `cwd` and return typed `FsError` values; they never unwrap or panic.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use thiserror::Error;
11
12// ── Error type ────────────────────────────────────────────────────────────────
13
14/// Where a deleted entry goes.
15///
16/// `dd` used to be `remove_dir_all` after a single `y`, which on a directory is
17/// as irreversible as it gets. The recycle bin is the platform's own answer to
18/// that, and it already solves the four problems a hand-rolled trash folder
19/// would have to: name collisions, moves across devices, unbounded growth, and
20/// when to purge.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum DeleteMode {
23    /// Hand the entry to the platform's recycle bin, from which the user can
24    /// restore it. The default.
25    Trash,
26    /// Unlink it. Nothing to restore.
27    Permanent,
28}
29
30impl DeleteMode {
31    /// Parses a `[general] delete_mode` value.
32    ///
33    /// Returns `None` for an unrecognised name so the config layer can report it
34    /// with the key that carried it.
35    pub fn parse(value: &str) -> Option<DeleteMode> {
36        match value.trim().to_ascii_lowercase().as_str() {
37            "trash" | "recycle" => Some(DeleteMode::Trash),
38            "permanent" | "delete" => Some(DeleteMode::Permanent),
39            _ => None,
40        }
41    }
42}
43
44/// Errors from filesystem mutation operations.
45#[derive(Debug, Error)]
46pub enum FsError {
47    /// A required source path does not exist or cannot be accessed.
48    #[error("source path not found: {0}")]
49    SourceNotFound(PathBuf),
50    /// The destination path already exists and would be overwritten.
51    #[error("destination already exists: {0}")]
52    DestExists(PathBuf),
53    /// The name contains invalid characters (e.g. path separators in a rename).
54    #[error("invalid name '{0}': {1}")]
55    InvalidName(String, &'static str),
56    /// An underlying I/O error.
57    #[error("{context}: {source}")]
58    Io {
59        context: String,
60        #[source]
61        source: std::io::Error,
62    },
63    /// The platform refused to move the entry to its recycle bin.
64    ///
65    /// Reported rather than silently falling back to an unlink: a user who asked
66    /// for a recoverable delete must not get an unrecoverable one because the
67    /// recycle bin was unavailable.
68    #[error("could not move {path} to the recycle bin: {source}")]
69    Trash {
70        /// The entry that was to be trashed.
71        path: PathBuf,
72        #[source]
73        source: trash::Error,
74    },
75}
76
77/// Convenience constructor for [`FsError::Io`].
78fn io_err(context: impl Into<String>, source: std::io::Error) -> FsError {
79    FsError::Io {
80        context: context.into(),
81        source,
82    }
83}
84
85// ── Create operations ─────────────────────────────────────────────────────────
86
87/// Creates a new directory named `name` inside `cwd`.
88///
89/// # Errors
90///
91/// Returns [`FsError::InvalidName`] if `name` contains a path separator.
92/// Returns [`FsError::DestExists`] if the directory already exists.
93/// Returns [`FsError::Io`] for other filesystem errors.
94pub fn mkdir(cwd: &Path, name: &str) -> Result<PathBuf, FsError> {
95    validate_simple_name(name)?;
96    let target = cwd.join(name);
97    if target.exists() {
98        return Err(FsError::DestExists(target));
99    }
100    fs::create_dir(&target).map_err(|e| io_err(format!("mkdir {name}"), e))?;
101    Ok(target)
102}
103
104/// Creates an empty file named `name` inside `cwd`.
105///
106/// # Errors
107///
108/// Returns [`FsError::InvalidName`] if `name` contains a path separator.
109/// Returns [`FsError::DestExists`] if the file already exists.
110/// Returns [`FsError::Io`] for other filesystem errors.
111pub fn touch(cwd: &Path, name: &str) -> Result<PathBuf, FsError> {
112    validate_simple_name(name)?;
113    let target = cwd.join(name);
114    if target.exists() {
115        return Err(FsError::DestExists(target));
116    }
117    fs::write(&target, b"").map_err(|e| io_err(format!("touch {name}"), e))?;
118    Ok(target)
119}
120
121// ── Rename / move / copy ──────────────────────────────────────────────────────
122
123/// Renames `source` to `new_name` within the same directory.
124///
125/// `new_name` must be a plain filename (no path separators). The rename is
126/// atomic on most platforms (POSIX `rename(2)`).
127///
128/// # Errors
129///
130/// Returns [`FsError::SourceNotFound`] if `source` does not exist.
131/// Returns [`FsError::InvalidName`] if `new_name` contains path separators.
132/// Returns [`FsError::DestExists`] if the target name is already in use.
133/// Returns [`FsError::Io`] for other filesystem errors.
134pub fn rename(source: &Path, new_name: &str) -> Result<PathBuf, FsError> {
135    if !source.exists() {
136        return Err(FsError::SourceNotFound(source.to_owned()));
137    }
138    validate_simple_name(new_name)?;
139    let dest = source.parent().unwrap_or(Path::new(".")).join(new_name);
140    if dest.exists() {
141        return Err(FsError::DestExists(dest));
142    }
143    fs::rename(source, &dest).map_err(|e| io_err(format!("rename → {new_name}"), e))?;
144    Ok(dest)
145}
146
147/// The path `source` would land on for a `:mv`/`:cp` to `dest_str`.
148///
149/// `dest_str` is resolved against `cwd` when relative. An existing directory
150/// means "into it, under the same name"; anything else is the new path itself.
151/// Only stats the destination, so it is cheap enough for the UI thread — the
152/// copy or move itself is not.
153pub fn resolve_target(source: &Path, dest_str: impl AsRef<Path>, cwd: &Path) -> PathBuf {
154    let raw_dest = resolve_dest(dest_str, cwd);
155    if raw_dest.is_dir() {
156        raw_dest.join(source.file_name().unwrap_or_default())
157    } else {
158        raw_dest
159    }
160}
161
162/// Moves `source` to exactly `dest`, across drives if it has to.
163///
164/// A rename first, since on one volume that is instant and atomic. A rename
165/// cannot cross volumes, though — `C:` to `D:` on Windows, or two mounts
166/// elsewhere — and "move here" from another drive is the common case rather
167/// than the odd one. So when the rename fails for that reason the entry is
168/// copied and the source removed, and the source is removed **only** once the
169/// copy has fully succeeded.
170///
171/// The caller checks that `dest` is free; this does not overwrite, but it does
172/// not re-check either.
173///
174/// # Errors
175///
176/// Returns [`FsError::Io`] if the rename fails for any other reason, if the
177/// fallback copy fails (the partial copy is removed, the source is untouched),
178/// or if the source cannot be removed after a successful copy (both then
179/// exist, and the message says so).
180pub fn move_path(source: &Path, dest: &Path) -> Result<(), FsError> {
181    move_path_with(source, dest, |from, to| fs::rename(from, to))
182}
183
184/// [`move_path`] with the rename injected, so the cross-drive fallback can be
185/// tested on a machine with one drive.
186fn move_path_with(
187    source: &Path,
188    dest: &Path,
189    rename: impl Fn(&Path, &Path) -> std::io::Result<()>,
190) -> Result<(), FsError> {
191    let err = match rename(source, dest) {
192        Ok(()) => return Ok(()),
193        Err(e) => e,
194    };
195    if !is_cross_device(&err) {
196        return Err(io_err(format!("mv → {}", dest.display()), err));
197    }
198
199    if let Err(e) = copy_path(source, dest) {
200        // `dest` was free before the copy started, so whatever is there now is
201        // the half-finished copy. Leaving it would make a retry fail with
202        // "destination already exists".
203        let _ = remove_path(dest);
204        return Err(e);
205    }
206    remove_path(source).map_err(|e| FsError::Io {
207        context: format!(
208            "copied to {} but could not remove the original {}",
209            dest.display(),
210            source.display()
211        ),
212        source: e,
213    })
214}
215
216/// Whether a rename failed because source and destination are on different
217/// volumes — the one failure a copy can get around.
218///
219/// Matched on the raw OS code: `io::ErrorKind::CrossesDevices` is newer than
220/// this crate's minimum Rust version.
221fn is_cross_device(err: &std::io::Error) -> bool {
222    // ERROR_NOT_SAME_DEVICE on Windows, EXDEV elsewhere.
223    let code = if cfg!(windows) { 17 } else { 18 };
224    err.raw_os_error() == Some(code)
225}
226
227/// Copies `source` — a file, or a directory and everything under it — to
228/// exactly `dest`.
229///
230/// # Errors
231///
232/// Returns [`FsError::InvalidName`] if `dest` is inside `source`: copying a
233/// directory into itself never finishes, because every level it creates is
234/// another level to copy. Returns [`FsError::Io`] for filesystem errors.
235pub fn copy_path(source: &Path, dest: &Path) -> Result<(), FsError> {
236    if source.is_dir() {
237        if dest.starts_with(source) {
238            return Err(FsError::InvalidName(
239                dest.display().to_string(),
240                "cannot copy a directory into itself",
241            ));
242        }
243        copy_dir_recursive(source, dest)
244    } else {
245        fs::copy(source, dest)
246            .map(|_| ())
247            .map_err(|e| io_err(format!("cp → {}", dest.display()), e))
248    }
249}
250
251/// Unlinks `path`, recursively for a directory. No recycle bin: this is the
252/// second half of a move, not a delete the user asked for.
253fn remove_path(path: &Path) -> std::io::Result<()> {
254    if path.is_dir() {
255        fs::remove_dir_all(path)
256    } else {
257        fs::remove_file(path)
258    }
259}
260
261/// Recursively copies directory `src` to `dst`.
262fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), FsError> {
263    fs::create_dir(dst).map_err(|e| io_err(format!("mkdir {}", dst.display()), e))?;
264    for entry in fs::read_dir(src).map_err(|e| io_err(format!("readdir {}", src.display()), e))? {
265        let entry = entry.map_err(|e| io_err("dir entry", e))?;
266        let ty = entry.file_type().map_err(|e| io_err("file_type", e))?;
267        let dest_path = dst.join(entry.file_name());
268        if ty.is_dir() {
269            copy_dir_recursive(&entry.path(), &dest_path)?;
270        } else {
271            fs::copy(entry.path(), &dest_path)
272                .map_err(|e| io_err(format!("cp {}", entry.path().display()), e))?;
273        }
274    }
275    Ok(())
276}
277
278// ── Delete ────────────────────────────────────────────────────────────────────
279
280/// Deletes `target` — file or directory (recursively) — the way `mode` says.
281///
282/// [`DeleteMode::Trash`] is recoverable by the user through their desktop's
283/// recycle bin; [`DeleteMode::Permanent`] is not recoverable at all, and on a
284/// directory it takes everything under it. Either way the caller is responsible
285/// for the confirmation prompt first; that flow lives in `actions/mod.rs` and
286/// `ui/status_bar.rs`, and the prompt says which of the two is about to happen.
287///
288/// # Errors
289///
290/// Returns [`FsError::SourceNotFound`] if `target` does not exist.
291/// Returns [`FsError::Trash`] if the recycle bin refused the entry — never
292/// falling back to an unlink, since that would turn a recoverable delete the
293/// user asked for into one that is not.
294/// Returns [`FsError::Io`] for other filesystem errors.
295pub fn delete(target: &Path, mode: DeleteMode) -> Result<(), FsError> {
296    if !target.exists() {
297        return Err(FsError::SourceNotFound(target.to_owned()));
298    }
299    match mode {
300        DeleteMode::Trash => trash::delete(target).map_err(|e| FsError::Trash {
301            path: target.to_owned(),
302            source: e,
303        }),
304        DeleteMode::Permanent => {
305            if target.is_dir() {
306                fs::remove_dir_all(target)
307                    .map_err(|e| io_err(format!("delete dir {}", target.display()), e))
308            } else {
309                fs::remove_file(target)
310                    .map_err(|e| io_err(format!("delete file {}", target.display()), e))
311            }
312        }
313    }
314}
315
316// ── Wildcard matching ─────────────────────────────────────────────────────────
317
318/// Whether `token` asks to be matched against several names rather than naming
319/// one.
320///
321/// `*` and `?` are not legal in a Windows filename at all, and are rare enough
322/// in a Unix one that reading them as wildcards is what a user typing them into
323/// a file manager means. This is what lets `:mv` keep its one-argument form —
324/// `:mv ../dest` still moves the selection — and grow a two-argument one without
325/// the two becoming ambiguous.
326pub fn is_pattern(token: &str) -> bool {
327    token.contains('*') || token.contains('?')
328}
329
330/// Entries of `cwd` whose names match `pattern`, in listing order.
331///
332/// `*` matches any run of characters including none, `?` exactly one. Matching is
333/// case-insensitive on Windows, where file names are, and case-sensitive
334/// elsewhere, where they are not.
335///
336/// A leading dot is protected the way a shell protects it: `*` does not match a
337/// hidden name unless the pattern itself starts with a dot. Otherwise `:mv *`
338/// in a repository would quietly take `.git` with it.
339///
340/// # Errors
341///
342/// Returns [`FsError::Io`] if `cwd` cannot be read.
343pub fn matching_entries(cwd: &Path, pattern: &str) -> Result<Vec<PathBuf>, FsError> {
344    let read = fs::read_dir(cwd).map_err(|e| io_err(format!("readdir {}", cwd.display()), e))?;
345    let include_hidden = pattern.starts_with('.');
346
347    let mut matches: Vec<PathBuf> = read
348        .filter_map(|entry| entry.ok())
349        .filter_map(|entry| {
350            let name = entry.file_name().into_string().ok()?;
351            if name.starts_with('.') && !include_hidden {
352                return None;
353            }
354            matches_pattern(&name, pattern).then(|| entry.path())
355        })
356        .collect();
357
358    matches.sort();
359    Ok(matches)
360}
361
362/// Whether `name` matches `pattern`, with `*` and `?` as the only wildcards.
363///
364/// Iterative with one backtrack point, so a pattern full of stars cannot make
365/// this blow up the way a naive recursive matcher does: `last_star` remembers
366/// where to resume, which is all the backtracking `*` needs.
367fn matches_pattern(name: &str, pattern: &str) -> bool {
368    // Windows file names are case-insensitive, so a pattern typed in either case
369    // has to match either case. Elsewhere the filesystem is literal and so is
370    // this.
371    let (name, pattern) = if cfg!(windows) {
372        (name.to_lowercase(), pattern.to_lowercase())
373    } else {
374        (name.to_owned(), pattern.to_owned())
375    };
376    let name: Vec<char> = name.chars().collect();
377    let pattern: Vec<char> = pattern.chars().collect();
378
379    let mut n = 0; // index into `name`
380    let mut p = 0; // index into `pattern`
381    let mut last_star: Option<(usize, usize)> = None;
382
383    while n < name.len() {
384        match pattern.get(p) {
385            Some('*') => {
386                // Try the shortest match first and come back for more if the
387                // rest of the pattern fails.
388                last_star = Some((p, n));
389                p += 1;
390            }
391            Some('?') => {
392                p += 1;
393                n += 1;
394            }
395            Some(&ch) if ch == name[n] => {
396                p += 1;
397                n += 1;
398            }
399            // Mismatch: let the last `*` swallow one more character, if there
400            // was one. If not, this name does not match.
401            _ => match last_star {
402                Some((star_p, star_n)) => {
403                    p = star_p + 1;
404                    n = star_n + 1;
405                    last_star = Some((star_p, n));
406                }
407                None => return false,
408            },
409        }
410    }
411
412    // Any pattern left over may only be stars.
413    pattern[p..].iter().all(|&ch| ch == '*')
414}
415
416// ── Helpers ───────────────────────────────────────────────────────────────────
417
418/// Validates that `name` is a simple, non-path component (no separators).
419fn validate_simple_name(name: &str) -> Result<(), FsError> {
420    if name.is_empty() {
421        return Err(FsError::InvalidName(
422            name.to_owned(),
423            "name must not be empty",
424        ));
425    }
426    if name.contains('/') || name.contains('\\') {
427        return Err(FsError::InvalidName(
428            name.to_owned(),
429            "name must not contain path separators",
430        ));
431    }
432    Ok(())
433}
434
435/// Resolves a destination string to an absolute path, treating relative paths
436/// as relative to `cwd`. Does not touch the filesystem.
437pub fn resolve_dest(dest_str: impl AsRef<Path>, cwd: &Path) -> PathBuf {
438    let p = dest_str.as_ref().to_path_buf();
439    if p.is_absolute() {
440        p
441    } else {
442        cwd.join(p)
443    }
444}
445
446// ── Unit tests ────────────────────────────────────────────────────────────────
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use std::fs;
452    use tempfile::TempDir;
453
454    fn tmp() -> TempDir {
455        tempfile::tempdir().expect("tempdir")
456    }
457
458    /// What a `:mv` does with one entry, minus the background thread.
459    fn mv(source: &Path, dest: &str, cwd: &Path) -> Result<PathBuf, FsError> {
460        let target = resolve_target(source, dest, cwd);
461        move_path(source, &target)?;
462        Ok(target)
463    }
464
465    /// What a `:cp` does with one entry, minus the background thread.
466    fn cp(source: &Path, dest: &str, cwd: &Path) -> Result<PathBuf, FsError> {
467        let target = resolve_target(source, dest, cwd);
468        copy_path(source, &target)?;
469        Ok(target)
470    }
471
472    // ── mkdir ──────────────────────────────────────────────────────────────────
473
474    #[test]
475    fn mkdir_creates_directory() {
476        let dir = tmp();
477        let created = mkdir(dir.path(), "new_dir").unwrap();
478        assert!(created.is_dir());
479    }
480
481    #[test]
482    fn mkdir_rejects_path_separator() {
483        let dir = tmp();
484        let err = mkdir(dir.path(), "foo/bar").unwrap_err();
485        assert!(matches!(err, FsError::InvalidName(..)));
486    }
487
488    #[test]
489    fn mkdir_fails_if_exists() {
490        let dir = tmp();
491        fs::create_dir(dir.path().join("exists")).unwrap();
492        let err = mkdir(dir.path(), "exists").unwrap_err();
493        assert!(matches!(err, FsError::DestExists(_)));
494    }
495
496    // ── touch ─────────────────────────────────────────────────────────────────
497
498    #[test]
499    fn touch_creates_empty_file() {
500        let dir = tmp();
501        let created = touch(dir.path(), "new.txt").unwrap();
502        assert!(created.is_file());
503        assert_eq!(fs::read(created).unwrap(), b"");
504    }
505
506    #[test]
507    fn touch_rejects_path_separator() {
508        let dir = tmp();
509        let err = touch(dir.path(), "foo/bar.txt").unwrap_err();
510        assert!(matches!(err, FsError::InvalidName(..)));
511    }
512
513    // ── rename ────────────────────────────────────────────────────────────────
514
515    #[test]
516    fn rename_renames_file() {
517        let dir = tmp();
518        let src = dir.path().join("old.txt");
519        fs::write(&src, b"content").unwrap();
520        let dest = rename(&src, "new.txt").unwrap();
521        assert!(!src.exists());
522        assert!(dest.is_file());
523    }
524
525    #[test]
526    fn rename_rejects_slash_in_name() {
527        let dir = tmp();
528        let src = dir.path().join("old.txt");
529        fs::write(&src, b"").unwrap();
530        let err = rename(&src, "new/name.txt").unwrap_err();
531        assert!(matches!(err, FsError::InvalidName(..)));
532    }
533
534    #[test]
535    fn rename_fails_if_source_missing() {
536        let dir = tmp();
537        let err = rename(&dir.path().join("missing.txt"), "new.txt").unwrap_err();
538        assert!(matches!(err, FsError::SourceNotFound(_)));
539    }
540
541    // ── mv ────────────────────────────────────────────────────────────────────
542
543    #[test]
544    fn mv_moves_file() {
545        let dir = tmp();
546        let src = dir.path().join("src.txt");
547        fs::write(&src, b"hello").unwrap();
548        let dest = mv(&src, "dest.txt", dir.path()).unwrap();
549        assert!(!src.exists());
550        assert!(dest.is_file());
551    }
552
553    #[test]
554    fn mv_into_existing_directory() {
555        let dir = tmp();
556        let src = dir.path().join("file.txt");
557        fs::write(&src, b"").unwrap();
558        let sub = dir.path().join("sub");
559        fs::create_dir(&sub).unwrap();
560        let dest = mv(&src, "sub", dir.path()).unwrap();
561        assert_eq!(dest, sub.join("file.txt"));
562    }
563
564    // ── cp ────────────────────────────────────────────────────────────────────
565
566    #[test]
567    fn cp_copies_file() {
568        let dir = tmp();
569        let src = dir.path().join("src.txt");
570        fs::write(&src, b"data").unwrap();
571        let dest = cp(&src, "copy.txt", dir.path()).unwrap();
572        assert!(src.exists());
573        assert!(dest.is_file());
574        assert_eq!(fs::read(dest).unwrap(), b"data");
575    }
576
577    #[test]
578    fn cp_copies_directory_recursively() {
579        let dir = tmp();
580        let src = dir.path().join("srcdir");
581        fs::create_dir(&src).unwrap();
582        fs::write(src.join("file.txt"), b"hi").unwrap();
583        let dest = cp(&src, "dstdir", dir.path()).unwrap();
584        assert!(dest.join("file.txt").exists());
585    }
586
587    #[test]
588    fn cp_refuses_to_copy_a_directory_into_itself() {
589        let dir = tmp();
590        let src = dir.path().join("srcdir");
591        fs::create_dir(&src).unwrap();
592        let err = cp(&src, "srcdir", dir.path()).unwrap_err();
593        assert!(matches!(err, FsError::InvalidName(..)), "{err}");
594    }
595
596    // ── move across drives ────────────────────────────────────────────────────
597
598    /// The rename failure a second volume gives. There is no second volume in
599    /// CI, so it is injected.
600    fn cross_device(_: &Path, _: &Path) -> std::io::Result<()> {
601        let code = if cfg!(windows) { 17 } else { 18 };
602        Err(std::io::Error::from_raw_os_error(code))
603    }
604
605    #[test]
606    fn a_move_across_drives_copies_then_removes_the_source() {
607        let dir = tmp();
608        let src = dir.path().join("tree");
609        fs::create_dir(&src).unwrap();
610        fs::write(src.join("leaf.txt"), b"leaf").unwrap();
611        let dest = dir.path().join("moved");
612
613        move_path_with(&src, &dest, cross_device).unwrap();
614
615        assert!(!src.exists(), "the source goes once the copy is complete");
616        assert_eq!(fs::read(dest.join("leaf.txt")).unwrap(), b"leaf");
617    }
618
619    #[test]
620    fn a_failed_copy_keeps_the_source_and_cleans_up_the_partial_copy() {
621        let dir = tmp();
622        let src = dir.path().join("tree");
623        fs::create_dir(&src).unwrap();
624        fs::write(src.join("leaf.txt"), b"leaf").unwrap();
625        // A destination inside the source makes the copy fail.
626        let dest = src.join("inside");
627
628        assert!(move_path_with(&src, &dest, cross_device).is_err());
629        assert!(src.join("leaf.txt").exists(), "the original is untouched");
630        assert!(!dest.exists());
631    }
632
633    #[test]
634    fn any_other_rename_failure_is_reported_without_copying() {
635        let dir = tmp();
636        let src = dir.path().join("file.txt");
637        fs::write(&src, b"x").unwrap();
638        let dest = dir.path().join("dest.txt");
639
640        let denied = |_: &Path, _: &Path| {
641            Err(std::io::Error::new(
642                std::io::ErrorKind::PermissionDenied,
643                "denied",
644            ))
645        };
646        assert!(move_path_with(&src, &dest, denied).is_err());
647        assert!(src.exists());
648        assert!(
649            !dest.exists(),
650            "only a cross-drive failure falls back to a copy"
651        );
652    }
653
654    // ── delete ────────────────────────────────────────────────────────────────
655
656    #[test]
657    fn delete_removes_file() {
658        let dir = tmp();
659        let f = dir.path().join("to_delete.txt");
660        fs::write(&f, b"").unwrap();
661        delete(&f, DeleteMode::Permanent).unwrap();
662        assert!(!f.exists());
663    }
664
665    #[test]
666    fn delete_removes_directory_recursively() {
667        let dir = tmp();
668        let d = dir.path().join("to_delete");
669        fs::create_dir(&d).unwrap();
670        fs::write(d.join("child.txt"), b"").unwrap();
671        delete(&d, DeleteMode::Permanent).unwrap();
672        assert!(!d.exists());
673    }
674
675    #[test]
676    fn delete_fails_if_missing() {
677        let dir = tmp();
678        // Checked before either mode is consulted, so one case covers both.
679        let err = delete(&dir.path().join("nope"), DeleteMode::Trash).unwrap_err();
680        assert!(matches!(err, FsError::SourceNotFound(_)));
681    }
682
683    // -- wildcard matching ----------------------------------------------------
684
685    #[test]
686    fn a_star_matches_any_run_including_none() {
687        assert!(matches_pattern("notes.md", "*.md"));
688        assert!(matches_pattern(".md", "*.md"));
689        assert!(matches_pattern("a.md", "*"));
690        assert!(!matches_pattern("notes.txt", "*.md"));
691        assert!(matches_pattern("README", "*"));
692    }
693
694    #[test]
695    fn a_question_mark_matches_exactly_one_character() {
696        assert!(matches_pattern("a.md", "?.md"));
697        assert!(!matches_pattern("ab.md", "?.md"));
698        assert!(!matches_pattern(".md", "?.md"));
699    }
700
701    /// The case a naive matcher gets wrong: a `*` has to give characters back
702    /// when what follows it fails to line up.
703    #[test]
704    fn a_star_backtracks_when_the_tail_does_not_fit() {
705        assert!(matches_pattern("archive.tar.gz", "*.tar.gz"));
706        assert!(matches_pattern("a.b.c.d", "*.d"));
707        assert!(matches_pattern("xxxxab", "*ab"));
708        assert!(!matches_pattern("xxxxabc", "*ab"));
709        assert!(matches_pattern("abcabcabd", "*abc*abd"));
710        // Many stars must not make this fall over.
711        assert!(matches_pattern("aaaaaaaaaaaaaaaaaaaab", "*a*a*a*a*a*b"));
712        assert!(!matches_pattern("aaaaaaaaaaaaaaaaaaaa", "*a*a*a*a*a*b"));
713    }
714
715    #[test]
716    fn a_pattern_is_recognised_by_its_wildcards() {
717        assert!(is_pattern("*.md"));
718        assert!(is_pattern("note?.txt"));
719        // A destination with a space in it is not a pattern, which is what keeps
720        // the one-argument form of `:mv` working.
721        assert!(!is_pattern("my"));
722        assert!(!is_pattern("../backup"));
723    }
724
725    #[test]
726    fn matching_entries_skips_hidden_names_unless_asked() {
727        let dir = tmp();
728        fs::write(dir.path().join("one.md"), b"").unwrap();
729        fs::write(dir.path().join("two.md"), b"").unwrap();
730        fs::write(dir.path().join(".hidden.md"), b"").unwrap();
731
732        let visible = matching_entries(dir.path(), "*.md").unwrap();
733        assert_eq!(visible.len(), 2, "a bare `*` must not take hidden files");
734
735        let hidden = matching_entries(dir.path(), ".*.md").unwrap();
736        assert_eq!(
737            hidden.len(),
738            1,
739            "a pattern starting with a dot asks for them"
740        );
741        assert!(hidden[0].ends_with(".hidden.md"));
742    }
743
744    /// The recycle bin itself is not exercised here: it is per-user desktop
745    /// state, and a test suite that fills someone's bin with fixtures — or that
746    /// depends on the machine having one — is exactly the ambient-state
747    /// dependency the coding standard rules out. What is testable is that the
748    /// config can only ask for one of the two behaviours.
749    #[test]
750    fn delete_modes_parse_from_what_the_config_can_say() {
751        assert_eq!(DeleteMode::parse("trash"), Some(DeleteMode::Trash));
752        assert_eq!(DeleteMode::parse(" TRASH "), Some(DeleteMode::Trash));
753        assert_eq!(DeleteMode::parse("recycle"), Some(DeleteMode::Trash));
754        assert_eq!(DeleteMode::parse("permanent"), Some(DeleteMode::Permanent));
755        assert_eq!(DeleteMode::parse("delete"), Some(DeleteMode::Permanent));
756        assert_eq!(DeleteMode::parse("bin"), None);
757        assert_eq!(DeleteMode::parse(""), None);
758    }
759}