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}