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