Skip to main content

tj_core/
paths.rs

1use anyhow::Context;
2use std::path::PathBuf;
3
4/// Base data directory for Task Journal on the current OS.
5///
6/// Resolution order (first wins):
7/// 1. `TASK_JOURNAL_DATA_DIR` env (explicit override; portable across all OS)
8/// 2. `XDG_DATA_HOME` env (Linux/WSL convention; respected on every OS for testability)
9/// 3. OS default via `directories` crate:
10///    - Linux/WSL: `~/.local/share/task-journal`
11///    - macOS: `~/Library/Application Support/task-journal`
12///    - Windows: `%LOCALAPPDATA%\task-journal`
13pub fn data_dir() -> anyhow::Result<PathBuf> {
14    if let Ok(custom) = std::env::var("TASK_JOURNAL_DATA_DIR") {
15        if !custom.is_empty() {
16            return Ok(PathBuf::from(custom));
17        }
18    }
19    if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
20        if !xdg.is_empty() {
21            return Ok(PathBuf::from(xdg).join("task-journal"));
22        }
23    }
24    let dirs = directories::ProjectDirs::from("", "", "task-journal")
25        .context("could not resolve OS data directories")?;
26    Ok(dirs.data_local_dir().to_path_buf())
27}
28
29pub fn events_dir() -> anyhow::Result<PathBuf> {
30    Ok(data_dir()?.join("events"))
31}
32
33pub fn state_dir() -> anyhow::Result<PathBuf> {
34    Ok(data_dir()?.join("state"))
35}
36
37pub fn metrics_dir() -> anyhow::Result<PathBuf> {
38    Ok(data_dir()?.join("metrics"))
39}
40
41pub fn project_storage_dir(project_hash: &str) -> anyhow::Result<PathBuf> {
42    Ok(data_dir()?.join(project_hash))
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48
49    #[test]
50    fn data_dir_returns_a_path_containing_task_journal() {
51        let p = data_dir().expect("data_dir");
52        let s = p.to_string_lossy();
53        assert!(s.contains("task-journal"), "got: {s}");
54    }
55
56    #[test]
57    fn project_dir_appends_subdir() {
58        let p = project_storage_dir("abc123").expect("project dir");
59        assert!(p.ends_with("abc123"), "got: {p:?}");
60    }
61
62    /// Regression: on macOS/Windows the `directories` crate ignores XDG_DATA_HOME, but our
63    /// tests (and power users) need a portable override. data_dir() must respect XDG_DATA_HOME
64    /// and TASK_JOURNAL_DATA_DIR on every OS. Using a thread-isolated env block since std env
65    /// is process-global; one test exercises both vars by serially restoring state.
66    #[test]
67    #[cfg_attr(
68        not(unix),
69        ignore = "env semantics differ on Windows test runners; covered by integration tests"
70    )]
71    fn env_overrides_take_precedence() {
72        // Snapshot existing values (best-effort cleanup).
73        let prev_tjdd = std::env::var("TASK_JOURNAL_DATA_DIR").ok();
74        let prev_xdg = std::env::var("XDG_DATA_HOME").ok();
75
76        // SAFETY: tests run in a separate process; setting env here is fine.
77        unsafe { std::env::remove_var("TASK_JOURNAL_DATA_DIR") };
78        unsafe { std::env::set_var("XDG_DATA_HOME", "/tmp/tj-paths-test-xdg") };
79        assert_eq!(
80            data_dir().unwrap(),
81            PathBuf::from("/tmp/tj-paths-test-xdg/task-journal")
82        );
83
84        unsafe { std::env::set_var("TASK_JOURNAL_DATA_DIR", "/tmp/tj-paths-test-explicit") };
85        assert_eq!(
86            data_dir().unwrap(),
87            PathBuf::from("/tmp/tj-paths-test-explicit")
88        );
89
90        // Restore.
91        unsafe { std::env::remove_var("TASK_JOURNAL_DATA_DIR") };
92        unsafe { std::env::remove_var("XDG_DATA_HOME") };
93        if let Some(v) = prev_tjdd {
94            unsafe { std::env::set_var("TASK_JOURNAL_DATA_DIR", v) };
95        }
96        if let Some(v) = prev_xdg {
97            unsafe { std::env::set_var("XDG_DATA_HOME", v) };
98        }
99    }
100}