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}