telar_services_core/
app_paths.rs1use 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
21pub 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
35pub fn config() -> Option<PathBuf> {
38 scoped(|p| p.config_dir())
39}
40
41pub fn data() -> Option<PathBuf> {
43 scoped(|p| p.data_dir())
44}
45
46pub fn cache() -> Option<PathBuf> {
48 scoped(|p| p.cache_dir())
49}
50
51pub fn state() -> Option<PathBuf> {
53 scoped(|p| p.state_dir())
54}
55
56pub fn runtime() -> Option<PathBuf> {
58 scoped(|p| p.runtime_dir())
59}
60
61pub fn home() -> Option<PathBuf> {
64 std::env::var_os("HOME")
65 .filter(|home| !home.is_empty())
66 .map(PathBuf::from)
67}
68
69pub 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
82pub 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
90pub 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
128fn 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
150pub 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 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 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 #[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}