Skip to main content

nu_config/
env_access.rs

1//! Process environment and platform directory seam for path resolution.
2//!
3//! Production code uses [`SystemEnv`]. Unit tests inject values with
4//! [`TestEnv`] so resolution can be verified without mutating the host
5//! process environment.
6
7use std::collections::HashMap;
8use std::ffi::{OsStr, OsString};
9use std::path::PathBuf;
10
11/// Abstraction over process environment and platform directory discovery so
12/// path-resolution logic can be unit-tested without touching the host.
13///
14/// # Seam
15///
16/// [`SystemEnv`] is the production adapter. [`TestEnv`] injects env vars and
17/// platform directory fallbacks for tests.
18///
19/// Prefer [`Self::var_os`] over UTF-8-only access when paths may contain
20/// non-Unicode bytes (Unix).
21pub trait EnvAccess {
22    /// Look up an environment variable as an [`OsString`] (preserves non-UTF-8).
23    fn var_os(&self, name: &str) -> Option<OsString>;
24
25    /// Platform config directory (e.g. `~/.config`), before the `nushell` suffix.
26    fn config_dir(&self) -> Option<PathBuf>;
27
28    /// Platform data directory (e.g. `~/.local/share`).
29    fn data_dir(&self) -> Option<PathBuf>;
30
31    /// Platform cache directory (e.g. `~/.cache`).
32    fn cache_dir(&self) -> Option<PathBuf>;
33
34    /// User home directory.
35    fn home_dir(&self) -> Option<PathBuf>;
36
37    /// Windows ProgramData folder. Unused on other OSes.
38    #[cfg(windows)]
39    fn program_data_dir(&self) -> Option<PathBuf>;
40
41    /// UTF-8 convenience wrapper around [`Self::var_os`].
42    fn var(&self, name: &str) -> Option<String> {
43        self.var_os(name).and_then(|value| value.into_string().ok())
44    }
45}
46
47/// Reads from the real process environment and `dirs` crate fallbacks.
48pub struct SystemEnv;
49
50impl EnvAccess for SystemEnv {
51    fn var_os(&self, name: &str) -> Option<OsString> {
52        std::env::var_os(name)
53    }
54
55    fn config_dir(&self) -> Option<PathBuf> {
56        dirs::config_dir()
57    }
58
59    fn data_dir(&self) -> Option<PathBuf> {
60        dirs::data_dir()
61    }
62
63    fn cache_dir(&self) -> Option<PathBuf> {
64        dirs::cache_dir()
65    }
66
67    fn home_dir(&self) -> Option<PathBuf> {
68        dirs::home_dir()
69    }
70
71    #[cfg(windows)]
72    fn program_data_dir(&self) -> Option<PathBuf> {
73        // Prefer the known folder when available; fall back to the env var.
74        // Call SHGetKnownFolderPath via our workspace `windows-sys` so we do not
75        // pass GUID types across different `windows-sys` versions that `dirs-sys`
76        // may resolve (those types are not interchangeable).
77        known_folder_program_data().or_else(|| std::env::var_os("ProgramData").map(PathBuf::from))
78    }
79}
80
81/// Resolve the Windows ProgramData known folder using the workspace `windows-sys`.
82#[cfg(windows)]
83fn known_folder_program_data() -> Option<PathBuf> {
84    use std::ffi::OsString;
85    use std::os::windows::ffi::OsStringExt;
86    use std::slice;
87    use windows_sys::Win32::Globalization::lstrlenW;
88    use windows_sys::Win32::System::Com::CoTaskMemFree;
89    use windows_sys::Win32::UI::Shell::{FOLDERID_ProgramData, SHGetKnownFolderPath};
90    use windows_sys::core::PWSTR;
91
92    // SAFETY: SHGetKnownFolderPath either returns a valid CoTaskMem-allocated
93    // wide string (result == 0) or nothing we may read; we free the pointer in
94    // both success and failure paths as the API requires.
95    unsafe {
96        let mut path_ptr: PWSTR = std::ptr::null_mut();
97        let result = SHGetKnownFolderPath(
98            &FOLDERID_ProgramData,
99            0,
100            std::ptr::null_mut(),
101            &mut path_ptr,
102        );
103        if result == 0 && !path_ptr.is_null() {
104            let len = lstrlenW(path_ptr) as usize;
105            let path = slice::from_raw_parts(path_ptr, len);
106            let ostr = OsString::from_wide(path);
107            CoTaskMemFree(path_ptr.cast());
108            Some(PathBuf::from(ostr))
109        } else {
110            if !path_ptr.is_null() {
111                CoTaskMemFree(path_ptr.cast());
112            }
113            None
114        }
115    }
116}
117
118/// In-memory environment + optional platform directory overrides for tests.
119///
120/// Build with [`TestEnv::new`] or [`TestEnv::with_os_vars`], then chain
121/// `with_*_dir` helpers for platform fallbacks.
122#[derive(derive_setters::Setters)]
123#[setters(prefix = "with_", strip_option, into)]
124pub struct TestEnv {
125    #[setters(skip)]
126    vars: HashMap<String, OsString>,
127    config_dir: Option<PathBuf>,
128    data_dir: Option<PathBuf>,
129    cache_dir: Option<PathBuf>,
130    home_dir: Option<PathBuf>,
131    #[cfg(windows)]
132    program_data_dir: Option<PathBuf>,
133}
134
135impl TestEnv {
136    /// Create a test env from UTF-8 key/value pairs.
137    pub fn new(vars: HashMap<String, String>) -> Self {
138        Self {
139            vars: vars
140                .into_iter()
141                .map(|(k, v)| (k, OsString::from(v)))
142                .collect(),
143            config_dir: None,
144            data_dir: None,
145            cache_dir: None,
146            home_dir: None,
147            #[cfg(windows)]
148            program_data_dir: None,
149        }
150    }
151
152    /// Create a test env from raw [`OsString`] values (for non-UTF-8 path tests).
153    pub fn with_os_vars(vars: HashMap<String, OsString>) -> Self {
154        Self {
155            vars,
156            config_dir: None,
157            data_dir: None,
158            cache_dir: None,
159            home_dir: None,
160            #[cfg(windows)]
161            program_data_dir: None,
162        }
163    }
164
165    /// Insert or replace an env var as an [`OsStr`].
166    pub fn insert_os(&mut self, name: impl Into<String>, value: impl AsRef<OsStr>) {
167        self.vars.insert(name.into(), value.as_ref().to_os_string());
168    }
169}
170
171impl EnvAccess for TestEnv {
172    fn var_os(&self, name: &str) -> Option<OsString> {
173        self.vars.get(name).cloned()
174    }
175
176    fn config_dir(&self) -> Option<PathBuf> {
177        self.config_dir.clone()
178    }
179
180    fn data_dir(&self) -> Option<PathBuf> {
181        self.data_dir.clone()
182    }
183
184    fn cache_dir(&self) -> Option<PathBuf> {
185        self.cache_dir.clone()
186    }
187
188    fn home_dir(&self) -> Option<PathBuf> {
189        self.home_dir.clone()
190    }
191
192    #[cfg(windows)]
193    fn program_data_dir(&self) -> Option<PathBuf> {
194        self.program_data_dir
195            .clone()
196            .or_else(|| self.var_os("ProgramData").map(PathBuf::from))
197    }
198}
199
200#[cfg(all(test, windows))]
201mod tests {
202    use super::*;
203    use std::path::{Component, Path};
204
205    /// Normalize for path comparisons that may differ only by separators or case.
206    fn path_key(path: &Path) -> String {
207        path.components()
208            .map(|c| match c {
209                Component::Prefix(p) => p.as_os_str().to_string_lossy().to_ascii_lowercase(),
210                Component::RootDir => String::new(),
211                Component::Normal(s) => s.to_string_lossy().to_ascii_lowercase(),
212                Component::CurDir => ".".into(),
213                Component::ParentDir => "..".into(),
214            })
215            .filter(|s| !s.is_empty())
216            .collect::<Vec<_>>()
217            .join("/")
218    }
219
220    #[test]
221    fn known_folder_program_data_returns_absolute_existing_directory() {
222        let path = known_folder_program_data().expect(
223            "SHGetKnownFolderPath(FOLDERID_ProgramData) should succeed on Windows CI/hosts",
224        );
225
226        assert!(
227            path.is_absolute(),
228            "ProgramData known folder must be absolute, got {path:?}"
229        );
230        assert!(
231            path.is_dir(),
232            "ProgramData known folder must exist as a directory, got {path:?}"
233        );
234        assert_eq!(
235            path.file_name().and_then(|n| n.to_str()),
236            Some("ProgramData"),
237            "expected final component to be ProgramData, got {path:?}"
238        );
239    }
240
241    #[test]
242    fn system_env_program_data_dir_uses_known_folder_when_available() {
243        let from_api = known_folder_program_data()
244            .expect("known folder lookup should succeed on Windows CI/hosts");
245        let from_system = SystemEnv
246            .program_data_dir()
247            .expect("SystemEnv::program_data_dir should resolve ProgramData");
248
249        assert_eq!(
250            path_key(&from_system),
251            path_key(&from_api),
252            "SystemEnv should prefer the known-folder path over env fallback; \
253             system={from_system:?} api={from_api:?}"
254        );
255
256        // When the common env var is set, it should agree with the known folder
257        // (case/separator differences aside).
258        if let Some(from_env) = std::env::var_os("ProgramData").map(PathBuf::from) {
259            assert_eq!(
260                path_key(&from_api),
261                path_key(&from_env),
262                "known folder and %ProgramData% should resolve to the same location; \
263                 api={from_api:?} env={from_env:?}"
264            );
265        }
266    }
267}