Skip to main content

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}