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}