Skip to main content

path_rs/
dirs.rs

1//! Platform directory locations and application-level roots.
2//!
3//! Uses the `dirs` crate for platform-native resolution. Environment variable
4//! expansion (`%APPDATA%`, `$XDG_CONFIG_HOME`, etc.) is separate — see [`crate::expand`].
5//!
6//! # Global application root vs repository root
7//!
8//! - **Application root** ([`AppPaths`]): user/global state for a tool (config, data, cache).
9//! - **Repository root**: a project working tree discovered by the application.
10//! - **Current working directory**: process-local; never used as the global app root unless
11//!   an explicit policy says so.
12//!
13//! This module never places global application state under a repository automatically,
14//! and never hardcodes product-specific subdirectory names.
15
16use crate::error::PathError;
17use crate::expand::{ExpandOptions, expand_input};
18use crate::inspect::is_existing_directory;
19use crate::internal::validation::validate_app_name;
20use crate::normalize::normalize;
21use crate::resolve::absolute;
22use std::env;
23use std::fs;
24use std::path::{Path, PathBuf};
25
26/// Platform-standard directories for the current user / process.
27///
28/// # Platform notes
29///
30/// **Windows**
31/// - Home: `%USERPROFILE%`
32/// - Config / roaming data: `%APPDATA%`
33/// - Local data / cache: `%LOCALAPPDATA%`
34/// - Temp: `%TEMP%` or `%TMP%`
35///
36/// **macOS**
37/// - Home: `$HOME`
38/// - Config / data: `~/Library/Application Support`
39/// - Cache: `~/Library/Caches`
40/// - Temp: `$TMPDIR`
41///
42/// **Linux**
43/// - Home: `$HOME`
44/// - Config: `$XDG_CONFIG_HOME` or `~/.config`
45/// - Data: `$XDG_DATA_HOME` or `~/.local/share`
46/// - Cache: `$XDG_CACHE_HOME` or `~/.cache`
47/// - State: `$XDG_STATE_HOME` or `~/.local/state` when available
48/// - Runtime: `$XDG_RUNTIME_DIR` when available
49/// - Temp: `$TMPDIR` or system temp
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct PlatformDirs {
52    /// User home directory.
53    pub home: PathBuf,
54    /// User configuration directory.
55    pub config: PathBuf,
56    /// User data directory.
57    pub data: PathBuf,
58    /// User cache directory.
59    pub cache: PathBuf,
60    /// User state directory (XDG state), when available.
61    pub state: Option<PathBuf>,
62    /// Runtime directory (XDG runtime), when available.
63    pub runtime: Option<PathBuf>,
64    /// Temporary directory.
65    pub temp: PathBuf,
66}
67
68/// Resolve standard platform directories for the current user.
69///
70/// # Filesystem access
71///
72/// May consult environment variables and platform APIs. Does not create directories.
73pub fn platform_dirs() -> Result<PlatformDirs, PathError> {
74    let home = dirs::home_dir().ok_or(PathError::HomeDirectoryUnavailable)?;
75    let config = dirs::config_dir()
76        .ok_or_else(|| PathError::invalid("config directory is unavailable on this platform"))?;
77    let data = dirs::data_dir()
78        .ok_or_else(|| PathError::invalid("data directory is unavailable on this platform"))?;
79    let cache = dirs::cache_dir()
80        .ok_or_else(|| PathError::invalid("cache directory is unavailable on this platform"))?;
81    let state = dirs::state_dir();
82    let runtime = dirs::runtime_dir();
83    let temp = std::env::temp_dir();
84
85    Ok(PlatformDirs {
86        home,
87        config,
88        data,
89        cache,
90        state,
91        runtime,
92        temp,
93    })
94}
95
96/// Application-specific configuration directory: `{config}/{app_name}`.
97///
98/// Validates `app_name` (no separators, no reserved names, non-empty).
99/// Does not create the directory.
100pub fn config_dir(app_name: &str) -> Result<PathBuf, PathError> {
101    validate_app_name(app_name)?;
102    let base = dirs::config_dir()
103        .ok_or_else(|| PathError::invalid("config directory is unavailable on this platform"))?;
104    Ok(base.join(app_name))
105}
106
107/// Application-specific data directory: `{data}/{app_name}`.
108///
109/// Does not create the directory.
110pub fn data_dir(app_name: &str) -> Result<PathBuf, PathError> {
111    validate_app_name(app_name)?;
112    let base = dirs::data_dir()
113        .ok_or_else(|| PathError::invalid("data directory is unavailable on this platform"))?;
114    Ok(base.join(app_name))
115}
116
117/// Application-specific cache directory: `{cache}/{app_name}`.
118///
119/// Does not create the directory.
120pub fn cache_dir(app_name: &str) -> Result<PathBuf, PathError> {
121    validate_app_name(app_name)?;
122    let base = dirs::cache_dir()
123        .ok_or_else(|| PathError::invalid("cache directory is unavailable on this platform"))?;
124    Ok(base.join(app_name))
125}
126
127/// Temporary directory for the process.
128pub fn temp_dir() -> PathBuf {
129    std::env::temp_dir()
130}
131
132/// Application-level platform roots for a named tool.
133///
134/// Callers may join product-specific subdirectories under [`Self::root_dir`].
135/// Those subdirectory names are **not** defined by this crate.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct AppPaths {
138    /// Validated application name.
139    pub application_name: String,
140    /// Primary application root directory.
141    pub root_dir: PathBuf,
142    /// Configuration directory for the application.
143    pub config_dir: PathBuf,
144    /// Data directory for the application.
145    pub data_dir: PathBuf,
146    /// Cache directory for the application.
147    pub cache_dir: PathBuf,
148    /// State directory when available.
149    pub state_dir: Option<PathBuf>,
150    /// Temporary directory (system temp unless rooted under an explicit override).
151    pub temp_dir: PathBuf,
152}
153
154/// How to choose the application root.
155///
156/// Does not hardcode product environment variables; callers pass the variable name.
157#[derive(Debug, Clone, PartialEq, Eq)]
158#[non_exhaustive]
159pub enum AppRootPolicy {
160    /// Use platform-native application directories (`dirs`).
161    PlatformDefault,
162    /// Prefer an environment variable when set and non-empty; else platform default.
163    EnvironmentOverride {
164        /// Name of the environment variable (not its value).
165        variable: String,
166    },
167    /// Use an explicit root path (expanded/normalized by the caller or this API).
168    Explicit {
169        /// Explicit application root.
170        path: PathBuf,
171    },
172}
173
174/// Options for resolving [`AppPaths`].
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct AppPathsOptions {
177    /// Application name (validated).
178    pub application_name: String,
179    /// Optional environment variable name used as root override when set.
180    ///
181    /// Equivalent to [`AppRootPolicy::EnvironmentOverride`] when `Some`.
182    pub environment_override: Option<String>,
183    /// When true, create the resolved directories.
184    pub create_directories: bool,
185    /// Explicit root policy. When set, takes precedence over `environment_override`
186    /// alone for the policy shape; if both are present, `root_policy` wins.
187    pub root_policy: Option<AppRootPolicy>,
188}
189
190impl AppPathsOptions {
191    /// Build options for `application_name` with platform defaults.
192    pub fn new(application_name: impl Into<String>) -> Self {
193        Self {
194            application_name: application_name.into(),
195            environment_override: None,
196            create_directories: false,
197            root_policy: None,
198        }
199    }
200}
201
202/// Resolve application paths using platform defaults (no directory creation).
203///
204/// # Filesystem access
205///
206/// Consults platform directory APIs. Does not create directories.
207/// Does not use the process current directory as the global root.
208pub fn app_paths(application_name: &str) -> Result<AppPaths, PathError> {
209    app_paths_with_options(AppPathsOptions {
210        application_name: application_name.to_owned(),
211        environment_override: None,
212        create_directories: false,
213        root_policy: Some(AppRootPolicy::PlatformDefault),
214    })
215}
216
217/// Resolve application paths with explicit options.
218///
219/// # Environment override
220///
221/// When an override variable is set and non-empty, its value is expanded with
222/// [`expand_input`] (tilde, `%VAR%`, `$VAR`) and used as the application root.
223/// Relative override values are made absolute against the current directory.
224///
225/// # Filesystem access
226///
227/// Platform directory lookup always. Optional directory creation when requested.
228/// May read the current directory only to absolutize a **relative** override.
229pub fn app_paths_with_options(options: AppPathsOptions) -> Result<AppPaths, PathError> {
230    validate_app_name(&options.application_name)?;
231
232    let policy = options.root_policy.clone().unwrap_or_else(|| {
233        if let Some(var) = &options.environment_override {
234            AppRootPolicy::EnvironmentOverride {
235                variable: var.clone(),
236            }
237        } else {
238            AppRootPolicy::PlatformDefault
239        }
240    });
241
242    let mut paths = match policy {
243        AppRootPolicy::PlatformDefault => platform_app_paths(&options.application_name)?,
244        AppRootPolicy::Explicit { path } => rooted_app_paths(&options.application_name, path)?,
245        AppRootPolicy::EnvironmentOverride { variable } => match env::var(&variable) {
246            Ok(value) if !value.trim().is_empty() => {
247                let expanded = expand_input(
248                    value.trim(),
249                    &ExpandOptions {
250                        reject_undefined_variables: true,
251                        ..ExpandOptions::default()
252                    },
253                )?;
254                let abs = if expanded.is_absolute() {
255                    normalize(expanded)?
256                } else {
257                    absolute(expanded)?
258                };
259                rooted_app_paths(&options.application_name, abs)?
260            }
261            Ok(_) | Err(env::VarError::NotPresent) => {
262                platform_app_paths(&options.application_name)?
263            }
264            Err(env::VarError::NotUnicode(_)) => {
265                return Err(PathError::invalid(format!(
266                    "environment variable {variable} is not valid Unicode"
267                )));
268            }
269        },
270    };
271
272    if options.create_directories {
273        create_app_directories(&paths)?;
274    } else {
275        // Soft validation: if root exists and is a file, error early.
276        if paths.root_dir.exists() && !is_existing_directory(&paths.root_dir) {
277            return Err(PathError::invalid(format!(
278                "application root exists and is not a directory: {}",
279                paths.root_dir.to_string_lossy()
280            )));
281        }
282    }
283
284    // Re-check after create.
285    if options.create_directories && !is_existing_directory(&paths.root_dir) {
286        return Err(PathError::invalid(format!(
287            "failed to materialize application root as a directory: {}",
288            paths.root_dir.to_string_lossy()
289        )));
290    }
291
292    let _ = &mut paths;
293    Ok(paths)
294}
295
296/// Resolve paths using an explicit [`AppRootPolicy`].
297pub fn app_paths_with_policy(
298    application_name: &str,
299    policy: AppRootPolicy,
300    create_directories: bool,
301) -> Result<AppPaths, PathError> {
302    app_paths_with_options(AppPathsOptions {
303        application_name: application_name.to_owned(),
304        environment_override: None,
305        create_directories,
306        root_policy: Some(policy),
307    })
308}
309
310fn platform_app_paths(application_name: &str) -> Result<AppPaths, PathError> {
311    let data = data_dir(application_name)?;
312    let config = config_dir(application_name)?;
313    let cache = cache_dir(application_name)?;
314    let state = dirs::state_dir().map(|s| s.join(application_name));
315    Ok(AppPaths {
316        application_name: application_name.to_owned(),
317        // Primary root is the data directory for the application.
318        root_dir: data.clone(),
319        config_dir: config,
320        data_dir: data,
321        cache_dir: cache,
322        state_dir: state,
323        temp_dir: temp_dir(),
324    })
325}
326
327fn rooted_app_paths(application_name: &str, root: PathBuf) -> Result<AppPaths, PathError> {
328    let root = normalize(root)?;
329    if root.as_os_str().is_empty() {
330        return Err(PathError::EmptyInput);
331    }
332    Ok(AppPaths {
333        application_name: application_name.to_owned(),
334        config_dir: root.clone(),
335        data_dir: root.clone(),
336        cache_dir: root.join("cache"),
337        state_dir: Some(root.join("state")),
338        temp_dir: temp_dir(),
339        root_dir: root,
340    })
341}
342
343fn create_app_directories(paths: &AppPaths) -> Result<(), PathError> {
344    for dir in [
345        Some(paths.root_dir.as_path()),
346        Some(paths.config_dir.as_path()),
347        Some(paths.data_dir.as_path()),
348        Some(paths.cache_dir.as_path()),
349        paths.state_dir.as_deref(),
350    ]
351    .into_iter()
352    .flatten()
353    {
354        create_dir_if_needed(dir)?;
355    }
356    Ok(())
357}
358
359fn create_dir_if_needed(path: &Path) -> Result<(), PathError> {
360    if path.exists() {
361        if is_existing_directory(path) {
362            return Ok(());
363        }
364        return Err(PathError::invalid(format!(
365            "path exists and is not a directory: {}",
366            path.to_string_lossy()
367        )));
368    }
369    fs::create_dir_all(path).map_err(|e| PathError::filesystem(path, e))
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn platform_dirs_available() {
378        let d = platform_dirs().unwrap();
379        assert!(!d.home.as_os_str().is_empty());
380        assert!(!d.temp.as_os_str().is_empty());
381    }
382
383    #[test]
384    fn app_name_validation() {
385        assert!(config_dir("").is_err());
386        assert!(config_dir("a/b").is_err());
387        assert!(config_dir("..").is_err());
388        assert!(config_dir("CON").is_err());
389        assert!(config_dir("my-app").is_ok());
390    }
391
392    #[test]
393    fn app_paths_platform() {
394        let p = app_paths("path-rs-test-app").unwrap();
395        assert_eq!(p.application_name, "path-rs-test-app");
396        assert!(p.root_dir.ends_with("path-rs-test-app"));
397    }
398
399    #[test]
400    fn app_paths_explicit_and_create() {
401        let dir = tempfile::tempdir().unwrap();
402        let root = dir.path().join("app-root");
403        let p = app_paths_with_policy(
404            "my-tool",
405            AppRootPolicy::Explicit { path: root.clone() },
406            true,
407        )
408        .unwrap();
409        assert_eq!(p.root_dir, normalize(&root).unwrap());
410        assert!(p.root_dir.is_dir());
411        assert!(p.cache_dir.is_dir());
412    }
413}