Skip to main content

plan_issue/
state.rs

1//! Runtime state directory resolution for plan-issue.
2//!
3//! plan-issue writes its canonical artefacts under
4//! `<state-dir>/out/plan-issue-delivery/...`. The state directory is
5//! resolved (in order) from:
6//!
7//! 1. CLI override set via [`set_state_dir_override`] (driven by the
8//!    `--state-dir` global flag).
9//! 2. `PLAN_ISSUE_HOME` environment variable (fallback for adapters that
10//!    pin a workspace via env, such as the CLI's own integration tests).
11//! 3. `${XDG_STATE_HOME:-$HOME/.local/state}/plan-issue` default.
12//!
13//! Callers must read the resolved path through [`state_dir`]; the helper
14//! is the single entry point for runtime-layout math elsewhere in the
15//! crate (see `runtime_layout::runtime_root`, `task_spec`, `render`,
16//! `execute`).
17
18use std::path::PathBuf;
19use std::sync::RwLock;
20
21use nils_common::env as common_env;
22
23/// Environment variable consulted when no `--state-dir` flag is passed.
24pub const PLAN_ISSUE_STATE_HOME_ENV: &str = "PLAN_ISSUE_HOME";
25
26static CLI_OVERRIDE: RwLock<Option<PathBuf>> = RwLock::new(None);
27
28/// Set or clear the CLI-level `--state-dir` override.
29///
30/// Called once near the top of `run_with_args` after `Cli::try_parse_from`.
31/// Passing `None` resets the override (used by tests that need to fall
32/// back to the env-var path).
33pub fn set_state_dir_override(value: Option<PathBuf>) {
34    let mut guard = CLI_OVERRIDE
35        .write()
36        .expect("plan-issue state-dir override write lock");
37    *guard = value;
38}
39
40/// Resolve the active state directory using the documented chain.
41///
42/// The returned path is **not** guaranteed to exist; callers that emit
43/// artefacts into it should `mkdir -p` before writing.
44pub fn state_dir() -> PathBuf {
45    if let Some(path) = CLI_OVERRIDE
46        .read()
47        .expect("plan-issue state-dir override read lock")
48        .clone()
49    {
50        return path;
51    }
52
53    if let Some(value) = common_env::env_non_empty(PLAN_ISSUE_STATE_HOME_ENV) {
54        return PathBuf::from(value);
55    }
56
57    xdg_default()
58}
59
60fn xdg_default() -> PathBuf {
61    if let Some(value) = common_env::env_non_empty("XDG_STATE_HOME") {
62        return PathBuf::from(value).join("plan-issue");
63    }
64
65    let home = std::env::var_os("HOME")
66        .map(PathBuf::from)
67        .unwrap_or_else(|| PathBuf::from("/"));
68    home.join(".local").join("state").join("plan-issue")
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use nils_test_support::{EnvGuard, GlobalStateLock};
75
76    fn reset() {
77        set_state_dir_override(None);
78    }
79
80    #[test]
81    fn cli_override_wins_over_env_and_xdg() {
82        let lock = GlobalStateLock::new();
83        let _env = EnvGuard::set(&lock, "PLAN_ISSUE_HOME", "/tmp/plan-issue-env");
84        let _xdg = EnvGuard::set(&lock, "XDG_STATE_HOME", "/tmp/xdg");
85
86        set_state_dir_override(Some(PathBuf::from("/tmp/cli-override")));
87        let resolved = state_dir();
88        reset();
89
90        assert_eq!(resolved, PathBuf::from("/tmp/cli-override"));
91    }
92
93    #[test]
94    fn env_used_when_override_unset() {
95        let lock = GlobalStateLock::new();
96        reset();
97        let _env = EnvGuard::set(&lock, "PLAN_ISSUE_HOME", "/tmp/plan-issue-env");
98        let _xdg = EnvGuard::set(&lock, "XDG_STATE_HOME", "/tmp/xdg");
99
100        let resolved = state_dir();
101        assert_eq!(resolved, PathBuf::from("/tmp/plan-issue-env"));
102    }
103
104    #[test]
105    fn xdg_default_when_neither_override_nor_env_set() {
106        let lock = GlobalStateLock::new();
107        reset();
108        let _env = EnvGuard::remove(&lock, "PLAN_ISSUE_HOME");
109        let _xdg = EnvGuard::set(&lock, "XDG_STATE_HOME", "/tmp/xdg");
110
111        let resolved = state_dir();
112        assert_eq!(resolved, PathBuf::from("/tmp/xdg/plan-issue"));
113    }
114}