Skip to main content

telar_services_core/
app_paths.rs

1//! Where an application's files go, asked rather than re-derived.
2//!
3//! [`AppPathsProvider`](crate::paths::AppPathsProvider) is the seam a platform adapter implements; this is the
4//! side an *application* uses. The runner installs the platform's provider and the app's name once at startup,
5//! so a caller asks `paths::cache()` instead of resolving `$XDG_CACHE_HOME` for itself and getting a different
6//! answer than the runtime it is embedded in.
7
8use std::ffi::OsString;
9use std::path::{Path, PathBuf};
10use std::sync::OnceLock;
11
12use crate::paths::AppPathsProvider;
13
14struct Installed {
15    name: String,
16    provider: std::sync::Arc<dyn AppPathsProvider>,
17}
18
19static INSTALLED: OnceLock<Installed> = OnceLock::new();
20
21/// Installs the platform's provider and the name every app-scoped directory is nested under. Called by the
22/// runner; a second call is ignored, so an embedded surface cannot repoint a host's directories.
23pub fn install(name: impl Into<String>, provider: std::sync::Arc<dyn AppPathsProvider>) {
24    let _ = INSTALLED.set(Installed {
25        name: name.into(),
26        provider,
27    });
28}
29
30fn scoped(base: impl Fn(&dyn AppPathsProvider) -> Option<PathBuf>) -> Option<PathBuf> {
31    let installed = INSTALLED.get()?;
32    Some(base(installed.provider.as_ref())?.join(&installed.name))
33}
34
35/// The app's own directory under the platform's config root, or `None` before a runner has installed one —
36/// which is also what a preview window and a headless test get, so neither touches a real XDG path.
37pub fn config() -> Option<PathBuf> {
38    scoped(|p| p.config_dir())
39}
40
41/// Persistent user data the app owns.
42pub fn data() -> Option<PathBuf> {
43    scoped(|p| p.data_dir())
44}
45
46/// Regenerable artefacts — thumbnails, decoded icons — that are safe to delete.
47pub fn cache() -> Option<PathBuf> {
48    scoped(|p| p.cache_dir())
49}
50
51/// Machine-written state the user never edits, as opposed to the config they own.
52pub fn state() -> Option<PathBuf> {
53    scoped(|p| p.state_dir())
54}
55
56/// Session-scoped runtime files — a socket, a lock — on a platform that has such a place for them.
57pub fn runtime() -> Option<PathBuf> {
58    scoped(|p| p.runtime_dir())
59}
60
61/// The user's home directory, or `None` where `$HOME` names nothing — which is how a process started without an
62/// environment presents, and a reason to fall back rather than to build a path rooted at `/`.
63pub fn home() -> Option<PathBuf> {
64    std::env::var_os("HOME")
65        .filter(|home| !home.is_empty())
66        .map(PathBuf::from)
67}
68
69/// Expands a leading `~` (bare or `~/…`) to `$HOME`, leaving every other path untouched.
70///
71/// User-authored config paths commonly use `~`, which the OS does not resolve on its own.
72pub fn expand_tilde(path: &Path) -> PathBuf {
73    let Ok(rest) = path.strip_prefix("~") else {
74        return path.to_path_buf();
75    };
76    match home() {
77        Some(home) => home.join(rest),
78        None => path.to_path_buf(),
79    }
80}
81
82/// Creates `dir` (and its parents) and returns it, so a caller can chain straight into a file path.
83pub fn ensure_dir(dir: PathBuf) -> PathBuf {
84    if let Err(e) = std::fs::create_dir_all(&dir) {
85        tracing::warn!("could not create {}: {e}", dir.display());
86    }
87    dir
88}
89
90/// A well-known user directory (`XDG_PICTURES_DIR`, `XDG_VIDEOS_DIR`, …), else `$HOME/<fallback>`.
91///
92/// These are not environment variables on most sessions: `xdg-user-dirs` writes them to
93/// `user-dirs.dirs` in the config root as a shell fragment that a login script sources, so a process started
94/// any other way never sees them. Reading the file directly is what makes a screenshot land in the user's own
95/// pictures directory on a localised system, where it is called `Imágenes` and no fallback would find it.
96pub fn user_dir(name: &str, fallback: &str) -> PathBuf {
97    let home = home();
98    let default = || match &home {
99        Some(home) => home.join(fallback),
100        None => PathBuf::from(fallback),
101    };
102    if let Some(value) = std::env::var_os(name).filter(|v| !v.is_empty()) {
103        return PathBuf::from(value);
104    }
105    let Some(home) = home.clone() else {
106        return default();
107    };
108    let Some(config_root) = INSTALLED
109        .get()
110        .and_then(|i| i.provider.config_dir())
111        .or_else(|| {
112            std::env::var_os("XDG_CONFIG_HOME")
113                .filter(|v| !v.is_empty())
114                .map(PathBuf::from)
115                .or_else(|| Some(home.join(".config")))
116        })
117    else {
118        return default();
119    };
120    let Ok(text) = std::fs::read_to_string(config_root.join("user-dirs.dirs")) else {
121        return default();
122    };
123    parse_user_dirs(&text, name)
124        .map(|value| PathBuf::from(value.replace("$HOME", &home.to_string_lossy())))
125        .unwrap_or_else(default)
126}
127
128/// Reads one `NAME="value"` assignment out of `user-dirs.dirs`, ignoring comments. `$HOME` is left in the value
129/// for the caller to expand, since only it knows what home is.
130fn parse_user_dirs(text: &str, name: &str) -> Option<String> {
131    for line in text.lines() {
132        let line = line.trim();
133        if line.starts_with('#') {
134            continue;
135        }
136        let Some((key, value)) = line.split_once('=') else {
137            continue;
138        };
139        if key.trim() != name {
140            continue;
141        }
142        let value = value.trim().trim_matches('"');
143        if !value.is_empty() {
144            return Some(value.to_string());
145        }
146    }
147    None
148}
149
150/// The XDG resolution rule over values rather than the environment: the variable when it names a non-empty
151/// path, else `$HOME` joined with `fallback`, else `fallback` relative.
152///
153/// Taking the values as arguments keeps it testable without mutating process-wide environment, which would race
154/// every other test in the binary.
155pub fn resolve_base(var: Option<OsString>, home: Option<OsString>, fallback: &str) -> PathBuf {
156    var.map(PathBuf::from)
157        .filter(|p| !p.as_os_str().is_empty())
158        .or_else(|| home.map(|h| PathBuf::from(h).join(fallback)))
159        .unwrap_or_else(|| PathBuf::from(fallback))
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn a_leading_tilde_becomes_home_and_nothing_else_moves() {
168        let absolute = Path::new("/etc/hosts");
169        assert_eq!(expand_tilde(absolute), absolute);
170        let relative = Path::new("pictures/a.png");
171        assert_eq!(expand_tilde(relative), relative);
172        // A `~` inside the path is a directory literally called `~`, not a home to expand.
173        let inner = Path::new("/tmp/~/x");
174        assert_eq!(expand_tilde(inner), inner);
175    }
176
177    #[test]
178    fn a_user_dirs_assignment_is_read_past_its_quotes_and_comments() {
179        let text =
180            "# generated\nXDG_PICTURES_DIR=\"$HOME/Imágenes\"\nXDG_VIDEOS_DIR=\"$HOME/Vídeos\"\n";
181        assert_eq!(
182            parse_user_dirs(text, "XDG_PICTURES_DIR").as_deref(),
183            Some("$HOME/Imágenes")
184        );
185        assert_eq!(parse_user_dirs(text, "XDG_MUSIC_DIR"), None);
186    }
187
188    #[test]
189    fn a_commented_assignment_is_not_an_answer() {
190        let text = "#XDG_PICTURES_DIR=\"$HOME/wrong\"\nXDG_PICTURES_DIR=\"$HOME/right\"\n";
191        assert_eq!(
192            parse_user_dirs(text, "XDG_PICTURES_DIR").as_deref(),
193            Some("$HOME/right")
194        );
195    }
196
197    #[test]
198    fn the_xdg_rule_prefers_the_variable_then_home_then_the_bare_fallback() {
199        assert_eq!(
200            resolve_base(Some("/x".into()), Some("/home/u".into()), ".cache"),
201            PathBuf::from("/x")
202        );
203        // An empty variable is not an answer: it is how an unset one presents through the shell.
204        assert_eq!(
205            resolve_base(Some("".into()), Some("/home/u".into()), ".cache"),
206            PathBuf::from("/home/u/.cache")
207        );
208        assert_eq!(resolve_base(None, None, ".cache"), PathBuf::from(".cache"));
209    }
210
211    /// Nothing installed is the preview/headless case, and it must answer `None` rather than guess a real path.
212    #[test]
213    fn an_app_directory_is_none_until_a_runner_installs_one() {
214        if INSTALLED.get().is_none() {
215            assert_eq!(cache(), None);
216            assert_eq!(runtime(), None);
217        }
218    }
219}