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}