Skip to main content

atomcode_tuix/
platform.rs

1// crates/atomcode-tuix/src/platform.rs
2//
3// Small cross-platform helpers. Every `$HOME`, `/tmp`, and shell-command
4// decision in tuix routes through this module so Windows doesn't have
5// to special-case each caller. Keeps `event_loop` and friends free of
6// `#[cfg(unix)]` clutter.
7
8use std::path::PathBuf;
9
10/// User home directory, or `None` if it can't be determined.
11///
12/// - macOS / Linux: `$HOME`, falling back to `getpwuid_r`
13/// - Windows: `%USERPROFILE%` (via `dirs`)
14///
15/// This function accounts for sudo scenarios where $HOME might be /root
16/// but we want the actual user's home directory.
17///
18/// Prefer this over `std::env::var("HOME")` — the latter returns `None`
19/// on stock Windows and sends us down a fallback path that then hits
20/// `/tmp` (also nonexistent on Windows).
21pub fn home_dir() -> Option<PathBuf> {
22    atomcode_core::tool::real_home_dir()
23}
24
25/// Replace a leading `$HOME` in `path` with `~`. Returns `path`
26/// unchanged if it doesn't start under home, or if home isn't known.
27///
28/// On Windows, `std::fs::canonicalize` returns paths with the verbatim
29/// (`\\?\`) or UNC-verbatim (`\\?\UNC\`) prefix; both are stripped here
30/// before any other processing so the status row never shows the raw
31/// extended-length form (e.g. `\\?\D:\wwwroot\xingyu-api`).
32///
33/// On Windows the comparison is case-insensitive because
34/// `canonicalize` may normalise the path to a different casing than
35/// `dirs::home_dir()` returns (e.g. `C:\Users\…` vs `c:\users\…`).
36///
37/// Used by the status row + welcome page to keep long paths readable.
38pub fn collapse_home(path: &str) -> String {
39    collapse_home_with(path, home_dir().as_deref())
40}
41
42/// Implementation of [`collapse_home`] that accepts an explicit home
43/// directory. This lets unit tests verify Windows-specific logic on any
44/// platform without needing `cfg!(windows)`.
45fn collapse_home_with(path: &str, home: Option<&std::path::Path>) -> String {
46    let path: std::borrow::Cow<'_, str> = if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
47        std::borrow::Cow::Owned(format!(r"\\{}", rest))
48    } else if let Some(rest) = path.strip_prefix(r"\\?\") {
49        std::borrow::Cow::Borrowed(rest)
50    } else {
51        std::borrow::Cow::Borrowed(path)
52    };
53    if let Some(home) = home {
54        let home_str = home.to_string_lossy();
55        if !home_str.is_empty() {
56            // On Windows the filesystem is case-insensitive.  `canonicalize`
57            // may return a path whose drive-letter / user-directory casing
58            // differs from `dirs::home_dir()` (e.g. `C:\USERS\alice\…` vs
59            // `C:\users\alice`).  Compare case-insensitively on Windows so
60            // the prefix is reliably stripped and the status row shows
61            // `~/atomcode` instead of the raw `C:\USERS\alice\atomcode`.
62            let rest = if cfg!(windows) {
63                // Case-insensitive prefix match on Windows: compare the
64                // lowercased forms, but slice the *original* path at
65                // `home_str.len()` — that offset is where the remainder
66                // starts regardless of casing differences.  Using the
67                // lowercase remainder's length (old code: `s.len()`) was
68                // wrong because it equals `path.len() - home_str.len()`,
69                // so `path[s.len()..]` took the *last* `home_str.len()`
70                // characters instead of everything after the home prefix.
71                if path.to_lowercase().starts_with(&home_str.to_lowercase()) {
72                    Some(path[home_str.len()..].to_string())
73                } else {
74                    None
75                }
76            } else {
77                path.strip_prefix(&*home_str).map(|s| s.to_string())
78            };
79            if let Some(rest) = rest {
80                if rest.is_empty() {
81                    return "~".to_string();
82                }
83                // Always emit forward slashes after `~` — the `~`
84                // shortcut is a Unix shell convention and `~\foo`
85                // (the Windows-native form) matches no actual shell:
86                // PowerShell / cmd don't expand `~`, Git Bash / WSL
87                // use `~/`. Mixed `~\…` reads as a typo. Normalising
88                // here keeps every status-row path consistent with
89                // the rest of the TUI (skill paths, command help,
90                // docs) which all reference `~/.atomcode/...`.
91                return format!("~{}", rest.replace('\\', "/"));
92            }
93        }
94    }
95    path.into_owned()
96}
97
98/// Path for the per-user input history file.
99/// Uses ATOMCODE_HOME if set, otherwise falls back to home directory.
100pub fn history_path() -> PathBuf {
101    atomcode_core::config::Config::config_dir().join("history")
102}
103
104/// Path for the per-user image attachment cache. Sibling to `history_path()`.
105/// Used by the History image-attachment feature to persist pasted bytes so
106/// up-arrow recall can rehydrate them on a future submit.
107pub fn image_cache_dir() -> PathBuf {
108    atomcode_core::config::Config::config_dir().join("image-cache")
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn collapse_home_rewrites_prefix() {
117        if let Some(home) = home_dir() {
118            let home_str = home.to_string_lossy().to_string();
119            let nested = format!("{}/project/foo", home_str);
120            let got = collapse_home(&nested);
121            assert_eq!(got, "~/project/foo");
122        }
123    }
124
125    /// Windows `home_str` uses `\`, and the input path strip_prefix
126    /// leaves a backslash-prefixed remainder. The collapse output must
127    /// still normalise to `~/foo`, not the hybrid `~\foo` form.
128    #[test]
129    fn collapse_home_emits_forward_slash_on_windows_separators() {
130        if let Some(home) = home_dir() {
131            let home_str = home.to_string_lossy().to_string();
132            // Build the path with the same separator home_str uses, so
133            // strip_prefix succeeds on both Unix and Windows test runs.
134            let sep = if home_str.contains('\\') { '\\' } else { '/' };
135            let nested = format!("{home_str}{sep}atomcode{sep}src");
136            let got = collapse_home(&nested);
137            assert_eq!(got, "~/atomcode/src");
138            assert!(!got.contains('\\'), "must not retain backslashes: {got}");
139        }
140    }
141
142    #[test]
143    fn collapse_home_returns_unchanged_for_unrelated_path() {
144        assert_eq!(collapse_home("/opt/tool/bar"), "/opt/tool/bar");
145    }
146
147    #[test]
148    fn collapse_home_strips_windows_verbatim_prefix() {
149        // The Windows extended-length / verbatim prefix is never
150        // user-facing; strip it before display.
151        assert_eq!(
152            collapse_home(r"\\?\D:\wwwroot\xingyu-api"),
153            r"D:\wwwroot\xingyu-api"
154        );
155    }
156
157    #[test]
158    fn collapse_home_strips_windows_verbatim_unc_prefix() {
159        assert_eq!(
160            collapse_home(r"\\?\UNC\server\share\proj"),
161            r"\\server\share\proj"
162        );
163    }
164
165    /// Simulates the exact scenario reported in issue #356: on Windows 10
166    /// the user's home directory is `C:\Users\username` (as returned by
167    /// `dirs::home_dir()`) but `canonicalize` produces
168    /// `\\?\C:\Users\username\atomcode` — note the `\\?\` prefix that
169    /// `collapse_home` already strips, **and** a potential case mismatch
170    /// between the two paths.  On Windows the filesystem is
171    /// case-insensitive so `C:\Users` and `c:\users` refer to the same
172    /// directory, but `str::strip_prefix` is case-sensitive.  Without
173    /// case-insensitive comparison the prefix is not matched and the raw
174    /// path is shown instead of `~/atomcode`.
175    #[test]
176    fn collapse_home_windows_case_insensitive_match() {
177        // Directly call `collapse_home_with` with synthetic paths so the
178        // test works on every platform (macOS, Linux, CI).  We simulate
179        // the Windows scenario: home_dir returns `C:\Users\username` but
180        // canonicalize gives us a different casing.
181        let home = std::path::Path::new(r"C:\Users\username");
182
183        // Exact case — should always work
184        assert_eq!(
185            collapse_home_with(r"C:\Users\username\atomcode", Some(home)),
186            "~/atomcode"
187        );
188
189        // Home dir uses `C:\Users\username`, canonicalize returns
190        // `C:\USERS\username` — must still collapse to `~/atomcode`.
191        // On non-Windows cfg!(windows) is false so this test verifies
192        // the case-sensitive path; the Windows-specific test below
193        // covers the case-insensitive branch.
194        if cfg!(windows) {
195            assert_eq!(
196                collapse_home_with(r"C:\USERS\username\atomcode", Some(home)),
197                "~/atomcode"
198            );
199        }
200    }
201
202    /// On Windows, a verbatim-prefixed path whose casing differs from
203    /// `dirs::home_dir()` must still be collapsed.  This test only
204    /// asserts the verbatim-stripping + case-insensitive match when
205    /// actually running on Windows; on other platforms it verifies
206    /// that the verbatim prefix is stripped correctly (which is
207    /// platform-agnostic).
208    #[test]
209    fn collapse_home_windows_verbatim_with_different_case() {
210        let home = std::path::Path::new(r"C:\Users\username");
211
212        // Verbatim prefix stripped, exact-case home matched
213        assert_eq!(
214            collapse_home_with(r"\\?\C:\Users\username\atomcode", Some(home)),
215            "~/atomcode"
216        );
217
218        // On Windows the case-insensitive branch must also handle
219        // verbatim paths with different casing.
220        if cfg!(windows) {
221            assert_eq!(
222                collapse_home_with(r"\\?\C:\USERS\username\atomcode", Some(home)),
223                "~/atomcode"
224            );
225        }
226    }
227
228    #[test]
229    fn collapse_home_windows_exact_home() {
230        let home = std::path::Path::new(r"C:\Users\username");
231        assert_eq!(
232            collapse_home_with(r"C:\Users\username", Some(home)),
233            "~"
234        );
235    }
236
237    /// Regression test for the slice-offset bug in the Windows
238    /// case-insensitive branch.  The old code did:
239    ///
240    ///   path.to_lowercase()
241    ///       .strip_prefix(&home_str.to_lowercase())
242    ///       .map(|s| path[s.len()..].to_string())
243    ///
244    /// where `s` was the *lowercase remainder* after stripping the
245    /// home prefix.  `s.len()` equals `path.len() - home_str.len()`,
246    /// so `path[s.len()..]` took the **last** `home_str.len()` chars
247    /// of the original path instead of everything *after* the home
248    /// prefix.  For `C:\Users\hao\Documents\WPSDrive\NotLoginPage`
249    /// with home `C:\Users\hao`, the result was `~NotLoginPage`
250    /// instead of `~/Documents/WPSDrive/NotLoginPage`.
251    #[test]
252    fn collapse_home_windows_deeply_nested_path() {
253        let home = std::path::Path::new(r"C:\Users\hao");
254        assert_eq!(
255            collapse_home_with(
256                r"C:\Users\hao\Documents\WPSDrive\NotLoginPage",
257                Some(home),
258            ),
259            "~/Documents/WPSDrive/NotLoginPage"
260        );
261    }
262
263    /// Same bug, but exercising the verbatim-prefix path that
264    /// `std::fs::canonicalize` returns on Windows.  After the `\\?\`
265    /// prefix is stripped the remaining path must still be sliced at
266    /// `home_str.len()`, not at the lowercase-remainder length.
267    #[test]
268    fn collapse_home_windows_verbatim_deeply_nested_path() {
269        let home = std::path::Path::new(r"C:\Users\hao");
270        assert_eq!(
271            collapse_home_with(
272                r"\\?\C:\Users\hao\Documents\WPSDrive\NotLoginPage",
273                Some(home),
274            ),
275            "~/Documents/WPSDrive/NotLoginPage"
276        );
277    }
278
279    #[test]
280    fn history_path_never_panics() {
281        let _ = history_path();
282    }
283
284    #[test]
285    fn image_cache_dir_lives_under_config_dir() {
286        let p = image_cache_dir();
287        let cfg = atomcode_core::config::Config::config_dir();
288        assert!(p.starts_with(&cfg), "{:?} should be under {:?}", p, cfg);
289        assert_eq!(p.file_name().and_then(|s| s.to_str()), Some("image-cache"));
290    }
291}