1use std::{
3 fs::{self, OpenOptions},
4 io::{self, Write},
5 path::{Path, PathBuf},
6 sync::atomic::{AtomicU64, Ordering},
7 time::{SystemTime, UNIX_EPOCH},
8};
9
10static TEMP_PATH_COUNTER: AtomicU64 = AtomicU64::new(0);
11
12const ENOSPC: i32 = 28;
17
18const ENOTEMPTY_LINUX: i32 = 39;
23const ENOTEMPTY_MACOS: i32 = 66;
24const ENOTEMPTY_WINDOWS: i32 = 145;
25
26const EACCES: i32 = 13;
29
30const ENOENT: i32 = 2;
33
34const EROFS: i32 = 30;
37
38const EXDEV: i32 = 18;
41
42pub fn is_out_of_space(err: &io::Error) -> bool {
47 if err.raw_os_error() == Some(ENOSPC) {
48 return true;
49 }
50 if err.kind() == io::ErrorKind::StorageFull {
54 return true;
55 }
56 if err.kind() == io::ErrorKind::WriteZero {
62 return true;
63 }
64 false
65}
66
67pub fn is_directory_not_empty(err: &io::Error) -> bool {
76 if err.kind() == io::ErrorKind::DirectoryNotEmpty {
77 return true;
78 }
79 matches!(
80 err.raw_os_error(),
81 Some(ENOTEMPTY_LINUX) | Some(ENOTEMPTY_MACOS) | Some(ENOTEMPTY_WINDOWS)
82 )
83}
84
85pub fn is_permission_denied(err: &io::Error) -> bool {
91 if err.kind() == io::ErrorKind::PermissionDenied {
92 return true;
93 }
94 err.raw_os_error() == Some(EACCES)
95}
96
97pub fn is_not_found(err: &io::Error) -> bool {
103 if err.kind() == io::ErrorKind::NotFound {
104 return true;
105 }
106 err.raw_os_error() == Some(ENOENT)
107}
108
109pub fn is_read_only_filesystem(err: &io::Error) -> bool {
115 if err.kind() == io::ErrorKind::ReadOnlyFilesystem {
116 return true;
117 }
118 err.raw_os_error() == Some(EROFS)
119}
120
121pub fn is_cross_device_link(err: &io::Error) -> bool {
128 if err.kind() == io::ErrorKind::CrossesDevices {
129 return true;
130 }
131 err.raw_os_error() == Some(EXDEV)
132}
133
134pub fn temp_path(path: &Path) -> PathBuf {
135 let parent = path.parent().unwrap_or_else(|| Path::new("."));
136 let file_name = path
137 .file_name()
138 .and_then(|s| s.to_str())
139 .filter(|s| !s.is_empty())
140 .unwrap_or("heddle-tmp");
141 let unique = SystemTime::now()
142 .duration_since(UNIX_EPOCH)
143 .map(|d| d.as_nanos())
144 .unwrap_or(0);
145 let counter = TEMP_PATH_COUNTER.fetch_add(1, Ordering::Relaxed);
146 let pid = std::process::id();
147 parent.join(format!(".{file_name}.tmp-{pid}-{unique}-{counter}"))
148}
149
150pub fn sync_directory(path: &Path) -> io::Result<()> {
151 let dir = OpenOptions::new().read(true).open(path)?;
152 dir.sync_all()
153}
154
155fn enrich_write_error(path: &Path, err: io::Error) -> io::Error {
165 enrich_fs_error(path, "writing", err)
166}
167
168pub fn enrich_fs_error(path: &Path, op: &'static str, err: io::Error) -> io::Error {
197 if is_out_of_space(&err) {
198 let msg = format!(
199 "out of disk space {op} {}: free disk space and re-run the command — your working tree is unchanged",
200 path.display()
201 );
202 return io::Error::new(
203 io::ErrorKind::StorageFull,
204 EnrichedFsError { msg, source: err },
205 );
206 }
207 if is_directory_not_empty(&err) {
208 let msg = format!(
209 "could not remove directory `{}` because it contains content (heddle-ignored or otherwise) — leaving in place",
210 path.display()
211 );
212 return io::Error::new(
213 io::ErrorKind::DirectoryNotEmpty,
214 EnrichedFsError { msg, source: err },
215 );
216 }
217 if is_read_only_filesystem(&err) {
218 let msg = format!(
219 "filesystem is read-only — `{}` cannot be modified",
220 path.display()
221 );
222 return io::Error::new(
223 io::ErrorKind::ReadOnlyFilesystem,
224 EnrichedFsError { msg, source: err },
225 );
226 }
227 if is_permission_denied(&err) {
228 let msg = format!(
229 "permission denied {op} `{}` — check filesystem permissions",
230 path.display()
231 );
232 return io::Error::new(
233 io::ErrorKind::PermissionDenied,
234 EnrichedFsError { msg, source: err },
235 );
236 }
237 if is_not_found(&err) {
238 let msg = format!("could not find `{}` for {op}", path.display());
239 return io::Error::new(
240 io::ErrorKind::NotFound,
241 EnrichedFsError { msg, source: err },
242 );
243 }
244 if is_cross_device_link(&err) {
245 let msg = format!(
246 "cannot rename across filesystems — temp file for `{}` lives on a different mount; set TMPDIR to the same filesystem as the destination",
247 path.display()
248 );
249 return io::Error::new(
250 io::ErrorKind::CrossesDevices,
251 EnrichedFsError { msg, source: err },
252 );
253 }
254 err
255}
256
257pub fn enrich_rename_error(src: &Path, dst: &Path, err: io::Error) -> io::Error {
262 if is_cross_device_link(&err) {
263 let msg = format!(
264 "cannot rename across filesystems — temp file at `{}` cannot be renamed to `{}`; set TMPDIR to the same filesystem as the destination",
265 src.display(),
266 dst.display()
267 );
268 return io::Error::new(
269 io::ErrorKind::CrossesDevices,
270 EnrichedFsError { msg, source: err },
271 );
272 }
273 enrich_fs_error(dst, "renaming", err)
274}
275
276#[derive(Debug)]
277struct EnrichedFsError {
278 msg: String,
279 source: io::Error,
280}
281
282impl std::fmt::Display for EnrichedFsError {
283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284 f.write_str(&self.msg)
285 }
286}
287
288impl std::error::Error for EnrichedFsError {
289 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
290 Some(&self.source)
291 }
292}
293
294pub fn write_file_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
295 let parent = path.parent().unwrap_or_else(|| Path::new("."));
296 fs::create_dir_all(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
297
298 let tmp = temp_path(path);
299 let inner = (|| -> io::Result<()> {
300 let mut file = OpenOptions::new()
301 .create(true)
302 .truncate(true)
303 .write(true)
304 .open(&tmp)?;
305 file.write_all(bytes)?;
306 file.sync_all()?;
307 Ok(())
308 })();
309
310 if let Err(err) = inner {
311 let _ = fs::remove_file(&tmp);
315 return Err(enrich_write_error(path, err));
316 }
317
318 fs::rename(&tmp, path).map_err(|e| enrich_rename_error(&tmp, path, e))?;
319 sync_directory(parent).map_err(|e| enrich_fs_error(parent, "syncing", e))
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 fn enospc_io_error() -> io::Error {
327 io::Error::from_raw_os_error(ENOSPC)
328 }
329
330 #[test]
331 fn is_out_of_space_detects_enospc_raw() {
332 assert!(is_out_of_space(&enospc_io_error()));
333 }
334
335 #[test]
336 fn is_out_of_space_detects_storage_full_kind() {
337 let err = io::Error::new(io::ErrorKind::StorageFull, "mock disk full");
338 assert!(is_out_of_space(&err));
339 }
340
341 #[test]
342 fn is_out_of_space_detects_write_zero() {
343 let err = io::Error::new(io::ErrorKind::WriteZero, "short write");
344 assert!(is_out_of_space(&err));
345 }
346
347 #[test]
348 fn is_out_of_space_rejects_unrelated_errors() {
349 assert!(!is_out_of_space(&io::Error::new(
350 io::ErrorKind::NotFound,
351 "missing"
352 )));
353 assert!(!is_out_of_space(&io::Error::new(
354 io::ErrorKind::PermissionDenied,
355 "nope"
356 )));
357 assert!(!is_out_of_space(&io::Error::other("generic")));
358 }
359
360 #[test]
361 fn is_directory_not_empty_detects_kind() {
362 let err = io::Error::new(io::ErrorKind::DirectoryNotEmpty, "still has children");
363 assert!(is_directory_not_empty(&err));
364 }
365
366 #[test]
367 fn is_directory_not_empty_detects_raw_codes() {
368 for code in [ENOTEMPTY_LINUX, ENOTEMPTY_MACOS, ENOTEMPTY_WINDOWS] {
369 assert!(
370 is_directory_not_empty(&io::Error::from_raw_os_error(code)),
371 "expected raw OS error {code} to classify as ENOTEMPTY"
372 );
373 }
374 }
375
376 #[test]
377 fn is_directory_not_empty_rejects_unrelated() {
378 assert!(!is_directory_not_empty(&io::Error::new(
379 io::ErrorKind::NotFound,
380 "missing"
381 )));
382 assert!(!is_directory_not_empty(&enospc_io_error()));
383 }
384
385 #[test]
386 fn is_permission_denied_detects_kind_and_raw() {
387 assert!(is_permission_denied(&io::Error::new(
388 io::ErrorKind::PermissionDenied,
389 "nope"
390 )));
391 assert!(is_permission_denied(&io::Error::from_raw_os_error(EACCES)));
392 }
393
394 #[test]
395 fn is_not_found_detects_kind_and_raw() {
396 assert!(is_not_found(&io::Error::new(
397 io::ErrorKind::NotFound,
398 "missing"
399 )));
400 assert!(is_not_found(&io::Error::from_raw_os_error(ENOENT)));
401 }
402
403 #[test]
404 fn is_read_only_filesystem_detects_raw() {
405 assert!(is_read_only_filesystem(&io::Error::from_raw_os_error(
406 EROFS
407 )));
408 }
409
410 #[test]
411 fn is_cross_device_link_detects_raw() {
412 assert!(is_cross_device_link(&io::Error::from_raw_os_error(EXDEV)));
413 }
414
415 #[test]
416 fn enrich_fs_error_passes_through_unclassified() {
417 let path = Path::new("/tmp/example");
418 let original = io::Error::other("weird");
419 let wrapped = enrich_fs_error(path, "writing", original);
420 assert_eq!(wrapped.kind(), io::ErrorKind::Other);
422 assert_eq!(wrapped.to_string(), "weird");
423 }
424
425 #[test]
426 fn enrich_fs_error_wraps_enospc_with_path_and_recovery_hint() {
427 let path = Path::new("/repo/.heddle/state/abc.bin");
428 let wrapped = enrich_fs_error(path, "writing", enospc_io_error());
429
430 assert_eq!(wrapped.kind(), io::ErrorKind::StorageFull);
432 let msg = wrapped.to_string();
434 assert!(
435 msg.contains("out of disk space"),
436 "missing failure name: {msg}"
437 );
438 assert!(
439 msg.contains("/repo/.heddle/state/abc.bin"),
440 "missing path: {msg}"
441 );
442 assert!(
443 msg.contains("free disk space") && msg.contains("re-run"),
444 "missing recovery hint: {msg}"
445 );
446 assert!(
447 msg.contains("working tree is unchanged"),
448 "missing reassurance: {msg}"
449 );
450 let src = std::error::Error::source(&wrapped as &dyn std::error::Error)
453 .or_else(|| wrapped.get_ref().and_then(|e| e.source()))
454 .expect("source preserved");
455 assert!(src.to_string().to_lowercase().contains("space"));
456 }
457
458 #[test]
459 fn enrich_fs_error_wraps_enotempty_with_directory_message() {
460 let path = Path::new("/repo/web");
461 let wrapped = enrich_fs_error(
462 path,
463 "removing",
464 io::Error::from_raw_os_error(ENOTEMPTY_MACOS),
465 );
466 assert_eq!(wrapped.kind(), io::ErrorKind::DirectoryNotEmpty);
467 let msg = wrapped.to_string();
468 assert!(
469 msg.contains("could not remove directory"),
470 "missing action: {msg}"
471 );
472 assert!(msg.contains("/repo/web"), "missing path: {msg}");
473 assert!(
474 msg.contains("heddle-ignored"),
475 "missing heddle-ignored hint: {msg}"
476 );
477 assert!(
478 msg.contains("leaving in place"),
479 "missing reassurance: {msg}"
480 );
481 let src = wrapped.get_ref().and_then(|e| e.source()).expect("source");
486 let original = src
487 .downcast_ref::<io::Error>()
488 .expect("original io::Error preserved");
489 assert_eq!(original.raw_os_error(), Some(ENOTEMPTY_MACOS));
490 }
491
492 #[test]
493 fn enrich_fs_error_wraps_eacces_with_op_and_path() {
494 let path = Path::new("/repo/.heddle/state/index.bin");
495 let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EACCES));
496 assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
497 let msg = wrapped.to_string();
498 assert!(msg.starts_with("permission denied writing"), "msg: {msg}");
499 assert!(msg.contains("/repo/.heddle/state/index.bin"), "msg: {msg}");
500 assert!(msg.contains("check filesystem permissions"), "msg: {msg}");
501 }
502
503 #[test]
504 fn enrich_fs_error_wraps_enoent_with_op_and_path() {
505 let path = Path::new("/repo/.heddle");
506 let wrapped = enrich_fs_error(path, "opening", io::Error::from_raw_os_error(ENOENT));
507 assert_eq!(wrapped.kind(), io::ErrorKind::NotFound);
508 let msg = wrapped.to_string();
509 assert!(msg.contains("could not find"), "missing action: {msg}");
510 assert!(msg.contains("/repo/.heddle"), "missing path: {msg}");
511 assert!(msg.contains("for opening"), "missing op: {msg}");
512 }
513
514 #[test]
515 fn enrich_fs_error_wraps_erofs_with_path() {
516 let path = Path::new("/mnt/readonly/.heddle/state/index.bin");
517 let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EROFS));
518 assert_eq!(wrapped.kind(), io::ErrorKind::ReadOnlyFilesystem);
519 let msg = wrapped.to_string();
520 assert!(msg.contains("filesystem is read-only"), "msg: {msg}");
521 assert!(
522 msg.contains("/mnt/readonly/.heddle/state/index.bin"),
523 "msg: {msg}"
524 );
525 assert!(msg.contains("cannot be modified"), "msg: {msg}");
526 }
527
528 #[test]
529 fn enrich_rename_error_wraps_exdev_with_src_and_dst() {
530 let src = Path::new("/tmp-mount/.x.tmp-1234");
531 let dst = Path::new("/repo/.heddle/state/index.bin");
532 let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EXDEV));
533 assert_eq!(wrapped.kind(), io::ErrorKind::CrossesDevices);
534 let msg = wrapped.to_string();
535 assert!(
536 msg.contains("cannot rename across filesystems"),
537 "msg: {msg}"
538 );
539 assert!(msg.contains("/tmp-mount/.x.tmp-1234"), "missing src: {msg}");
540 assert!(
541 msg.contains("/repo/.heddle/state/index.bin"),
542 "missing dst: {msg}"
543 );
544 assert!(msg.contains("TMPDIR"), "missing recovery hint: {msg}");
545 }
546
547 #[test]
548 fn enrich_rename_error_falls_through_to_generic_for_other_kinds() {
549 let src = Path::new("/tmp/.x.tmp");
550 let dst = Path::new("/repo/file");
551 let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EACCES));
552 assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
555 let msg = wrapped.to_string();
556 assert!(msg.starts_with("permission denied renaming"), "msg: {msg}");
557 assert!(msg.contains("/repo/file"), "missing dst: {msg}");
558 }
559
560 #[test]
561 fn enrich_write_error_passes_through_non_enospc_unclassified() {
562 let path = Path::new("/tmp/example");
565 let original = io::Error::other("weird");
566 let wrapped = enrich_write_error(path, original);
567 assert_eq!(wrapped.kind(), io::ErrorKind::Other);
568 assert_eq!(wrapped.to_string(), "weird");
569 }
570
571 #[test]
572 fn write_file_atomic_round_trip() {
573 let dir = tempfile::TempDir::new().unwrap();
574 let target = dir.path().join("nested/under/here/file.bin");
575 write_file_atomic(&target, b"hello").unwrap();
576 assert_eq!(fs::read(&target).unwrap(), b"hello");
577 }
578}