Skip to main content

cosh_tools/skills/
paths.rs

1//! Path helpers for resolving skill sources across shell environments.
2//!
3//! On Windows the harness is frequently launched from Git Bash / MSYS, whose
4//! `HOME` uses POSIX drive syntax (`/c/Users/...`). Native Rust filesystem
5//! calls do not understand that spelling — `Path::new("/c/Users/rooti/.skills")
6//! .is_dir()` is false on Windows — so the `~/.skills` default source was
7//! silently dropped and the `skills_*` tools reported an empty list.
8//!
9//! These helpers convert MSYS-style paths to their native Windows spelling
10//! and provide a `HOME` resolution that falls back to `USERPROFILE` when
11//! `HOME` does not point at a real directory.
12
13/// Convert an MSYS-style POSIX drive path (`/c/Users/...`) to its native
14/// Windows spelling (`C:/Users/...`).
15///
16/// Pure and platform-independent: returns `None` unless the string matches
17/// exactly `/<drive-letter>/...`. Paths like `/usr/local`, `//server/share`,
18/// relative paths, and already-native `C:/...` paths are left untouched
19/// (forward slashes are kept — they are valid on Windows).
20///
21/// Note: on Windows `/<letter>/...` is therefore ALWAYS interpreted as MSYS
22/// drive syntax — it cannot denote a root-relative `C:\<letter>\...` path.
23/// This matches MSYS semantics but is an inherent heuristic.
24#[must_use]
25pub fn msys_to_windows(path: &str) -> Option<String> {
26    let bytes = path.as_bytes();
27    if bytes.len() >= 3 && bytes[0] == b'/' && bytes[2] == b'/' && bytes[1].is_ascii_alphabetic() {
28        let drive = (bytes[1] as char).to_ascii_uppercase();
29        Some(format!("{drive}:{}", &path[2..]))
30    } else {
31        None
32    }
33}
34
35/// Normalize `path` to a native filesystem spelling.
36///
37/// On Windows, MSYS-style drive paths (`/c/...`) are rewritten to `C:/...`;
38/// everything else — and every path on non-Windows — passes through
39/// unchanged.
40#[must_use]
41pub fn normalize_shell_path(path: &str) -> String {
42    #[cfg(windows)]
43    {
44        msys_to_windows(path).unwrap_or_else(|| path.to_string())
45    }
46    #[cfg(not(windows))]
47    {
48        path.to_string()
49    }
50}
51
52/// Best-effort home directory resolution for skill-source defaults.
53///
54/// Prefers `HOME` (normalizing MSYS spellings on Windows) when it points at
55/// an existing directory, then falls back to `USERPROFILE` — always set on
56/// Windows, which covers the common broken case of an MSYS `HOME` inherited
57/// from a Git Bash parent process. Returns `None` when neither resolves.
58#[must_use]
59pub fn home_dir() -> Option<String> {
60    if let Some(h) = std::env::var_os("HOME") {
61        // Lossy on invalid UTF-8 (unpaired surrogates): the is_dir probe
62        // then fails and resolution degrades safely to USERPROFILE.
63        let h = normalize_shell_path(&h.to_string_lossy());
64        if std::path::Path::new(&h).is_dir() {
65            return Some(h);
66        }
67    }
68    if let Some(up) = std::env::var_os("USERPROFILE") {
69        let up = up.to_string_lossy().into_owned();
70        if std::path::Path::new(&up).is_dir() {
71            return Some(up);
72        }
73    }
74    None
75}
76
77#[cfg(test)]
78mod tests {
79    use super::{msys_to_windows, normalize_shell_path};
80
81    #[test]
82    fn msys_drive_paths_convert_with_uppercase_drive() {
83        assert_eq!(
84            msys_to_windows("/c/Users/rooti/.skills").as_deref(),
85            Some("C:/Users/rooti/.skills")
86        );
87        assert_eq!(
88            msys_to_windows("/d/data/skills").as_deref(),
89            Some("D:/data/skills")
90        );
91        // Single-letter drive: input `/m/x`, output `M:/x`.
92        assert_eq!(msys_to_windows("/m/x"), Some("M:/x".to_string()));
93    }
94
95    #[test]
96    fn non_msys_paths_are_left_untouched() {
97        assert_eq!(msys_to_windows("/usr/local"), None);
98        assert_eq!(msys_to_windows("//server/share"), None);
99        assert_eq!(msys_to_windows("C:/Users/rooti"), None);
100        assert_eq!(msys_to_windows("C:\\Users\\rooti"), None);
101        assert_eq!(msys_to_windows("relative/path"), None);
102        assert_eq!(msys_to_windows(""), None);
103        assert_eq!(msys_to_windows("/c"), None);
104        assert_eq!(msys_to_windows("/1/invalid-drive"), None);
105    }
106
107    #[cfg(windows)]
108    #[test]
109    fn normalize_rewrites_msys_paths_only_on_windows() {
110        assert_eq!(
111            normalize_shell_path("/c/Users/rooti/.skills"),
112            "C:/Users/rooti/.skills"
113        );
114        assert_eq!(normalize_shell_path("/usr/local"), "/usr/local");
115        assert_eq!(normalize_shell_path("D:/data"), "D:/data");
116    }
117
118    #[cfg(not(windows))]
119    #[test]
120    fn normalize_is_identity_off_windows() {
121        assert_eq!(
122            normalize_shell_path("/c/Users/rooti/.skills"),
123            "/c/Users/rooti/.skills"
124        );
125    }
126}