mtp_rs/error.rs
1//! Error types for mtp-rs.
2
3use crate::ptp::ObjectHandle;
4use thiserror::Error;
5
6/// The main error type for mtp-rs operations.
7#[derive(Debug, Error)]
8pub enum PtpError {
9 /// USB communication error
10 #[error("USB error: {0}")]
11 Usb(#[from] nusb::Error),
12
13 /// Protocol-level error from device
14 #[error("Protocol error: {code:?} during {operation:?}")]
15 Protocol {
16 /// The response code returned by the device.
17 code: crate::ptp::ResponseCode,
18 /// The operation that triggered the error.
19 operation: crate::ptp::OperationCode,
20 },
21
22 /// Invalid data received from device
23 #[error("Invalid data: {message}")]
24 InvalidData {
25 /// Description of what was invalid.
26 message: String,
27 },
28
29 /// I/O error
30 #[error("I/O error: {0}")]
31 Io(std::io::Error),
32
33 /// Operation timed out
34 #[error("Operation timed out")]
35 Timeout,
36
37 /// Device was disconnected
38 #[error("Device disconnected")]
39 Disconnected,
40
41 /// Session not open
42 #[error("Session not open")]
43 SessionNotOpen,
44
45 /// No device found
46 #[error("No MTP device found")]
47 NoDevice,
48
49 /// Operation cancelled
50 #[error("Operation cancelled")]
51 Cancelled,
52
53 /// A transfer cancel wedged the device, and the transport was reset to
54 /// recover it. The PTP session is gone; reopen the device to continue.
55 ///
56 /// Seen when cancelling or abandoning an in-flight read on an Android device
57 /// (issue #18): the device stops responding, so `cancel_transfer` issues a
58 /// USB `DEVICE_RESET` and reports this instead of a false success. Transfer
59 /// size doesn't drive it; a 36-byte file is enough.
60 ///
61 /// **Don't treat this as the only wedge signature.** A Samsung reports it; a
62 /// Pixel wedges the same way but the next operation simply hangs with no
63 /// error at all (verified on a Pixel 9 Pro XL, macOS/nusb, 2026-07-20), so
64 /// wrap operations in a timeout as well as matching this variant. See
65 /// `docs/notes/android-wedges-and-the-reset-kill-switch.md`.
66 #[error("device was reset to recover from a wedged cancel; reopen to continue")]
67 DeviceReset,
68}
69
70/// Error from an upload, carrying the handle of the object the device created
71/// during `SendObjectInfo` before the data phase failed.
72///
73/// PTP uploads are two-phase: `SendObjectInfo` creates the object on the device
74/// (returning a handle), then `SendObject` streams the bytes. If the data phase
75/// fails or is cancelled, the device is left holding a partial (often empty or
76/// truncated) object. This error surfaces that handle so the caller owns the
77/// cleanup-or-resume decision, rather than the library guessing.
78///
79/// The library does **not** auto-delete the partial object: deleting it would
80/// issue hidden USB I/O to a possibly-disconnected device, the leave-vs-delete
81/// behavior is device-dependent, and PTP's two-phase model is designed so a
82/// failed `SendObject` can be retried against the same handle (resume).
83///
84/// [`From<PtpUploadError> for PtpError`] keeps `?` ergonomic for callers working in a
85/// [`enum@PtpError`] context; they drop the [`partial`](Self::partial) handle unless
86/// they match on `PtpUploadError` explicitly.
87///
88/// This is the low-level PTP-layer upload error. The high-level [`crate::mtp`] API
89/// has its own backend-neutral [`crate::mtp::UploadError`].
90#[derive(Debug, Error)]
91#[error("{source}")]
92pub struct PtpUploadError {
93 /// The underlying failure (I/O, protocol, cancellation, timeout, …).
94 #[source]
95 pub source: PtpError,
96 /// The handle of the partially-written object the device may still hold.
97 ///
98 /// `Some` iff `SendObjectInfo` succeeded but the data phase did not complete
99 /// (genuine error OR cancellation). The object may be empty or truncated. The
100 /// caller decides: delete it to discard the corrupt artifact, or retry the
101 /// data phase to resume.
102 ///
103 /// `None` iff no object was created (for example, `SendObjectInfo` itself
104 /// failed because the storage is read-only or the parent is invalid).
105 pub partial: Option<ObjectHandle>,
106}
107
108impl From<PtpUploadError> for PtpError {
109 fn from(e: PtpUploadError) -> Self {
110 e.source
111 }
112}
113
114impl PtpError {
115 /// Create an invalid data error with a message.
116 #[must_use]
117 pub fn invalid_data(message: impl Into<String>) -> Self {
118 PtpError::InvalidData {
119 message: message.into(),
120 }
121 }
122
123 /// Check if this is a retryable error.
124 ///
125 /// Retryable errors are transient and the operation may succeed if retried:
126 /// - `DeviceBusy`: Device is temporarily busy
127 /// - `Timeout`: Operation timed out but device may still be responsive
128 #[must_use]
129 pub fn is_retryable(&self) -> bool {
130 matches!(
131 self,
132 PtpError::Protocol {
133 code: crate::ptp::ResponseCode::DeviceBusy,
134 ..
135 } | PtpError::Timeout
136 )
137 }
138
139 /// Get the response code if this is a protocol error.
140 #[must_use]
141 pub fn response_code(&self) -> Option<crate::ptp::ResponseCode> {
142 match self {
143 PtpError::Protocol { code, .. } => Some(*code),
144 _ => None,
145 }
146 }
147
148 /// Check if this error indicates another process has exclusive access to the device.
149 ///
150 /// This typically happens on macOS when `ptpcamerad` or another application
151 /// has already claimed the USB interface. Applications can use this to provide
152 /// platform-specific guidance to users.
153 ///
154 /// # Example
155 ///
156 /// ```ignore
157 /// match device.open().await {
158 /// Err(e) if e.is_exclusive_access() => {
159 /// // On macOS, likely ptpcamerad interference
160 /// // App can query IORegistry for UsbExclusiveOwner to get details
161 /// show_exclusive_access_help();
162 /// }
163 /// Err(e) => handle_other_error(e),
164 /// Ok(dev) => use_device(dev),
165 /// }
166 /// ```
167 #[must_use]
168 pub fn is_exclusive_access(&self) -> bool {
169 match self {
170 PtpError::Usb(io_err) => {
171 let msg = io_err.to_string().to_lowercase();
172 // macOS: "could not be opened for exclusive access"
173 // Linux: typically EBUSY, but message varies
174 // Windows: "access denied" or similar
175 msg.contains("exclusive access")
176 || msg.contains("device or resource busy")
177 || (msg.contains("access") && msg.contains("denied"))
178 }
179 PtpError::Io(io_err) => {
180 let msg = io_err.to_string().to_lowercase();
181 msg.contains("exclusive access")
182 || msg.contains("device or resource busy")
183 || (msg.contains("access") && msg.contains("denied"))
184 }
185 _ => false,
186 }
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use std::io::{Error as IoError, ErrorKind};
194
195 #[test]
196 fn test_is_exclusive_access_macos_message() {
197 // macOS nusb error message (tested via Io variant; same logic as Usb variant)
198 let io_err = IoError::other("could not be opened for exclusive access");
199 let err = PtpError::Io(io_err);
200 assert!(err.is_exclusive_access());
201 }
202
203 #[test]
204 fn test_is_exclusive_access_linux_busy() {
205 // Linux EBUSY style message (tested via Io variant; same logic as Usb variant)
206 let io_err = IoError::other("Device or resource busy");
207 let err = PtpError::Io(io_err);
208 assert!(err.is_exclusive_access());
209 }
210
211 #[test]
212 fn test_is_exclusive_access_windows_denied() {
213 // Windows access denied style message (tested via Io variant; same logic as Usb variant)
214 let io_err = IoError::new(ErrorKind::PermissionDenied, "Access is denied");
215 let err = PtpError::Io(io_err);
216 assert!(err.is_exclusive_access());
217 }
218
219 #[test]
220 fn test_is_exclusive_access_io_error() {
221 // Also works for Io variant
222 let io_err = IoError::other("could not be opened for exclusive access");
223 let err = PtpError::Io(io_err);
224 assert!(err.is_exclusive_access());
225 }
226
227 #[test]
228 fn test_is_exclusive_access_false_for_other_errors() {
229 assert!(!PtpError::Timeout.is_exclusive_access());
230 assert!(!PtpError::Disconnected.is_exclusive_access());
231 assert!(!PtpError::NoDevice.is_exclusive_access());
232 assert!(!PtpError::invalid_data("some error").is_exclusive_access());
233
234 let io_err = IoError::new(ErrorKind::NotFound, "device not found");
235 assert!(!PtpError::Io(io_err).is_exclusive_access());
236 }
237}