1use std::{env, path::PathBuf};
2
3pub const APP_ID: &str = "dev.taskers.app";
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum HostPlatform {
7 Linux,
8 Macos,
9 Other,
10}
11
12impl HostPlatform {
13 pub const fn detect() -> Self {
14 if cfg!(target_os = "linux") {
15 Self::Linux
16 } else if cfg!(target_os = "macos") {
17 Self::Macos
18 } else {
19 Self::Other
20 }
21 }
22}
23
24#[derive(Debug, Clone, Default)]
25struct EnvPaths {
26 home: Option<PathBuf>,
27 xdg_cache_home: Option<PathBuf>,
28 xdg_config_home: Option<PathBuf>,
29 xdg_data_home: Option<PathBuf>,
30 xdg_runtime_dir: Option<PathBuf>,
31 xdg_state_home: Option<PathBuf>,
32 taskers_config_path: Option<PathBuf>,
33 taskers_ghostty_runtime_dir: Option<PathBuf>,
34 taskers_runtime_dir: Option<PathBuf>,
35 taskers_session_path: Option<PathBuf>,
36 taskers_socket_path: Option<PathBuf>,
37}
38
39impl EnvPaths {
40 fn current() -> Self {
41 Self {
42 home: env::var_os("HOME").map(PathBuf::from),
43 xdg_cache_home: env::var_os("XDG_CACHE_HOME").map(PathBuf::from),
44 xdg_config_home: env::var_os("XDG_CONFIG_HOME").map(PathBuf::from),
45 xdg_data_home: env::var_os("XDG_DATA_HOME").map(PathBuf::from),
46 xdg_runtime_dir: env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from),
47 xdg_state_home: env::var_os("XDG_STATE_HOME").map(PathBuf::from),
48 taskers_config_path: env::var_os("TASKERS_CONFIG_PATH").map(PathBuf::from),
49 taskers_ghostty_runtime_dir: env::var_os("TASKERS_GHOSTTY_RUNTIME_DIR")
50 .map(PathBuf::from),
51 taskers_runtime_dir: env::var_os("TASKERS_RUNTIME_DIR").map(PathBuf::from),
52 taskers_session_path: env::var_os("TASKERS_SESSION_PATH").map(PathBuf::from),
53 taskers_socket_path: env::var_os("TASKERS_SOCKET_PATH").map(PathBuf::from),
54 }
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct TaskersPaths {
60 config_dir: PathBuf,
61 state_dir: PathBuf,
62 cache_dir: PathBuf,
63 data_dir: PathBuf,
64 shell_runtime_dir: PathBuf,
65 ghostty_runtime_dir: PathBuf,
66 socket_path: PathBuf,
67 session_path: PathBuf,
68 config_path: PathBuf,
69 theme_dir: PathBuf,
70}
71
72impl TaskersPaths {
73 pub fn detect() -> Self {
74 Self::from_env(HostPlatform::detect(), &EnvPaths::current())
75 }
76
77 fn from_env(platform: HostPlatform, env_paths: &EnvPaths) -> Self {
78 if let Some(config_path) = env_paths.taskers_config_path.clone() {
79 let config_dir = config_path
80 .parent()
81 .map(PathBuf::from)
82 .unwrap_or_else(|| temp_root().join("config"));
83 let state_dir = env_paths
84 .taskers_session_path
85 .clone()
86 .and_then(|path| path.parent().map(PathBuf::from))
87 .unwrap_or_else(|| platform_state_dir(platform, env_paths));
88 let cache_dir = platform_cache_dir(platform, env_paths);
89 let data_dir = platform_data_dir(platform, env_paths);
90 let shell_runtime_dir = shell_runtime_dir(platform, env_paths, &cache_dir);
91 let ghostty_runtime_dir = env_paths
92 .taskers_ghostty_runtime_dir
93 .clone()
94 .unwrap_or_else(|| data_dir.join("ghostty"));
95 let socket_path = env_paths
96 .taskers_socket_path
97 .clone()
98 .unwrap_or_else(|| socket_path(platform, &cache_dir));
99 let session_path = env_paths
100 .taskers_session_path
101 .clone()
102 .unwrap_or_else(|| state_dir.join("session.json"));
103 return Self {
104 theme_dir: config_dir.join("themes"),
105 config_dir,
106 state_dir,
107 cache_dir,
108 data_dir,
109 shell_runtime_dir,
110 ghostty_runtime_dir,
111 socket_path,
112 session_path,
113 config_path,
114 };
115 }
116
117 let config_dir = platform_config_dir(platform, env_paths);
118 let state_dir = platform_state_dir(platform, env_paths);
119 let cache_dir = platform_cache_dir(platform, env_paths);
120 let data_dir = platform_data_dir(platform, env_paths);
121 let shell_runtime_dir = shell_runtime_dir(platform, env_paths, &cache_dir);
122 let ghostty_runtime_dir = env_paths
123 .taskers_ghostty_runtime_dir
124 .clone()
125 .unwrap_or_else(|| data_dir.join("ghostty"));
126 let socket_path = env_paths
127 .taskers_socket_path
128 .clone()
129 .unwrap_or_else(|| socket_path(platform, &cache_dir));
130 let session_path = env_paths
131 .taskers_session_path
132 .clone()
133 .unwrap_or_else(|| state_dir.join("session.json"));
134
135 Self {
136 config_path: config_dir.join("config.json"),
137 theme_dir: config_dir.join("themes"),
138 config_dir,
139 state_dir,
140 cache_dir,
141 data_dir,
142 shell_runtime_dir,
143 ghostty_runtime_dir,
144 socket_path,
145 session_path,
146 }
147 }
148
149 pub fn config_dir(&self) -> &PathBuf {
150 &self.config_dir
151 }
152
153 pub fn state_dir(&self) -> &PathBuf {
154 &self.state_dir
155 }
156
157 pub fn cache_dir(&self) -> &PathBuf {
158 &self.cache_dir
159 }
160
161 pub fn data_dir(&self) -> &PathBuf {
162 &self.data_dir
163 }
164
165 pub fn shell_runtime_dir(&self) -> &PathBuf {
166 &self.shell_runtime_dir
167 }
168
169 pub fn ghostty_runtime_dir(&self) -> &PathBuf {
170 &self.ghostty_runtime_dir
171 }
172
173 pub fn socket_path(&self) -> &PathBuf {
174 &self.socket_path
175 }
176
177 pub fn session_path(&self) -> &PathBuf {
178 &self.session_path
179 }
180
181 pub fn config_path(&self) -> &PathBuf {
182 &self.config_path
183 }
184
185 pub fn theme_dir(&self) -> &PathBuf {
186 &self.theme_dir
187 }
188}
189
190pub fn default_socket_path() -> PathBuf {
191 TaskersPaths::detect().socket_path
192}
193
194pub fn default_session_path() -> PathBuf {
195 TaskersPaths::detect().session_path
196}
197
198pub fn default_config_path() -> PathBuf {
199 TaskersPaths::detect().config_path
200}
201
202pub fn default_theme_dir() -> PathBuf {
203 TaskersPaths::detect().theme_dir
204}
205
206pub fn default_shell_runtime_dir() -> PathBuf {
207 TaskersPaths::detect().shell_runtime_dir
208}
209
210pub fn default_ghostty_runtime_dir() -> PathBuf {
211 TaskersPaths::detect().ghostty_runtime_dir
212}
213
214pub fn default_release_install_root() -> PathBuf {
215 TaskersPaths::detect().data_dir.join("releases")
216}
217
218fn platform_config_dir(platform: HostPlatform, env_paths: &EnvPaths) -> PathBuf {
219 match platform {
220 HostPlatform::Macos => home_library_dir(env_paths, "Application Support"),
221 HostPlatform::Linux => env_paths
222 .xdg_config_home
223 .clone()
224 .map(|path| path.join("taskers"))
225 .or_else(|| {
226 env_paths
227 .home
228 .clone()
229 .map(|path| path.join(".config").join("taskers"))
230 })
231 .unwrap_or_else(|| temp_root().join("config")),
232 HostPlatform::Other => env_paths
233 .home
234 .clone()
235 .map(|path| path.join(".taskers"))
236 .unwrap_or_else(|| temp_root().join("config")),
237 }
238}
239
240fn platform_state_dir(platform: HostPlatform, env_paths: &EnvPaths) -> PathBuf {
241 match platform {
242 HostPlatform::Macos => home_library_dir(env_paths, "Application Support"),
243 HostPlatform::Linux => env_paths
244 .xdg_state_home
245 .clone()
246 .map(|path| path.join("taskers"))
247 .or_else(|| {
248 env_paths
249 .home
250 .clone()
251 .map(|path| path.join(".local").join("state").join("taskers"))
252 })
253 .unwrap_or_else(|| temp_root().join("state")),
254 HostPlatform::Other => env_paths
255 .home
256 .clone()
257 .map(|path| path.join(".taskers"))
258 .unwrap_or_else(|| temp_root().join("state")),
259 }
260}
261
262fn platform_cache_dir(platform: HostPlatform, env_paths: &EnvPaths) -> PathBuf {
263 match platform {
264 HostPlatform::Macos => home_library_cache_dir(env_paths),
265 HostPlatform::Linux => env_paths
266 .xdg_cache_home
267 .clone()
268 .map(|path| path.join("taskers"))
269 .or_else(|| {
270 env_paths
271 .home
272 .clone()
273 .map(|path| path.join(".cache").join("taskers"))
274 })
275 .unwrap_or_else(|| temp_root().join("cache")),
276 HostPlatform::Other => env_paths
277 .home
278 .clone()
279 .map(|path| path.join(".taskers").join("cache"))
280 .unwrap_or_else(|| temp_root().join("cache")),
281 }
282}
283
284fn platform_data_dir(platform: HostPlatform, env_paths: &EnvPaths) -> PathBuf {
285 match platform {
286 HostPlatform::Macos => home_library_dir(env_paths, "Application Support"),
287 HostPlatform::Linux => env_paths
288 .xdg_data_home
289 .clone()
290 .map(|path| path.join("taskers"))
291 .or_else(|| {
292 env_paths
293 .home
294 .clone()
295 .map(|path| path.join(".local").join("share").join("taskers"))
296 })
297 .unwrap_or_else(|| temp_root().join("data")),
298 HostPlatform::Other => env_paths
299 .home
300 .clone()
301 .map(|path| path.join(".taskers").join("data"))
302 .unwrap_or_else(|| temp_root().join("data")),
303 }
304}
305
306fn shell_runtime_dir(platform: HostPlatform, env_paths: &EnvPaths, cache_dir: &PathBuf) -> PathBuf {
307 if let Some(path) = env_paths.taskers_runtime_dir.clone() {
308 return path.join("shell");
309 }
310
311 match platform {
312 HostPlatform::Linux => env_paths
313 .xdg_runtime_dir
314 .clone()
315 .map(|path| path.join("taskers").join("shell"))
316 .unwrap_or_else(|| env::temp_dir().join("taskers-runtime").join("shell")),
317 HostPlatform::Macos => cache_dir.join("runtime").join("shell"),
318 HostPlatform::Other => env::temp_dir().join("taskers-runtime").join("shell"),
319 }
320}
321
322fn socket_path(platform: HostPlatform, cache_dir: &PathBuf) -> PathBuf {
323 match platform {
324 HostPlatform::Macos => cache_dir.join("control.sock"),
325 HostPlatform::Linux | HostPlatform::Other => PathBuf::from("/tmp/taskers.sock"),
326 }
327}
328
329fn home_library_dir(env_paths: &EnvPaths, leaf: &str) -> PathBuf {
330 env_paths
331 .home
332 .clone()
333 .map(|path| path.join("Library").join(leaf).join(APP_ID))
334 .unwrap_or_else(|| temp_root().join(leaf.replace(' ', "-").to_ascii_lowercase()))
335}
336
337fn home_library_cache_dir(env_paths: &EnvPaths) -> PathBuf {
338 env_paths
339 .home
340 .clone()
341 .map(|path| path.join("Library").join("Caches").join(APP_ID))
342 .unwrap_or_else(|| temp_root().join("cache"))
343}
344
345fn temp_root() -> PathBuf {
346 env::temp_dir().join("taskers")
347}
348
349#[cfg(test)]
350mod tests {
351 use super::{APP_ID, EnvPaths, HostPlatform, TaskersPaths};
352 use std::path::PathBuf;
353
354 #[test]
355 fn macos_paths_use_library_directories() {
356 let env = EnvPaths {
357 home: Some(PathBuf::from("/Users/notes")),
358 ..EnvPaths::default()
359 };
360 let paths = TaskersPaths::from_env(HostPlatform::Macos, &env);
361
362 assert_eq!(
363 paths.config_path(),
364 &PathBuf::from(format!(
365 "/Users/notes/Library/Application Support/{APP_ID}/config.json"
366 ))
367 );
368 assert_eq!(
369 paths.session_path(),
370 &PathBuf::from(format!(
371 "/Users/notes/Library/Application Support/{APP_ID}/session.json"
372 ))
373 );
374 assert_eq!(
375 paths.socket_path(),
376 &PathBuf::from(format!("/Users/notes/Library/Caches/{APP_ID}/control.sock"))
377 );
378 assert_eq!(
379 paths.shell_runtime_dir(),
380 &PathBuf::from(format!(
381 "/Users/notes/Library/Caches/{APP_ID}/runtime/shell"
382 ))
383 );
384 }
385
386 #[test]
387 fn linux_paths_preserve_xdg_defaults() {
388 let env = EnvPaths {
389 home: Some(PathBuf::from("/home/notes")),
390 xdg_config_home: Some(PathBuf::from("/tmp/config")),
391 xdg_state_home: Some(PathBuf::from("/tmp/state")),
392 xdg_cache_home: Some(PathBuf::from("/tmp/cache")),
393 xdg_data_home: Some(PathBuf::from("/tmp/data")),
394 xdg_runtime_dir: Some(PathBuf::from("/tmp/runtime")),
395 ..EnvPaths::default()
396 };
397 let paths = TaskersPaths::from_env(HostPlatform::Linux, &env);
398
399 assert_eq!(
400 paths.config_path(),
401 &PathBuf::from("/tmp/config/taskers/config.json")
402 );
403 assert_eq!(
404 paths.session_path(),
405 &PathBuf::from("/tmp/state/taskers/session.json")
406 );
407 assert_eq!(
408 paths.ghostty_runtime_dir(),
409 &PathBuf::from("/tmp/data/taskers/ghostty")
410 );
411 assert_eq!(
412 paths.shell_runtime_dir(),
413 &PathBuf::from("/tmp/runtime/taskers/shell")
414 );
415 assert_eq!(paths.socket_path(), &PathBuf::from("/tmp/taskers.sock"));
416 }
417
418 #[test]
419 fn explicit_overrides_win() {
420 let env = EnvPaths {
421 taskers_config_path: Some(PathBuf::from("/work/config.json")),
422 taskers_session_path: Some(PathBuf::from("/work/session.json")),
423 taskers_socket_path: Some(PathBuf::from("/work/control.sock")),
424 taskers_runtime_dir: Some(PathBuf::from("/work/runtime")),
425 taskers_ghostty_runtime_dir: Some(PathBuf::from("/work/ghostty")),
426 ..EnvPaths::default()
427 };
428 let paths = TaskersPaths::from_env(HostPlatform::Macos, &env);
429
430 assert_eq!(paths.config_path(), &PathBuf::from("/work/config.json"));
431 assert_eq!(paths.session_path(), &PathBuf::from("/work/session.json"));
432 assert_eq!(paths.socket_path(), &PathBuf::from("/work/control.sock"));
433 assert_eq!(
434 paths.shell_runtime_dir(),
435 &PathBuf::from("/work/runtime/shell")
436 );
437 assert_eq!(paths.ghostty_runtime_dir(), &PathBuf::from("/work/ghostty"));
438 }
439
440 #[test]
441 fn release_install_roots_follow_platform_defaults() {
442 let mac = EnvPaths {
443 home: Some(PathBuf::from("/Users/notes")),
444 ..EnvPaths::default()
445 };
446 let linux = EnvPaths {
447 home: Some(PathBuf::from("/home/notes")),
448 xdg_data_home: Some(PathBuf::from("/tmp/data")),
449 ..EnvPaths::default()
450 };
451
452 assert_eq!(
453 TaskersPaths::from_env(HostPlatform::Macos, &mac)
454 .data_dir()
455 .join("releases"),
456 PathBuf::from(format!(
457 "/Users/notes/Library/Application Support/{APP_ID}/releases"
458 ))
459 );
460 assert_eq!(
461 TaskersPaths::from_env(HostPlatform::Linux, &linux)
462 .data_dir()
463 .join("releases"),
464 PathBuf::from("/tmp/data/taskers/releases")
465 );
466 }
467}