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>;