Skip to main content

qubit_fs/error/
fs_error.rs

1// =============================================================================
2//    Copyright (c) 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8// facade tests.
9//! Concrete filesystem error type.
10
11use std::error::Error;
12use std::fmt::Debug;
13use std::fmt::Display;
14use std::fmt::Formatter;
15use std::fmt::Result as FmtResult;
16use std::io;
17
18use crate::error::FsEffectState;
19use crate::error::FsErrorKind;
20use crate::error::FsOperation;
21use crate::metadata::FileSystemCapability;
22use crate::path::Path;
23
24/// Provider-neutral filesystem error with operation and path context.
25///
26/// [`Debug`] and [`Display`] never expand the retained source error because a
27/// lower-level SDK or transport diagnostic may contain credentials. `Debug`
28/// reports only whether a source exists; explicit diagnostic code may inspect
29/// it through [`Error::source`]. The message supplied by constructors must
30/// already be scrubbed of secret material.
31///
32/// # Examples
33///
34/// ```
35/// use qubit_fs::Path;
36/// use qubit_fs::error::FsError;
37/// use qubit_fs::error::FsErrorKind;
38/// use qubit_fs::error::FsOperation;
39///
40/// let path = Path::parse("/reports/latest.csv")?;
41/// let error = FsError::new(FsErrorKind::NotFound, FsOperation::Stat, "object missing")
42///     .with_path(path.clone());
43/// assert_eq!(FsErrorKind::NotFound, error.kind());
44/// assert_eq!(Some(&path), error.path());
45/// # Ok::<(), qubit_fs::FsError>(())
46/// ```
47pub struct FsError {
48    /// Error category.
49    kind: FsErrorKind,
50    /// Operation that produced the error.
51    operation: FsOperation,
52    /// Concrete path where the operation failed.
53    path: Option<Box<Path>>,
54    /// Secondary path involved in the operation.
55    target: Option<Box<Path>>,
56    /// Concrete source entry where a structured operation failed.
57    failure_path: Option<Box<Path>>,
58    /// Concrete destination entry where a structured operation failed.
59    failure_target: Option<Box<Path>>,
60    /// Provider id or alias involved in the operation.
61    provider: Option<Box<str>>,
62    /// Capability needed to satisfy the request, when applicable.
63    required_capability: Option<FileSystemCapability>,
64    /// Strongest known external effect of the failed operation.
65    effect_state: Option<FsEffectState>,
66    /// Human-readable, non-sensitive error message.
67    message: Box<str>,
68    /// Lower-level source error, excluded from automatic formatting.
69    source: Option<Box<dyn Error + Send + Sync + 'static>>,
70}
71
72impl FsError {
73    /// Creates a filesystem error without path or provider context.
74    ///
75    /// # Parameters
76    /// - `kind`: Provider-neutral error category.
77    /// - `operation`: Operation that produced the error.
78    /// - `message`: Human-readable diagnostic message that must not contain
79    ///   credentials or other secret material.
80    ///
81    /// # Returns
82    /// New filesystem error.
83    #[inline]
84    #[must_use]
85    pub fn new(kind: FsErrorKind, operation: FsOperation, message: &str) -> Self {
86        Self {
87            kind,
88            operation,
89            path: None,
90            target: None,
91            failure_path: None,
92            failure_target: None,
93            provider: None,
94            required_capability: None,
95            effect_state: None,
96            message: message.into(),
97            source: None,
98        }
99    }
100
101    /// Creates a filesystem error that wraps a lower-level source error.
102    ///
103    /// # Parameters
104    /// - `kind`: Provider-neutral error category.
105    /// - `operation`: Operation that produced the error.
106    /// - `message`: Human-readable diagnostic message that must not contain
107    ///   credentials or other secret material.
108    /// - `source`: Lower-level error to preserve. Its formatting may contain
109    ///   secrets and is therefore never expanded by this type's `Debug` or
110    ///   `Display` implementation.
111    ///
112    /// # Returns
113    /// New filesystem error with source context.
114    #[inline]
115    pub fn with_source<E>(kind: FsErrorKind, operation: FsOperation, message: &str, source: E) -> Self
116    where
117        E: Error + Send + Sync + 'static,
118    {
119        Self {
120            source: Some(Box::new(source)),
121            ..Self::new(kind, operation, message)
122        }
123    }
124
125    /// Adds primary path context.
126    ///
127    /// # Parameters
128    /// - `path`: Concrete path where the operation failed. For a two-path
129    ///   operation this may be either request path; use [`Self::target`] to
130    ///   identify the destination.
131    ///
132    /// # Returns
133    /// Updated filesystem error.
134    #[inline]
135    #[must_use]
136    pub fn with_path(mut self, path: impl Into<Path>) -> Self {
137        self.path = Some(Box::new(path.into()));
138        self
139    }
140
141    /// Rebinds the error to the public operation that was requested.
142    ///
143    /// This is useful for convenience operations implemented through another
144    /// primitive, such as `exists` implemented through `stat`, while retaining
145    /// all path, provider, capability, and source context.
146    ///
147    /// # Parameters
148    /// - `operation`: Public operation whose failure is being returned.
149    ///
150    /// # Returns
151    /// Updated filesystem error.
152    #[inline]
153    #[must_use]
154    pub fn with_operation(mut self, operation: FsOperation) -> Self {
155        self.operation = operation;
156        self
157    }
158
159    /// Adds secondary target path context.
160    ///
161    /// # Parameters
162    /// - `target`: Destination path of a two-path operation.
163    ///
164    /// # Returns
165    /// Updated filesystem error.
166    #[inline]
167    #[must_use]
168    pub fn with_target(mut self, target: impl Into<Path>) -> Self {
169        self.target = Some(Box::new(target.into()));
170        self
171    }
172
173    /// Adds the concrete source entry where a structured operation failed.
174    #[inline]
175    #[must_use]
176    pub fn with_failure_path(mut self, path: impl Into<Path>) -> Self {
177        self.failure_path = Some(Box::new(path.into()));
178        self
179    }
180
181    /// Adds the concrete destination entry where a structured operation failed.
182    #[inline]
183    #[must_use]
184    pub fn with_failure_target(mut self, target: impl Into<Path>) -> Self {
185        self.failure_target = Some(Box::new(target.into()));
186        self
187    }
188
189    /// Adds provider context.
190    ///
191    /// # Parameters
192    /// - `provider`: Canonical provider id involved in the operation.
193    ///
194    /// # Returns
195    /// Updated filesystem error.
196    #[inline]
197    #[must_use]
198    pub fn with_provider(mut self, provider: impl Display) -> Self {
199        self.provider = Some(provider.to_string().into());
200        self
201    }
202
203    /// Adds the capability required by an unsupported or unmet request.
204    ///
205    /// # Parameters
206    /// - `capability`: Stable capability required by the request.
207    ///
208    /// # Returns
209    /// Updated filesystem error.
210    #[inline]
211    #[must_use]
212    pub fn with_required_capability(mut self, capability: FileSystemCapability) -> Self {
213        self.required_capability = Some(capability);
214        self
215    }
216
217    /// Adds the strongest known external effect of the failed operation.
218    ///
219    /// # Parameters
220    ///
221    /// * `effect_state` - Provider-neutral effect state proven by the provider.
222    ///
223    /// # Returns
224    ///
225    /// Updated filesystem error.
226    #[inline]
227    #[must_use]
228    pub fn with_effect_state(mut self, effect_state: FsEffectState) -> Self {
229        self.effect_state = Some(effect_state);
230        self
231    }
232
233    /// Replaces untrusted session locations with a known cleanup request.
234    ///
235    /// Used only for quarantined sessions whose opening identity was invalid.
236    /// Diagnostic entry and target paths cannot be trusted in this case. The
237    /// original category, effect evidence, message, and source remain intact.
238    pub(crate) fn with_trusted_cleanup_context(
239        mut self,
240        operation: FsOperation,
241        path: Option<&Path>,
242        provider: &str,
243    ) -> Self {
244        self.operation = operation;
245        self.path = path.cloned().map(Box::new);
246        self.target = None;
247        self.failure_path = None;
248        self.failure_target = None;
249        self.provider = Some(provider.into());
250        self
251    }
252
253    /// Adds missing path, target, and provider context without overwriting
254    /// provider-supplied details.
255    ///
256    /// Core resource wrappers use this when an error crosses an abstraction
257    /// boundary. It preserves a provider's more specific context while making
258    /// generic validation and stream failures actionable to callers.
259    ///
260    /// # Parameters
261    /// - `path`: Fallback primary path for the requested operation.
262    /// - `target`: Fallback secondary path, when the operation has one.
263    /// - `provider`: Fallback canonical provider id.
264    ///
265    /// # Returns
266    /// Updated error with every previously absent context field filled.
267    #[inline]
268    #[must_use]
269    pub(crate) fn with_missing_context(mut self, path: &Path, target: Option<&Path>, provider: &str) -> Self {
270        if self.path.is_none() {
271            self.path = Some(Box::new(path.clone()));
272        }
273        if self.target.is_none() {
274            self.target = target.cloned().map(Box::new);
275        }
276        if self.provider.is_none() {
277            self.provider = Some(provider.into());
278        }
279        self
280    }
281
282    /// Adds provider context only when an error does not already carry it.
283    ///
284    /// This is used by operations that have no meaningful logical path, such
285    /// as provider capability checks performed before temporary resource
286    /// creation.
287    #[inline]
288    #[must_use]
289    pub(crate) fn with_missing_provider(mut self, provider: &str) -> Self {
290        if self.provider.is_none() {
291            self.provider = Some(provider.into());
292        }
293        self
294    }
295
296    /// Creates an invalid-path error.
297    ///
298    /// # Parameters
299    /// - `operation`: Operation that rejected the path.
300    /// - `message`: Human-readable reason.
301    ///
302    /// # Returns
303    /// Invalid-path filesystem error.
304    #[inline]
305    #[must_use]
306    pub fn invalid_path(operation: FsOperation, message: &str) -> Self {
307        Self::new(FsErrorKind::InvalidPath, operation, message)
308    }
309
310    /// Wraps a byte-stream error with filesystem operation context.
311    ///
312    /// # Parameters
313    /// - `error`: Lower-level stream error.
314    /// - `operation`: Filesystem operation in progress when it occurred.
315    ///
316    /// # Returns
317    /// A filesystem error retaining `error` as its source.
318    #[inline]
319    #[must_use]
320    pub fn from_io(error: io::Error, operation: FsOperation) -> Self {
321        let kind = match error.kind() {
322            io::ErrorKind::NotFound => FsErrorKind::NotFound,
323            io::ErrorKind::AlreadyExists => FsErrorKind::AlreadyExists,
324            io::ErrorKind::DirectoryNotEmpty => FsErrorKind::Conflict,
325            io::ErrorKind::NotADirectory => FsErrorKind::NotDirectory,
326            io::ErrorKind::IsADirectory => FsErrorKind::IsDirectory,
327            io::ErrorKind::PermissionDenied => FsErrorKind::PermissionDenied,
328            io::ErrorKind::InvalidInput => FsErrorKind::InvalidOptions,
329            io::ErrorKind::Unsupported => FsErrorKind::UnsupportedOperation,
330            io::ErrorKind::TimedOut => FsErrorKind::Timeout,
331            io::ErrorKind::Interrupted => FsErrorKind::Interrupted,
332            io::ErrorKind::StorageFull => FsErrorKind::QuotaExceeded,
333            io::ErrorKind::InvalidData => FsErrorKind::DataCorruption,
334            _ => FsErrorKind::Io,
335        };
336        Self::with_source(kind, operation, "stream I/O failed", error)
337    }
338
339    /// Restores a filesystem error transported through an I/O boundary.
340    ///
341    /// Provider streams may embed an [`FsError`] inside [`io::Error`]. This
342    /// helper recovers that typed error when present; ordinary I/O errors use
343    /// [`Self::from_io`] classification instead. An untyped `InvalidData`
344    /// remains generic I/O because stream adapters also use it for contract
345    /// violations; providers report verified corruption with an embedded typed
346    /// error.
347    ///
348    /// # Parameters
349    ///
350    /// * `error` - Stream error returned by a reader or writer.
351    /// * `operation` - Public filesystem operation consuming the stream.
352    /// * `path` - Resource path supplied to that public operation.
353    ///
354    /// # Returns
355    ///
356    /// A typed filesystem error with the public operation and path rebound.
357    #[allow(dead_code)]
358    #[inline]
359    pub(crate) fn from_stream_io(error: io::Error, operation: FsOperation, path: &Path) -> Self {
360        match error.downcast::<Self>() {
361            Ok(error) => error.with_operation(operation).with_path(path.clone()),
362            Err(error) if error.kind() == io::ErrorKind::InvalidData => {
363                Self::with_source(FsErrorKind::Io, operation, "stream I/O contract failed", error)
364                    .with_path(path.clone())
365            }
366            Err(error) => Self::from_io(error, operation).with_path(path.clone()),
367        }
368    }
369
370    /// Returns the provider-neutral error category.
371    ///
372    /// # Returns
373    /// Error category.
374    #[inline]
375    #[must_use]
376    pub fn kind(&self) -> FsErrorKind {
377        self.kind
378    }
379
380    /// Returns the operation that produced this error.
381    ///
382    /// # Returns
383    /// The provider-neutral operation identifier.
384    #[inline]
385    #[must_use]
386    pub fn operation(&self) -> FsOperation {
387        self.operation
388    }
389
390    /// Returns the primary path associated with this error.
391    ///
392    /// # Returns
393    /// The path when one was attached.
394    #[inline]
395    #[must_use]
396    pub fn path(&self) -> Option<&Path> {
397        self.path.as_deref()
398    }
399
400    /// Returns the secondary target path associated with this error.
401    ///
402    /// # Returns
403    /// The target path when one was attached.
404    #[inline]
405    #[must_use]
406    pub fn target(&self) -> Option<&Path> {
407        self.target.as_deref()
408    }
409
410    /// Returns the concrete source entry where the operation failed.
411    ///
412    /// # Returns
413    /// The structured source path when one was attached for copy, rename, or
414    /// similar multi-path operations.
415    #[inline]
416    #[must_use]
417    pub fn failure_path(&self) -> Option<&Path> {
418        self.failure_path.as_deref()
419    }
420
421    /// Returns the concrete destination entry where the operation failed.
422    ///
423    /// # Returns
424    /// The structured destination path when one was attached for copy, rename,
425    /// or similar multi-path operations.
426    #[inline]
427    #[must_use]
428    pub fn failure_target(&self) -> Option<&Path> {
429        self.failure_target.as_deref()
430    }
431
432    /// Returns the provider associated with this error.
433    ///
434    /// # Returns
435    /// The canonical provider id when one was attached.
436    #[inline]
437    #[must_use]
438    pub fn provider(&self) -> Option<&str> {
439        self.provider.as_deref()
440    }
441
442    /// Returns the required capability associated with this error.
443    ///
444    /// # Returns
445    /// The capability when the error describes unsupported functionality or
446    /// an unmet semantic requirement.
447    #[inline]
448    #[must_use]
449    pub fn required_capability(&self) -> Option<FileSystemCapability> {
450        self.required_capability
451    }
452
453    /// Returns the strongest known external effect of the failed operation.
454    ///
455    /// # Returns
456    ///
457    /// `Some` when a provider proved an effect state, or `None` when the error
458    /// carries no effect-state claim.
459    #[inline]
460    #[must_use]
461    pub fn effect_state(&self) -> Option<FsEffectState> {
462        self.effect_state
463    }
464
465    /// Returns whether the operation's external effect cannot be determined.
466    #[inline]
467    #[must_use]
468    pub fn has_indeterminate_effect(&self) -> bool {
469        self.kind == FsErrorKind::Indeterminate || self.effect_state == Some(FsEffectState::Indeterminate)
470    }
471
472    /// Converts this filesystem error into a byte-stream error.
473    ///
474    /// The complete [`FsError`] is retained as the [`io::Error`] source so
475    /// callers crossing the open/stream boundary do not lose provider,
476    /// operation, or path context.
477    ///
478    /// # Returns
479    /// An I/O error with a corresponding standard category.
480    #[inline]
481    #[must_use]
482    pub fn into_io_error(self) -> io::Error {
483        let kind = match self.kind {
484            FsErrorKind::NotFound => io::ErrorKind::NotFound,
485            FsErrorKind::AlreadyExists => io::ErrorKind::AlreadyExists,
486            FsErrorKind::NotDirectory => io::ErrorKind::NotADirectory,
487            FsErrorKind::IsDirectory => io::ErrorKind::IsADirectory,
488            FsErrorKind::PermissionDenied | FsErrorKind::AuthenticationFailed => io::ErrorKind::PermissionDenied,
489            FsErrorKind::InvalidPath
490            | FsErrorKind::InvalidUri
491            | FsErrorKind::InvalidOptions
492            | FsErrorKind::InvalidState => io::ErrorKind::InvalidInput,
493            FsErrorKind::UnsupportedOperation | FsErrorKind::UnsupportedCapability => io::ErrorKind::Unsupported,
494            FsErrorKind::Timeout => io::ErrorKind::TimedOut,
495            FsErrorKind::Interrupted => io::ErrorKind::Interrupted,
496            FsErrorKind::Cancelled => io::ErrorKind::Other,
497            FsErrorKind::QuotaExceeded => io::ErrorKind::StorageFull,
498            FsErrorKind::DataCorruption => io::ErrorKind::InvalidData,
499            _ => io::ErrorKind::Other,
500        };
501        io::Error::new(kind, self)
502    }
503}
504
505impl Debug for FsError {
506    #[inline]
507    fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
508        formatter
509            .debug_struct("FsError")
510            .field("kind", &self.kind)
511            .field("operation", &self.operation)
512            .field("path", &self.path.as_deref())
513            .field("target", &self.target.as_deref())
514            .field("failure_path", &self.failure_path.as_deref())
515            .field("failure_target", &self.failure_target.as_deref())
516            .field("provider", &self.provider)
517            .field("required_capability", &self.required_capability)
518            .field("effect_state", &self.effect_state)
519            .field("message", &self.message)
520            .field("source_present", &self.source.is_some())
521            .finish()
522    }
523}
524
525impl Display for FsError {
526    #[inline]
527    fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
528        write!(
529            formatter,
530            "{:?} failed with {:?}: {}",
531            self.operation, self.kind, self.message,
532        )
533    }
534}
535
536impl Error for FsError {
537    #[inline]
538    fn source(&self) -> Option<&(dyn Error + 'static)> {
539        self.source.as_deref().map(|source| source as &(dyn Error + 'static))
540    }
541}