Skip to main content

exarch_core/error/
io_context.rs

1//! Structured context for `std::io::Error::other` call sites.
2
3use std::error::Error as StdError;
4use std::fmt;
5
6/// Pairs a static, non-path-bearing summary with the dynamic detail of an
7/// I/O failure constructed via [`std::io::Error::other`].
8///
9/// FFI bindings redact `ArchiveError::Io` messages down to their
10/// [`std::io::ErrorKind`] description in release builds, since free-form
11/// messages have no structured path field to sanitize. For errors built
12/// from `ErrorKind::Other`, that redaction collapses to the fixed string
13/// "other error", discarding all diagnostic value. Wrapping the detail in
14/// `IoContext` lets bindings recognize the error (via
15/// [`std::io::Error::get_ref`] and downcasting) and surface `context`
16/// instead — safe to show even in release builds because it is always a
17/// `&'static str` fixed at the call site, never built from path or archive
18/// entry data.
19///
20/// # Examples
21///
22/// ```
23/// use exarch_core::IoContext;
24/// use std::io;
25///
26/// let err = io::Error::other(IoContext::new(
27///     "failed to read entry metadata",
28///     "/tmp/x: denied",
29/// ));
30/// assert_eq!(
31///     err.to_string(),
32///     "failed to read entry metadata: /tmp/x: denied"
33/// );
34///
35/// let ctx = err
36///     .get_ref()
37///     .and_then(|inner| inner.downcast_ref::<IoContext>())
38///     .expect("IoContext downcast");
39/// assert_eq!(ctx.context, "failed to read entry metadata");
40/// ```
41#[derive(Debug)]
42pub struct IoContext {
43    /// Static summary of the failure. Never carries path or archive entry
44    /// data — safe to surface in release builds.
45    pub context: &'static str,
46    /// Full dynamic detail (may embed a host path). Shown only in debug
47    /// builds via `ArchiveError::Io`'s `Display`.
48    pub detail: String,
49}
50
51impl IoContext {
52    /// Creates a new `IoContext` pairing a static summary with dynamic
53    /// detail.
54    ///
55    /// # Examples
56    ///
57    /// ```
58    /// use exarch_core::IoContext;
59    ///
60    /// let ctx = IoContext::new("failed to start file in zip archive", "boom");
61    /// assert_eq!(ctx.context, "failed to start file in zip archive");
62    /// assert_eq!(ctx.detail, "boom");
63    /// ```
64    #[must_use]
65    pub fn new(context: &'static str, detail: impl Into<String>) -> Self {
66        Self {
67            context,
68            detail: detail.into(),
69        }
70    }
71}
72
73impl fmt::Display for IoContext {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        write!(f, "{}: {}", self.context, self.detail)
76    }
77}
78
79impl StdError for IoContext {}
80
81#[cfg(test)]
82#[allow(clippy::expect_used)] // Allow expect in tests for brevity
83mod tests {
84    use super::IoContext;
85    use std::io;
86
87    #[test]
88    fn display_includes_context_and_detail() {
89        let ctx = IoContext::new("failed to read entry metadata", "/tmp/x: denied");
90        assert_eq!(
91            ctx.to_string(),
92            "failed to read entry metadata: /tmp/x: denied"
93        );
94    }
95
96    #[test]
97    fn roundtrips_through_io_error_other() {
98        let err = io::Error::other(IoContext::new("failed to finish zip archive", "disk full"));
99        assert_eq!(err.kind(), io::ErrorKind::Other);
100        assert_eq!(err.to_string(), "failed to finish zip archive: disk full");
101
102        let ctx = err
103            .get_ref()
104            .and_then(|inner| inner.downcast_ref::<IoContext>())
105            .expect("IoContext should be downcastable from io::Error");
106        assert_eq!(ctx.context, "failed to finish zip archive");
107        assert_eq!(ctx.detail, "disk full");
108    }
109}