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 Error {
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
54/// Error from an upload, carrying the handle of the object the device created
55/// during `SendObjectInfo` before the data phase failed.
56///
57/// PTP uploads are two-phase: `SendObjectInfo` creates the object on the device
58/// (returning a handle), then `SendObject` streams the bytes. If the data phase
59/// fails or is cancelled, the device is left holding a partial (often empty or
60/// truncated) object. This error surfaces that handle so the caller owns the
61/// cleanup-or-resume decision, rather than the library guessing.
62///
63/// The library does **not** auto-delete the partial object: deleting it would
64/// issue hidden USB I/O to a possibly-disconnected device, the leave-vs-delete
65/// behavior is device-dependent, and PTP's two-phase model is designed so a
66/// failed `SendObject` can be retried against the same handle (resume).
67///
68/// [`From<UploadError> for Error`] keeps `?` ergonomic for callers working in an
69/// [`enum@Error`] context; they drop the [`partial`](Self::partial) handle unless
70/// they match on `UploadError` explicitly.
71#[derive(Debug, Error)]
72#[error("{source}")]
73pub struct UploadError {
74 /// The underlying failure (I/O, protocol, cancellation, timeout, …).
75 #[source]
76 pub source: Error,
77 /// The handle of the partially-written object the device may still hold.
78 ///
79 /// `Some` iff `SendObjectInfo` succeeded but the data phase did not complete
80 /// (genuine error OR cancellation). The object may be empty or truncated. The
81 /// caller decides: delete it (for example, [`Storage::delete`]) to discard the
82 /// corrupt artifact, or retry the data phase to resume.
83 ///
84 /// `None` iff no object was created (for example, `SendObjectInfo` itself
85 /// failed because the storage is read-only or the parent is invalid).
86 ///
87 /// [`Storage::delete`]: crate::mtp::Storage::delete
88 pub partial: Option<ObjectHandle>,
89}
90
91impl From<UploadError> for Error {
92 fn from(e: UploadError) -> Self {
93 e.source
94 }
95}
96
97impl Error {
98 /// Create an invalid data error with a message.
99 #[must_use]
100 pub fn invalid_data(message: impl Into<String>) -> Self {
101 Error::InvalidData {
102 message: message.into(),
103 }
104 }
105
106 /// Check if this is a retryable error.
107 ///
108 /// Retryable errors are transient and the operation may succeed if retried:
109 /// - `DeviceBusy`: Device is temporarily busy
110 /// - `Timeout`: Operation timed out but device may still be responsive
111 #[must_use]
112 pub fn is_retryable(&self) -> bool {
113 matches!(
114 self,
115 Error::Protocol {
116 code: crate::ptp::ResponseCode::DeviceBusy,
117 ..
118 } | Error::Timeout
119 )
120 }
121
122 /// Get the response code if this is a protocol error.
123 #[must_use]
124 pub fn response_code(&self) -> Option<crate::ptp::ResponseCode> {
125 match self {
126 Error::Protocol { code, .. } => Some(*code),
127 _ => None,
128 }
129 }
130
131 /// Check if this error indicates another process has exclusive access to the device.
132 ///
133 /// This typically happens on macOS when `ptpcamerad` or another application
134 /// has already claimed the USB interface. Applications can use this to provide
135 /// platform-specific guidance to users.
136 ///
137 /// # Example
138 ///
139 /// ```ignore
140 /// match device.open().await {
141 /// Err(e) if e.is_exclusive_access() => {
142 /// // On macOS, likely ptpcamerad interference
143 /// // App can query IORegistry for UsbExclusiveOwner to get details
144 /// show_exclusive_access_help();
145 /// }
146 /// Err(e) => handle_other_error(e),
147 /// Ok(dev) => use_device(dev),
148 /// }
149 /// ```
150 #[must_use]
151 pub fn is_exclusive_access(&self) -> bool {
152 match self {
153 Error::Usb(io_err) => {
154 let msg = io_err.to_string().to_lowercase();
155 // macOS: "could not be opened for exclusive access"
156 // Linux: typically EBUSY, but message varies
157 // Windows: "access denied" or similar
158 msg.contains("exclusive access")
159 || msg.contains("device or resource busy")
160 || (msg.contains("access") && msg.contains("denied"))
161 }
162 Error::Io(io_err) => {
163 let msg = io_err.to_string().to_lowercase();
164 msg.contains("exclusive access")
165 || msg.contains("device or resource busy")
166 || (msg.contains("access") && msg.contains("denied"))
167 }
168 _ => false,
169 }
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use std::io::{Error as IoError, ErrorKind};
177
178 #[test]
179 fn test_is_exclusive_access_macos_message() {
180 // macOS nusb error message (tested via Io variant; same logic as Usb variant)
181 let io_err = IoError::other("could not be opened for exclusive access");
182 let err = Error::Io(io_err);
183 assert!(err.is_exclusive_access());
184 }
185
186 #[test]
187 fn test_is_exclusive_access_linux_busy() {
188 // Linux EBUSY style message (tested via Io variant; same logic as Usb variant)
189 let io_err = IoError::other("Device or resource busy");
190 let err = Error::Io(io_err);
191 assert!(err.is_exclusive_access());
192 }
193
194 #[test]
195 fn test_is_exclusive_access_windows_denied() {
196 // Windows access denied style message (tested via Io variant; same logic as Usb variant)
197 let io_err = IoError::new(ErrorKind::PermissionDenied, "Access is denied");
198 let err = Error::Io(io_err);
199 assert!(err.is_exclusive_access());
200 }
201
202 #[test]
203 fn test_is_exclusive_access_io_error() {
204 // Also works for Io variant
205 let io_err = IoError::other("could not be opened for exclusive access");
206 let err = Error::Io(io_err);
207 assert!(err.is_exclusive_access());
208 }
209
210 #[test]
211 fn test_is_exclusive_access_false_for_other_errors() {
212 assert!(!Error::Timeout.is_exclusive_access());
213 assert!(!Error::Disconnected.is_exclusive_access());
214 assert!(!Error::NoDevice.is_exclusive_access());
215 assert!(!Error::invalid_data("some error").is_exclusive_access());
216
217 let io_err = IoError::new(ErrorKind::NotFound, "device not found");
218 assert!(!Error::Io(io_err).is_exclusive_access());
219 }
220}