Skip to content

Commit c10e061

Browse files
committed
Refactor: Get rid of FE string parsing in write errors
- Split `WriteOperationError::IoError` catch-all into 9 specific variants: `DeviceDisconnected`, `ReadOnlyDevice`, `FileLocked`, `TrashNotSupported`, `ConnectionInterrupted`, `ReadError`, `WriteError`, `NameTooLong`, `InvalidName` - Added `classify_io_error()` in `types.rs` — shared by `IoResultExt::with_path()` and `From<std::io::Error>`, auto-classifies based on `ErrorKind` + message heuristics - Updated `chunked_copy.rs` to emit `ReadError`/`WriteError` at call sites - Updated `linux_copy.rs` to emit `NameTooLong` (ENAMETOOLONG), `ReadOnlyDevice`, `ReadError` - Updated `volume_copy.rs` `map_volume_error` to classify MTP errors into specific variants - Mirrored all new variants in the TS `WriteOperationError` discriminated union - Rewrote `transfer-error-messages.ts`: deleted all string-parsing helpers (`getIoErrorMessage`, `getIoErrorSuggestion`, `getDeviceNameFromError`, etc.), replaced with clean `switch` cases (280 → 190 lines) - Updated all 48 tests to use structured error types
1 parent 6341c25 commit c10e061

7 files changed

Lines changed: 309 additions & 221 deletions

File tree

apps/desktop/src-tauri/src/file_system/write_operations/chunked_copy.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,12 @@ fn copy_data_chunked(
102102
source_size: u64,
103103
progress_callback: Option<ChunkedCopyProgressFn>,
104104
) -> Result<u64, WriteOperationError> {
105-
let mut src_file = std::fs::File::open(source).map_err(|e| WriteOperationError::IoError {
105+
let mut src_file = std::fs::File::open(source).map_err(|e| WriteOperationError::ReadError {
106106
path: source.display().to_string(),
107107
message: format!("Failed to open source file: {}", e),
108108
})?;
109109

110-
let mut dst_file = std::fs::File::create(dest).map_err(|e| WriteOperationError::IoError {
110+
let mut dst_file = std::fs::File::create(dest).map_err(|e| WriteOperationError::WriteError {
111111
path: dest.display().to_string(),
112112
message: format!("Failed to create destination file: {}", e),
113113
})?;
@@ -130,7 +130,7 @@ fn copy_data_chunked(
130130
});
131131
}
132132

133-
let bytes_read = src_file.read(&mut buffer).map_err(|e| WriteOperationError::IoError {
133+
let bytes_read = src_file.read(&mut buffer).map_err(|e| WriteOperationError::ReadError {
134134
path: source.display().to_string(),
135135
message: format!("Failed to read from source: {}", e),
136136
})?;
@@ -141,7 +141,7 @@ fn copy_data_chunked(
141141

142142
dst_file
143143
.write_all(&buffer[..bytes_read])
144-
.map_err(|e| WriteOperationError::IoError {
144+
.map_err(|e| WriteOperationError::WriteError {
145145
path: dest.display().to_string(),
146146
message: format!("Failed to write to destination: {}", e),
147147
})?;

apps/desktop/src-tauri/src/file_system/write_operations/linux_copy.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ pub fn copy_single_file_linux(
3838
) -> Result<u64, WriteOperationError> {
3939
let src_file = fs::File::open(source).map_err(|e| map_io_error(e, source, destination))?;
4040

41-
let src_metadata = src_file.metadata().map_err(|e| WriteOperationError::IoError {
41+
let src_metadata = src_file.metadata().map_err(|e| WriteOperationError::ReadError {
4242
path: source.display().to_string(),
4343
message: format!("Failed to read source metadata: {}", e),
4444
})?;
@@ -153,6 +153,21 @@ fn map_io_error(err: std::io::Error, source: &Path, destination: &Path) -> Write
153153
volume_name: None,
154154
};
155155
}
156+
// ENAMETOOLONG = 36 on Linux
157+
if let Some(os_err) = err.raw_os_error()
158+
&& os_err == libc::ENAMETOOLONG
159+
{
160+
return WriteOperationError::NameTooLong {
161+
path: destination.display().to_string(),
162+
};
163+
}
164+
let lower = err.to_string().to_lowercase();
165+
if lower.contains("read-only") {
166+
return WriteOperationError::ReadOnlyDevice {
167+
path: destination.display().to_string(),
168+
device_name: None,
169+
};
170+
}
156171
WriteOperationError::IoError {
157172
path: source.display().to_string(),
158173
message: err.to_string(),

apps/desktop/src-tauri/src/file_system/write_operations/types.rs

Lines changed: 101 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -261,39 +261,124 @@ pub enum WriteOperationError {
261261
Cancelled {
262262
message: String,
263263
},
264+
/// Device was disconnected during the operation (USB, MTP, etc.).
265+
DeviceDisconnected {
266+
path: String,
267+
},
268+
/// Target device or volume is read-only.
269+
ReadOnlyDevice {
270+
path: String,
271+
device_name: Option<String>,
272+
},
273+
/// File is locked (macOS immutable flag, "Operation not permitted" on delete).
274+
FileLocked {
275+
path: String,
276+
},
277+
/// Volume doesn't support trash (network mounts, FAT, etc.).
278+
TrashNotSupported {
279+
path: String,
280+
},
281+
/// Network connection was interrupted or timed out.
282+
ConnectionInterrupted {
283+
path: String,
284+
},
285+
/// Couldn't read from the source.
286+
ReadError {
287+
path: String,
288+
message: String,
289+
},
290+
/// Couldn't write to the destination.
291+
WriteError {
292+
path: String,
293+
message: String,
294+
},
295+
/// File name exceeds the destination filesystem's length limit.
296+
NameTooLong {
297+
path: String,
298+
},
299+
/// File name contains characters not allowed at the destination.
300+
InvalidName {
301+
path: String,
302+
message: String,
303+
},
304+
/// Catch-all for genuinely unexpected IO errors.
264305
IoError {
265306
path: String,
266307
message: String,
267308
},
268309
}
269310

311+
/// Classifies a raw `std::io::Error` into a specific `WriteOperationError` variant based on its
312+
/// `ErrorKind` and message content. Used by both `IoResultExt::with_path` and the `From` impl.
313+
fn classify_io_error(e: &std::io::Error, path: String) -> WriteOperationError {
314+
let msg = e.to_string();
315+
let lower = msg.to_lowercase();
316+
317+
match e.kind() {
318+
std::io::ErrorKind::NotFound => {
319+
if lower.contains("disconnect") || lower.contains("no such device") {
320+
return WriteOperationError::DeviceDisconnected { path };
321+
}
322+
WriteOperationError::SourceNotFound { path }
323+
}
324+
std::io::ErrorKind::PermissionDenied => {
325+
if lower.contains("read-only") || lower.contains("read only") {
326+
return WriteOperationError::ReadOnlyDevice {
327+
path,
328+
device_name: None,
329+
};
330+
}
331+
if lower.contains("immutable") || lower.contains("operation not permitted") {
332+
return WriteOperationError::FileLocked { path };
333+
}
334+
WriteOperationError::PermissionDenied { path, message: msg }
335+
}
336+
std::io::ErrorKind::AlreadyExists => WriteOperationError::DestinationExists { path },
337+
std::io::ErrorKind::InvalidInput => {
338+
if lower.contains("name too long") || lower.contains("file name too long") {
339+
return WriteOperationError::NameTooLong { path };
340+
}
341+
if lower.contains("invalid") && lower.contains("name") {
342+
return WriteOperationError::InvalidName { path, message: msg };
343+
}
344+
WriteOperationError::IoError { path, message: msg }
345+
}
346+
_ => {
347+
// Heuristic classification based on message content
348+
if lower.contains("disconnect") || lower.contains("no such device") {
349+
return WriteOperationError::DeviceDisconnected { path };
350+
}
351+
if lower.contains("read-only") || lower.contains("read only") {
352+
return WriteOperationError::ReadOnlyDevice {
353+
path,
354+
device_name: None,
355+
};
356+
}
357+
if lower.contains("connection") || lower.contains("timed out") || lower.contains("timeout") {
358+
return WriteOperationError::ConnectionInterrupted { path };
359+
}
360+
if lower.contains("name too long") || lower.contains("file name too long") {
361+
return WriteOperationError::NameTooLong { path };
362+
}
363+
WriteOperationError::IoError { path, message: msg }
364+
}
365+
}
366+
}
367+
270368
/// Extension trait for converting `io::Result` to `Result<T, WriteOperationError>` with path context.
271369
pub(super) trait IoResultExt<T> {
272370
fn with_path(self, path: &Path) -> Result<T, WriteOperationError>;
273371
}
274372

275373
impl<T> IoResultExt<T> for std::io::Result<T> {
276374
fn with_path(self, path: &Path) -> Result<T, WriteOperationError> {
277-
self.map_err(|e| WriteOperationError::IoError {
278-
path: path.display().to_string(),
279-
message: e.to_string(),
280-
})
375+
self.map_err(|e| classify_io_error(&e, path.display().to_string()))
281376
}
282377
}
378+
283379
impl From<std::io::Error> for WriteOperationError {
284380
fn from(err: std::io::Error) -> Self {
285-
match err.kind() {
286-
std::io::ErrorKind::NotFound => WriteOperationError::SourceNotFound { path: err.to_string() },
287-
std::io::ErrorKind::PermissionDenied => WriteOperationError::PermissionDenied {
288-
path: String::new(),
289-
message: err.to_string(),
290-
},
291-
std::io::ErrorKind::AlreadyExists => WriteOperationError::DestinationExists { path: err.to_string() },
292-
_ => WriteOperationError::IoError {
293-
path: String::new(),
294-
message: err.to_string(),
295-
},
296-
}
381+
classify_io_error(&err, String::new())
297382
}
298383
}
299384

apps/desktop/src-tauri/src/file_system/write_operations/volume_copy.rs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -904,10 +904,28 @@ fn map_volume_error(context_path: &str, e: VolumeError) -> WriteOperationError {
904904
path: context_path.to_string(),
905905
message: "Operation not supported by this volume type".to_string(),
906906
},
907-
VolumeError::IoError(msg) => WriteOperationError::IoError {
908-
path: context_path.to_string(),
909-
message: msg,
910-
},
907+
VolumeError::IoError(msg) => {
908+
let lower = msg.to_lowercase();
909+
if lower.contains("disconnect") || lower.contains("no such device") || lower.contains("not found") {
910+
WriteOperationError::DeviceDisconnected {
911+
path: context_path.to_string(),
912+
}
913+
} else if lower.contains("read-only") || lower.contains("read only") {
914+
WriteOperationError::ReadOnlyDevice {
915+
path: context_path.to_string(),
916+
device_name: None,
917+
}
918+
} else if lower.contains("connection") || lower.contains("timed out") || lower.contains("timeout") {
919+
WriteOperationError::ConnectionInterrupted {
920+
path: context_path.to_string(),
921+
}
922+
} else {
923+
WriteOperationError::IoError {
924+
path: context_path.to_string(),
925+
message: msg,
926+
}
927+
}
928+
}
911929
}
912930
}
913931

apps/desktop/src/lib/file-explorer/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,15 @@ export type WriteOperationError =
451451
| { type: 'destination_inside_source'; source: string; destination: string }
452452
| { type: 'symlink_loop'; path: string }
453453
| { type: 'cancelled'; message: string }
454+
| { type: 'device_disconnected'; path: string }
455+
| { type: 'read_only_device'; path: string; deviceName: string | null }
456+
| { type: 'file_locked'; path: string }
457+
| { type: 'trash_not_supported'; path: string }
458+
| { type: 'connection_interrupted'; path: string }
459+
| { type: 'read_error'; path: string; message: string }
460+
| { type: 'write_error'; path: string; message: string }
461+
| { type: 'name_too_long'; path: string }
462+
| { type: 'invalid_name'; path: string; message: string }
454463
| { type: 'io_error'; path: string; message: string }
455464

456465
/** Progress event during scanning phase (emitted in dry-run mode). */

0 commit comments

Comments
 (0)