thoughts_tool/mount/
diagnostics.rs1use crate::utils::paths::ensure_dir;
2use anyhow::Result;
3use std::io::ErrorKind;
4use std::path::Path;
5
6pub fn ensure_mount_dir(path: &Path) -> Result<()> {
7 ensure_dir(path).map_err(|error| add_mount_repair_context(path, error))
8}
9
10fn add_mount_repair_context(path: &Path, error: anyhow::Error) -> anyhow::Error {
11 if is_likely_inaccessible_mount_error(&error) {
12 error.context(format!(
13 "Mount directory {} exists but is not accessible.\n\
14This may be a stale/disconnected FUSE mount (for example mergerfs).\n\
15Repair or unmount/remount the stale mount, then run:\n\
16 thoughts mount update\n\
17or:\n\
18 thoughts sync\n\
19If this repository is already initialized, you likely do not need to run `thoughts init` again.",
20 path.display()
21 ))
22 } else {
23 error
24 }
25}
26
27fn is_likely_inaccessible_mount_error(error: &anyhow::Error) -> bool {
28 error.chain().any(|cause| {
29 cause
30 .downcast_ref::<std::io::Error>()
31 .is_some_and(is_likely_inaccessible_mount_io_error)
32 })
33}
34
35fn is_likely_inaccessible_mount_io_error(error: &std::io::Error) -> bool {
36 matches!(error.kind(), ErrorKind::AlreadyExists) || is_disconnected_mount_raw_error(error)
37}
38
39fn is_disconnected_mount_raw_error(error: &std::io::Error) -> bool {
40 error
41 .raw_os_error()
42 .is_some_and(|raw| DISCONNECTED_MOUNT_RAW_ERRORS.contains(&raw))
43}
44
45#[cfg(target_os = "linux")]
46const DISCONNECTED_MOUNT_RAW_ERRORS: &[i32] = &[107, 116];
48
49#[cfg(target_os = "macos")]
50const DISCONNECTED_MOUNT_RAW_ERRORS: &[i32] = &[57, 70];
52
53#[cfg(not(any(target_os = "linux", target_os = "macos")))]
54const DISCONNECTED_MOUNT_RAW_ERRORS: &[i32] = &[];
56
57#[cfg(test)]
58mod tests {
59 use super::*;
60
61 #[test]
62 fn test_add_mount_repair_context_is_actionable_for_existing_inaccessible_dir() {
63 let path = Path::new(".thoughts-data/thoughts");
64 let error = anyhow::anyhow!(std::io::Error::from(ErrorKind::AlreadyExists));
65 let error = add_mount_repair_context(path, error).to_string();
66
67 assert!(error.contains(".thoughts-data/thoughts"));
68 assert!(error.contains("stale/disconnected FUSE mount"));
69 assert!(error.contains("thoughts mount update"));
70 assert!(error.contains("thoughts sync"));
71 assert!(error.contains("do not need to run `thoughts init` again"));
72 }
73
74 #[test]
75 fn test_add_mount_repair_context_is_actionable_for_disconnected_mount_raw_error() {
76 let Some(raw) = DISCONNECTED_MOUNT_RAW_ERRORS.first() else {
77 return;
78 };
79 let path = Path::new(".thoughts-data/thoughts");
80 let error = anyhow::anyhow!(std::io::Error::from_raw_os_error(*raw));
81 let error = add_mount_repair_context(path, error).to_string();
82
83 assert!(error.contains(".thoughts-data/thoughts"));
84 assert!(error.contains("stale/disconnected FUSE mount"));
85 assert!(error.contains("thoughts mount update"));
86 }
87
88 #[test]
89 fn test_add_mount_repair_context_leaves_unrelated_errors_unchanged() {
90 let path = Path::new(".thoughts-data/thoughts");
91 let error = anyhow::anyhow!("Path exists but is not a directory: {}", path.display());
92 let error = add_mount_repair_context(path, error).to_string();
93
94 assert_eq!(
95 error,
96 "Path exists but is not a directory: .thoughts-data/thoughts"
97 );
98 }
99}