Skip to main content

file_engine/
error.rs

1use std::io;
2use std::path::PathBuf;
3
4use thiserror::Error;
5
6#[derive(Debug, Error)]
7pub enum Error {
8    #[error("source not found: {path}")]
9    SourceNotFound { path: PathBuf },
10
11    #[error("destination already exists: {path}")]
12    DestExists { path: PathBuf },
13
14    #[error("operation cancelled")]
15    Cancelled,
16
17    #[error("insufficient disk space: needed {needed} bytes, available {available} bytes")]
18    NoSpace { needed: u64, available: u64 },
19
20    #[error("permission denied: {path}")]
21    PermissionDenied { path: PathBuf },
22
23    #[error("io error on {path}: {source}")]
24    Io { path: PathBuf, source: io::Error },
25
26    #[cfg(feature = "compress")]
27    #[error("could not infer compression format from destination: {path}")]
28    UnknownCompressFormat { path: PathBuf },
29
30    // Constructed by `analyze`'s `AnalysisFilter` and `remove`'s
31    // `RemoveFilter` independently (neither implies the other) — gated
32    // on the union of both, same reasoning as `classify_io_error` below.
33    #[cfg(any(feature = "analyze", feature = "remove"))]
34    #[error("invalid glob pattern {pattern:?}: {source}")]
35    InvalidGlobPattern {
36        pattern: String,
37        source: globset::Error,
38    },
39
40    #[cfg(feature = "compress")]
41    #[error("gzip compression requires a single file, got a directory: {path}")]
42    GzipRequiresFile { path: PathBuf },
43
44    // The four variants below back the pre-flight validation in
45    // `profiler::validate` — gated on `operations` since that's the
46    // only feature that ever constructs them, matching the
47    // `compress`-gated variants above.
48    #[cfg(feature = "operations")]
49    #[error("filename differs only by case from another entry, which the destination filesystem cannot represent: {path} collides with {other}")]
50    CaseCollision { path: PathBuf, other: PathBuf },
51
52    #[cfg(feature = "operations")]
53    #[error("file exceeds the destination filesystem's maximum file size: {path} ({size} bytes, max {max} bytes)")]
54    FileTooLargeForDest { path: PathBuf, size: u64, max: u64 },
55
56    #[cfg(feature = "operations")]
57    #[error("filename is reserved or invalid on the destination filesystem: {path}")]
58    ReservedName { path: PathBuf },
59
60    /// Unlike the three variants above (per-entry, governed by
61    /// `ErrorStrategy`), this describes a whole-destination risk, not a
62    /// property of any specific entry — `is_fatal` accordingly.
63    #[cfg(feature = "operations")]
64    #[error(
65        "destination filesystem ({filesystem}) has a known write-integrity issue on this platform"
66    )]
67    FilesystemIntegrityRisk { filesystem: String },
68
69    /// Whole-batch pre-flight validation for `MoveManyBuilder`, same
70    /// spirit as `FilesystemIntegrityRisk` above: two sources sharing a
71    /// basename is ambiguous ("moved into `dest`" would mean two
72    /// different things), so it's caught before any source is touched
73    /// rather than surfacing as a confusing overwrite of one by the
74    /// other partway through. `is_fatal` accordingly.
75    #[cfg(feature = "operations")]
76    #[error("two sources would both move to the same destination name: {path} and {other}")]
77    DuplicateSourceName { path: PathBuf, other: PathBuf },
78
79    /// `MoveManyBuilder` pre-flight validation: a source with no final
80    /// path component (`/`, `.`, `..`, ...) has nothing to name its
81    /// destination entry after. `is_fatal`, same reasoning as
82    /// `DuplicateSourceName`.
83    #[cfg(feature = "operations")]
84    #[error("source path has no file name to move under: {path}")]
85    InvalidSourceName { path: PathBuf },
86
87    /// `RemoveBuilder` pre-flight validation: `.start()` refuses to run
88    /// with no filter criteria set at all, since an empty
89    /// `RemoveFilter` matches every entry under the root — "delete
90    /// everything" should never be the accidental default of an
91    /// unconfigured builder. `.allow_unfiltered_delete(true)` is the
92    /// explicit opt-in past this.
93    #[cfg(feature = "remove")]
94    #[error(
95        "remove() called with no filter criteria set, which would match every entry under the root"
96    )]
97    RemoveCriteriaRequired,
98
99    /// Per-entry: `RemoveBuilder` defaults to moving matched entries to
100    /// the platform trash/recycle bin rather than unlinking them
101    /// outright (see `RemoveBuilder::hard_delete`). Surfaced instead of
102    /// silently falling back to a hard delete, since that fallback would
103    /// defeat the point of trash being the safe default — a platform or
104    /// environment with no trash service (e.g. a headless Linux box)
105    /// must fail loudly here, not delete permanently without being
106    /// asked to.
107    #[cfg(feature = "remove")]
108    #[error("could not move to trash: {path}: {source}")]
109    TrashFailed { path: PathBuf, source: trash::Error },
110}
111
112impl Error {
113    /// Fatal errors stop all remaining dispatch regardless of the
114    /// configured `ErrorStrategy`; per-entry errors are handled per that
115    /// strategy. `PermissionDenied`/`Io` default to per-entry despite
116    /// being genuinely ambiguous.
117    ///
118    /// Only consumed by `operations`-gated code (`dispatcher.rs`,
119    /// `move_path.rs`, `sync.rs`, `compress.rs`) — gated to match, since
120    /// a `watch`-only build (which doesn't imply `operations`) would
121    /// otherwise leave this genuinely dead and fail the crate's
122    /// warnings-as-errors build (`.cargo/config.toml`).
123    #[cfg(feature = "operations")]
124    pub(crate) fn is_fatal(&self) -> bool {
125        matches!(
126            self,
127            Error::Cancelled
128                | Error::NoSpace { .. }
129                | Error::FilesystemIntegrityRisk { .. }
130                | Error::DuplicateSourceName { .. }
131                | Error::InvalidSourceName { .. }
132        )
133    }
134}
135
136/// Maps a raw `io::Error` onto the crate's `Error` variants — shared by
137/// every module that turns a filesystem call's `io::Error` into one.
138/// Previously duplicated ten times, nearly identically, across
139/// `operations`/`profiler`/`analysis` (see
140/// `dev-docs/design/error-classification-audit.md`'s "Research"
141/// section); consolidated here specifically because `error.rs` is the
142/// one module every feature combination compiles unconditionally —
143/// `profiler::scan`'s copy of this was gated behind `operations`, which
144/// is exactly why `analysis::util` couldn't reuse it and grew its own.
145///
146/// `needed` is only meaningful for the `StorageFull` arm (`NoSpace`'s
147/// `needed` field); callers with no real figure to hand pass `0` rather
148/// than fabricating one, matching every pre-consolidation call site
149/// except `planner::action`'s (which has the entry's real size).
150/// `available` isn't queried at this level either (would need an extra
151/// statvfs-style syscall) so it's always reported as `0`.
152///
153/// Gated on the union of every feature that actually calls this
154/// (`operations`, `watch`, `analyze` independently — none of the three
155/// implies another) rather than left unconditional, so a build enabling
156/// none of them (e.g. `--no-default-features --features diagnostics`,
157/// exercised by CI's feature-powerset check) doesn't fail on dead code.
158#[cfg(any(feature = "operations", feature = "watch", feature = "analyze"))]
159pub(crate) fn classify_io_error(err: io::Error, path: PathBuf, needed: u64) -> Error {
160    match err.kind() {
161        io::ErrorKind::NotFound => Error::SourceNotFound { path },
162        io::ErrorKind::PermissionDenied => Error::PermissionDenied { path },
163        io::ErrorKind::StorageFull => Error::NoSpace {
164            needed,
165            available: 0,
166        },
167        _ => Error::Io { path, source: err },
168    }
169}
170
171pub type Result<T> = std::result::Result<T, Error>;