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
41/// Global cross-project memory index (Pillar B). One SQLite file aggregating
42/// high-signal events + embeddings from every project.
43pub fn memory_db() -> anyhow::Result<PathBuf> {
44    Ok(data_dir()?.join("memory.sqlite"))
45}
46
47pub fn project_storage_dir(project_hash: &str) -> anyhow::Result<PathBuf> {
48    Ok(data_dir()?.join(project_hash))
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54
55    #[test]
56    fn data_dir_returns_a_path_containing_task_journal() {
57        let p = data_dir().expect("data_dir");
58        let s = p.to_string_lossy();
59        assert!(s.contains("task-journal"), "got: {s}");
60    }
61
62    #[test]
63    fn project_dir_appends_subdir() {
64        let p = project_storage_dir("abc123").expect("project dir");
65        assert!(p.ends_with("abc123"), "got: {p:?}");
66    }
67
68    /// Regression: on macOS/Windows the `directories` crate ignores XDG_DATA_HOME, but our
69    /// tests (and power users) need a portable override. data_dir() must respect XDG_DATA_HOME
70    /// and TASK_JOURNAL_DATA_DIR on every OS. Using a thread-isolated env block since std env
71    /// is process-global; one test exercises both vars by serially restoring state.
72    #[test]
73    #[cfg_attr(
74        not(unix),
75        ignore = "env semantics differ on Windows test runners; covered by integration tests"
76    )]
77    fn env_overrides_take_precedence() {
78        // Snapshot existing values (best-effort cleanup).
79        let prev_tjdd = std::env::var("TASK_JOURNAL_DATA_DIR").ok();
80        let prev_xdg = std::env::var("XDG_DATA_HOME").ok();
81
82        // SAFETY: tests run in a separate process; setting env here is fine.
83        unsafe { std::env::remove_var("TASK_JOURNAL_DATA_DIR") };
84        unsafe { std::env::set_var("XDG_DATA_HOME", "/tmp/tj-paths-test-xdg") };
85        assert_eq!(
86            data_dir().unwrap(),
87            PathBuf::from("/tmp/tj-paths-test-xdg/task-journal")
88        );
89
90        unsafe { std::env::set_var("TASK_JOURNAL_DATA_DIR", "/tmp/tj-paths-test-explicit") };
91        assert_eq!(
92            data_dir().unwrap(),
93            PathBuf::from("/tmp/tj-paths-test-explicit")
94        );
95
96        // Restore.
97        unsafe { std::env::remove_var("TASK_JOURNAL_DATA_DIR") };
98        unsafe { std::env::remove_var("XDG_DATA_HOME") };
99        if let Some(v) = prev_tjdd {
100            unsafe { std::env::set_var("TASK_JOURNAL_DATA_DIR", v) };
101        }
102        if let Some(v) = prev_xdg {
103            unsafe { std::env::set_var("XDG_DATA_HOME", v) };
104        }
105    }
106}