vfa_tui/paths.rs
1//! Cross-platform default path helpers (Task 11.5, Req 28.1–28.4).
2//!
3//! # Platform conventions
4//!
5//! - **Linux / WSL**: XDG Base Directory Specification.
6//! - Config: `$XDG_CONFIG_HOME/vfa/` (fallback: `~/.config/vfa/`)
7//! - Data: `$XDG_DATA_HOME/vfa/` (fallback: `~/.local/share/vfa/`)
8//! - **macOS**: Apple convention.
9//! - Config: `~/Library/Application Support/vfa/`
10//! - Data: `~/Library/Application Support/vfa/`
11//!
12//! # WSL detection (Req 28.3 / 28.4)
13//!
14//! WSL is detected on a best-effort basis by checking `WSL_DISTRO_NAME` env var
15//! or the existence of `/proc/sys/fs/binfmt_misc/WSLInterop`. When WSL is
16//! detected the tool falls back to standard Linux path conventions.
17
18use std::path::PathBuf;
19
20// ---------------------------------------------------------------------------
21// Public helpers
22// ---------------------------------------------------------------------------
23
24/// Default path for the workspace registry TOML.
25///
26/// - Linux / WSL: `$XDG_CONFIG_HOME/vfa/workspaces.toml`
27/// (fallback `~/.config/vfa/workspaces.toml`)
28/// - macOS: `~/Library/Application Support/vfa/workspaces.toml`
29pub fn default_registry_path() -> PathBuf {
30 config_dir().join("workspaces.toml")
31}
32
33/// Default path for the policies TOML.
34///
35/// - Linux / WSL: `$XDG_CONFIG_HOME/vfa/policies.toml`
36/// - macOS: `~/Library/Application Support/vfa/policies.toml`
37pub fn default_policies_path() -> PathBuf {
38 config_dir().join("policies.toml")
39}
40
41/// Default path for the SQLite index database.
42///
43/// - Linux / WSL: `$XDG_DATA_HOME/vfa/index.db`
44/// (fallback `~/.local/share/vfa/index.db`)
45/// - macOS: `~/Library/Application Support/vfa/index.db`
46pub fn default_index_path() -> PathBuf {
47 data_dir().join("index.db")
48}
49
50/// Detect whether the process is running inside Windows Subsystem for Linux.
51///
52/// This is a best-effort check (Req 28.3). On detection we fall back to
53/// standard Linux path conventions rather than Windows conventions.
54///
55/// Detection strategy (in order):
56/// 1. `WSL_DISTRO_NAME` environment variable is set (WSL 2+).
57/// 2. `/proc/sys/fs/binfmt_misc/WSLInterop` exists (WSL 1+).
58///
59/// Returns `false` on any I/O error (safe fallback).
60pub fn is_wsl() -> bool {
61 // Fast check via env var (WSL 2 always sets this).
62 if std::env::var_os("WSL_DISTRO_NAME").is_some() {
63 return true;
64 }
65 // Slower check: file presence (WSL 1 may not have the env var).
66 std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists()
67}
68
69// ---------------------------------------------------------------------------
70// Internal helpers
71// ---------------------------------------------------------------------------
72
73/// Return the VFA config directory for the current platform.
74fn config_dir() -> PathBuf {
75 #[cfg(target_os = "macos")]
76 {
77 home_dir()
78 .join("Library")
79 .join("Application Support")
80 .join("vfa")
81 }
82 #[cfg(not(target_os = "macos"))]
83 {
84 // Linux (including WSL) — XDG_CONFIG_HOME or fallback.
85 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
86 PathBuf::from(xdg).join("vfa")
87 } else {
88 home_dir().join(".config").join("vfa")
89 }
90 }
91}
92
93/// Return the VFA data directory for the current platform.
94fn data_dir() -> PathBuf {
95 #[cfg(target_os = "macos")]
96 {
97 home_dir()
98 .join("Library")
99 .join("Application Support")
100 .join("vfa")
101 }
102 #[cfg(not(target_os = "macos"))]
103 {
104 // Linux (including WSL) — XDG_DATA_HOME or fallback.
105 if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") {
106 PathBuf::from(xdg).join("vfa")
107 } else {
108 home_dir().join(".local").join("share").join("vfa")
109 }
110 }
111}
112
113/// Return the home directory using `HOME` env var (Linux / macOS).
114///
115/// Falls back to `/tmp` on failure so all callers get a valid (if wrong)
116/// `PathBuf` rather than panicking.
117fn home_dir() -> PathBuf {
118 std::env::var_os("HOME")
119 .map(PathBuf::from)
120 .unwrap_or_else(|| PathBuf::from("/tmp"))
121}
122
123// ---------------------------------------------------------------------------
124// Tests
125// ---------------------------------------------------------------------------
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use std::env;
131
132 // -----------------------------------------------------------------------
133 // default_registry_path
134 // -----------------------------------------------------------------------
135
136 #[test]
137 fn registry_path_ends_with_workspaces_toml() {
138 let p = default_registry_path();
139 assert_eq!(p.file_name().unwrap(), "workspaces.toml");
140 }
141
142 #[test]
143 fn policies_path_ends_with_policies_toml() {
144 let p = default_policies_path();
145 assert_eq!(p.file_name().unwrap(), "policies.toml");
146 }
147
148 #[test]
149 fn index_path_ends_with_index_db() {
150 let p = default_index_path();
151 assert_eq!(p.file_name().unwrap(), "index.db");
152 }
153
154 // -----------------------------------------------------------------------
155 // XDG override (Linux / non-macOS only — cfg guards keep macOS clean)
156 // -----------------------------------------------------------------------
157
158 #[cfg(not(target_os = "macos"))]
159 #[test]
160 fn xdg_config_home_overrides_config_dir() {
161 // Temporarily set XDG_CONFIG_HOME; note: not safe to run in parallel
162 // with other tests that also mutate env — isolated by test binary.
163 let old = env::var_os("XDG_CONFIG_HOME");
164
165 // SAFETY: single-threaded test binary section; no threads spawned here.
166 unsafe {
167 env::set_var("XDG_CONFIG_HOME", "/custom/config");
168 }
169 let p = default_registry_path();
170 // Restore before asserting so we clean up even if assert panics.
171 unsafe {
172 match &old {
173 Some(v) => env::set_var("XDG_CONFIG_HOME", v),
174 None => env::remove_var("XDG_CONFIG_HOME"),
175 }
176 }
177
178 assert!(
179 p.starts_with("/custom/config"),
180 "XDG_CONFIG_HOME should override config dir; got {p:?}"
181 );
182 }
183
184 #[cfg(not(target_os = "macos"))]
185 #[test]
186 fn xdg_data_home_overrides_data_dir() {
187 let old = env::var_os("XDG_DATA_HOME");
188
189 unsafe {
190 env::set_var("XDG_DATA_HOME", "/custom/data");
191 }
192 let p = default_index_path();
193 unsafe {
194 match &old {
195 Some(v) => env::set_var("XDG_DATA_HOME", v),
196 None => env::remove_var("XDG_DATA_HOME"),
197 }
198 }
199
200 assert!(
201 p.starts_with("/custom/data"),
202 "XDG_DATA_HOME should override data dir; got {p:?}"
203 );
204 }
205
206 // -----------------------------------------------------------------------
207 // macOS paths (only compiled/run on macOS)
208 // -----------------------------------------------------------------------
209
210 #[cfg(target_os = "macos")]
211 #[test]
212 fn macos_registry_path_under_library() {
213 let p = default_registry_path();
214 let s = p.to_string_lossy();
215 assert!(
216 s.contains("Library/Application Support/vfa"),
217 "macOS path should be under Library/Application Support/vfa; got {p:?}"
218 );
219 }
220
221 // -----------------------------------------------------------------------
222 // is_wsl
223 // -----------------------------------------------------------------------
224
225 #[test]
226 fn is_wsl_returns_bool_without_panic() {
227 // We cannot assert the value (depends on environment) but it must not panic.
228 let _ = is_wsl();
229 }
230
231 #[test]
232 fn is_wsl_detects_wsl_distro_name_env() {
233 let had_it = env::var_os("WSL_DISTRO_NAME").is_some();
234 if had_it {
235 // Already in WSL — just verify it returns true.
236 assert!(is_wsl());
237 } else {
238 // Set it and verify detection.
239 unsafe {
240 env::set_var("WSL_DISTRO_NAME", "Ubuntu");
241 }
242 let result = is_wsl();
243 unsafe {
244 env::remove_var("WSL_DISTRO_NAME");
245 }
246 assert!(result, "WSL_DISTRO_NAME should trigger is_wsl");
247 }
248 }
249
250 // -----------------------------------------------------------------------
251 // Platform-level sanity: all returned paths are absolute
252 // -----------------------------------------------------------------------
253
254 #[test]
255 fn all_default_paths_are_absolute() {
256 // Only holds when HOME is set (which it is in all normal environments).
257 if env::var_os("HOME").is_some() {
258 assert!(default_registry_path().is_absolute());
259 assert!(default_policies_path().is_absolute());
260 assert!(default_index_path().is_absolute());
261 }
262 }
263}