Skip to main content

thoughts_tool/mount/
diagnostics.rs

1use 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")]
46// Linux errno values: ENOTCONN = 107, ESTALE = 116.
47const DISCONNECTED_MOUNT_RAW_ERRORS: &[i32] = &[107, 116];
48
49#[cfg(target_os = "macos")]
50// Darwin errno values: ENOTCONN = 57, ESTALE = 70.
51const DISCONNECTED_MOUNT_RAW_ERRORS: &[i32] = &[57, 70];
52
53#[cfg(not(any(target_os = "linux", target_os = "macos")))]
54// No disconnected-mount raw errno mappings are defined on unsupported targets.
55const 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}