Skip to main content

cloudiful_config/
paths.rs

1use std::ffi::OsString;
2use std::io::{self, ErrorKind};
3use std::path::{Path, PathBuf};
4
5/// Resolve the per-user config directory for `app_name` on the current OS.
6pub fn config_dir(app_name: &str) -> io::Result<PathBuf> {
7    let app_name = Path::new(app_name);
8    validate_relative_path(app_name, "app name")?;
9    Ok(config_root()?.join(app_name))
10}
11
12/// Resolve a config file path relative to the per-user config directory.
13pub fn config_path<P>(app_name: &str, relative_path: P) -> io::Result<PathBuf>
14where
15    P: AsRef<Path>,
16{
17    let relative_path = relative_path.as_ref();
18    validate_relative_path(relative_path, "config path")?;
19    Ok(config_dir(app_name)?.join(relative_path))
20}
21
22fn validate_relative_path(path: &Path, label: &str) -> io::Result<()> {
23    if path.as_os_str().is_empty() {
24        return Err(io::Error::new(
25            ErrorKind::InvalidInput,
26            format!("{label} must not be empty"),
27        ));
28    }
29
30    if path.is_absolute() {
31        return Err(io::Error::new(
32            ErrorKind::InvalidInput,
33            format!("{label} must be relative, got {}", path.display()),
34        ));
35    }
36
37    Ok(())
38}
39
40fn env_path_with(get_env: impl Fn(&str) -> Option<OsString>, key: &str) -> Option<PathBuf> {
41    get_env(key)
42        .filter(|value| !value.is_empty())
43        .map(PathBuf::from)
44}
45
46#[cfg(windows)]
47fn config_root_from(get_env: impl Fn(&str) -> Option<OsString>) -> io::Result<PathBuf> {
48    env_path_with(&get_env, "APPDATA")
49        .or_else(|| {
50            env_path_with(&get_env, "USERPROFILE")
51                .map(|path| path.join("AppData").join("Roaming"))
52        })
53        .or_else(|| match (get_env("HOMEDRIVE"), get_env("HOMEPATH")) {
54            (Some(drive), Some(path)) if !drive.is_empty() && !path.is_empty() => {
55                Some(PathBuf::from(drive).join(path))
56            }
57            _ => None,
58        })
59        .map(|path| {
60            if path.ends_with("Roaming") {
61                path
62            } else {
63                path.join("AppData").join("Roaming")
64            }
65        })
66        .ok_or_else(|| {
67            io::Error::new(
68                ErrorKind::NotFound,
69                "failed to resolve Windows config directory from APPDATA, USERPROFILE, or HOMEDRIVE/HOMEPATH",
70            )
71        })
72}
73
74#[cfg(target_os = "macos")]
75fn config_root_from(get_env: impl Fn(&str) -> Option<OsString>) -> io::Result<PathBuf> {
76    env_path_with(get_env, "HOME")
77        .map(|path| path.join("Library").join("Application Support"))
78        .ok_or_else(|| {
79            io::Error::new(
80                ErrorKind::NotFound,
81                "failed to resolve macOS config directory from HOME",
82            )
83        })
84}
85
86#[cfg(all(not(windows), not(target_os = "macos")))]
87fn config_root_from(get_env: impl Fn(&str) -> Option<OsString>) -> io::Result<PathBuf> {
88    env_path_with(&get_env, "XDG_CONFIG_HOME")
89        .or_else(|| env_path_with(get_env, "HOME").map(|path| path.join(".config")))
90        .ok_or_else(|| {
91            io::Error::new(
92                ErrorKind::NotFound,
93                "failed to resolve config directory from XDG_CONFIG_HOME or HOME",
94            )
95        })
96}
97
98fn config_root() -> io::Result<PathBuf> {
99    config_root_from(|key| std::env::var_os(key))
100}
101
102#[cfg(test)]
103pub(crate) fn test_config_root_from(
104    get_env: impl Fn(&str) -> Option<OsString>,
105) -> io::Result<PathBuf> {
106    config_root_from(get_env)
107}