Skip to main content

kanade_shared/
default_paths.rs

1//! Spec §2.11 install layout — OS-aware default paths for config /
2//! mutable data / logs, plus the [`find_config`] fallback chain that
3//! every binary uses to locate its config file.
4//!
5//! Layout
6//!
7//! ```text
8//! Windows                                    Linux
9//! C:\Program Files\Kanade\                   /usr/local/bin/
10//!   ↑ binaries                                 ↑ binaries
11//!
12//! C:\ProgramData\Kanade\config\              /etc/kanade/
13//!   ├─ agent.toml                              ├─ agent.toml
14//!   └─ backend.toml                            └─ backend.toml
15//!
16//! C:\ProgramData\Kanade\data\                /var/lib/kanade/
17//!   ├─ state.db        (agent)                 ├─ state.db
18//!   ├─ outbox/         (agent)                 ├─ outbox/
19//!   ├─ staging/        (agent self-update)     ├─ staging/
20//!   ├─ backend.db      (backend)               ├─ backend.db
21//!   ├─ certs/                                  ├─ certs/
22//!   └─ nats/           (JetStream data)        └─ nats/
23//!
24//! C:\ProgramData\Kanade\logs\                /var/log/kanade/
25//!   ├─ agent.log                               ├─ agent.log
26//!   ├─ backend.log                             ├─ backend.log
27//!   └─ nats-server.log                         └─ nats-server.log
28//! ```
29
30use std::path::{Path, PathBuf};
31
32/// `%ProgramData%\Kanade\config\` on Windows, `/etc/kanade/` on Linux.
33pub fn config_dir() -> PathBuf {
34    #[cfg(target_os = "windows")]
35    {
36        program_data().join("Kanade").join("config")
37    }
38    #[cfg(not(target_os = "windows"))]
39    {
40        PathBuf::from("/etc/kanade")
41    }
42}
43
44/// Where the agent stores its outbox, state.db, local_completions.json,
45/// staging area, etc. Defaults to:
46///   * Windows: `%ProgramData%\Kanade\data\`
47///   * Linux:   `/var/lib/kanade/`
48///
49/// Honors `KANADE_AGENT_DATA_DIR` (non-empty value wins) so a single
50/// host can run multiple isolated agents — the dev-fleet target uses
51/// this to fan out agents into `target/dev-data/agents/{pc_id}/`
52/// without two agents stomping on each other's outbox files.
53pub fn data_dir() -> PathBuf {
54    if let Some(os_path) = std::env::var_os("KANADE_AGENT_DATA_DIR").filter(|s| !s.is_empty()) {
55        // Promote relative env paths to absolute against the current
56        // cwd so the agent's outbox / state.db stay put even if a
57        // later component changes its working directory (the
58        // self_update flow notably does this on Windows). Honours the
59        // `dirs_are_os_appropriate` invariant that the rest of the
60        // codebase assumes about data_dir's return value.
61        let path = PathBuf::from(&os_path);
62        if path.is_absolute() {
63            return path;
64        }
65        return std::env::current_dir()
66            .map(|cwd| cwd.join(&path))
67            .unwrap_or_else(|_| PathBuf::from(os_path));
68    }
69    #[cfg(target_os = "windows")]
70    {
71        program_data().join("Kanade").join("data")
72    }
73    #[cfg(not(target_os = "windows"))]
74    {
75        PathBuf::from("/var/lib/kanade")
76    }
77}
78
79/// `%ProgramData%\Kanade\logs\` on Windows, `/var/log/kanade/` on Linux.
80pub fn log_dir() -> PathBuf {
81    #[cfg(target_os = "windows")]
82    {
83        program_data().join("Kanade").join("logs")
84    }
85    #[cfg(not(target_os = "windows"))]
86    {
87        PathBuf::from("/var/log/kanade")
88    }
89}
90
91#[cfg(target_os = "windows")]
92fn program_data() -> PathBuf {
93    std::env::var_os("ProgramData")
94        .map(PathBuf::from)
95        .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
96}
97
98/// Resolve the config file path through the fallback chain:
99///
100/// 1. `flag` (e.g. `--config <path>`) — honored verbatim, even when the
101///    file does not exist (caller's choice).
102/// 2. `env_var` value (e.g. `KANADE_AGENT_CONFIG`) — honored verbatim
103///    when set to a non-empty string.
104/// 3. OS-standard location `<config_dir>/<basename>` — only used when
105///    the file is actually present.
106///
107/// Returns an error when none of the above produced a usable path; the
108/// message lists every option an operator can take to fix it.
109pub fn find_config(flag: Option<&Path>, env_var: &str, basename: &str) -> anyhow::Result<PathBuf> {
110    if let Some(p) = flag {
111        return Ok(p.to_path_buf());
112    }
113    if let Ok(raw) = std::env::var(env_var)
114        && !raw.is_empty()
115    {
116        return Ok(PathBuf::from(raw));
117    }
118    let std_path = config_dir().join(basename);
119    if std_path.exists() {
120        return Ok(std_path);
121    }
122    Err(anyhow::anyhow!(
123        "config not found — pass `--config <path>`, set `{env_var}`, or place `{basename}` at `{}`",
124        std_path.display(),
125    ))
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::path::PathBuf;
132
133    /// Unique env var per test so the global-environment writes don't race.
134    /// (Cargo runs tests within a binary on a single thread by default for
135    /// unit tests, but be explicit to avoid surprises.)
136    fn unique_env(test_name: &str) -> String {
137        format!("KANADE_TEST_CFG_{test_name}_{}", std::process::id())
138    }
139
140    /// Tests that touch the *real* `KANADE_AGENT_DATA_DIR` env var
141    /// (or call `data_dir()` whose return value depends on it) must
142    /// take this lock so they serialize against each other. Cargo
143    /// test parallelises across tests within a binary by default, and
144    /// macOS CI in particular cranks that parallelism high enough to
145    /// reliably catch this kind of cross-test pollution.
146    static DATA_DIR_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
147
148    fn lock_data_dir_env() -> std::sync::MutexGuard<'static, ()> {
149        DATA_DIR_ENV_LOCK
150            .lock()
151            .unwrap_or_else(|poisoned| poisoned.into_inner())
152    }
153
154    #[test]
155    fn find_config_prefers_flag_over_env_and_default() {
156        let env = unique_env("flag_wins");
157        // SAFETY: single-threaded test, env var owned by us.
158        unsafe {
159            std::env::set_var(&env, "env-path.toml");
160        }
161        let flag = PathBuf::from("flag-path.toml");
162        let got = find_config(Some(&flag), &env, "agent.toml").expect("ok");
163        assert_eq!(got, flag);
164        unsafe { std::env::remove_var(&env) };
165    }
166
167    #[test]
168    fn find_config_uses_env_when_flag_missing() {
169        let env = unique_env("env_wins");
170        unsafe {
171            std::env::set_var(&env, "env-path.toml");
172        }
173        let got = find_config(None, &env, "agent.toml").expect("ok");
174        assert_eq!(got, PathBuf::from("env-path.toml"));
175        unsafe { std::env::remove_var(&env) };
176    }
177
178    #[test]
179    fn find_config_skips_empty_env() {
180        let env = unique_env("empty_env");
181        unsafe {
182            std::env::set_var(&env, "");
183        }
184        // No flag, env is empty, and the OS-standard path almost certainly
185        // doesn't exist on the test machine — should return an error.
186        let r = find_config(None, &env, "non-existent-kanade-test.toml");
187        assert!(r.is_err(), "expected error, got {:?}", r);
188        unsafe { std::env::remove_var(&env) };
189    }
190
191    #[test]
192    fn find_config_errors_with_helpful_message() {
193        let env = unique_env("missing");
194        let r = find_config(None, &env, "definitely-not-here-kanade-test.toml");
195        let err = r.expect_err("should error");
196        let msg = format!("{err}");
197        assert!(msg.contains("--config"), "msg: {msg}");
198        assert!(msg.contains(&env), "msg: {msg}");
199        assert!(
200            msg.contains("definitely-not-here-kanade-test.toml"),
201            "msg: {msg}"
202        );
203    }
204
205    /// Gemini #54 review: when the env override is set to a relative
206    /// path (e.g. the dev-fleet tasks use
207    /// `target/dev-data/agents/dev-pc-1`), data_dir() must still
208    /// return an absolute path — anchored against the cwd at call
209    /// time. Otherwise a later component that chdir's (the
210    /// self_update flow does, notably) would point the outbox at a
211    /// surprise location.
212    #[test]
213    fn data_dir_promotes_relative_env_to_absolute() {
214        let _g = lock_data_dir_env();
215        unsafe {
216            std::env::set_var("KANADE_AGENT_DATA_DIR", "target/dev-data/agents/dev-pc-x");
217        }
218        let p = data_dir();
219        assert!(p.is_absolute(), "expected absolute path, got {p:?}");
220        assert!(
221            p.ends_with("target/dev-data/agents/dev-pc-x")
222                || p.ends_with(r"target\dev-data\agents\dev-pc-x"),
223            "trailing components should match the env value, got {p:?}",
224        );
225        unsafe {
226            std::env::remove_var("KANADE_AGENT_DATA_DIR");
227        }
228    }
229
230    #[test]
231    fn dirs_are_os_appropriate() {
232        let _g = lock_data_dir_env();
233        let cfg = config_dir();
234        let data = data_dir();
235        let logs = log_dir();
236        // Sanity: all three are absolute paths.
237        assert!(cfg.is_absolute(), "config_dir = {cfg:?}");
238        assert!(data.is_absolute(), "data_dir = {data:?}");
239        assert!(logs.is_absolute(), "log_dir = {logs:?}");
240        // And distinct from each other.
241        assert_ne!(cfg, data);
242        assert_ne!(data, logs);
243        assert_ne!(cfg, logs);
244    }
245
246    #[cfg(target_os = "windows")]
247    #[test]
248    fn windows_dirs_root_at_program_data_kanade() {
249        let _g = lock_data_dir_env();
250        let cfg = config_dir();
251        let data = data_dir();
252        let logs = log_dir();
253        assert!(cfg.ends_with("Kanade\\config"), "{cfg:?}");
254        assert!(data.ends_with("Kanade\\data"), "{data:?}");
255        assert!(logs.ends_with("Kanade\\logs"), "{logs:?}");
256    }
257
258    #[cfg(not(target_os = "windows"))]
259    #[test]
260    fn unix_dirs_match_fhs_conventions() {
261        let _g = lock_data_dir_env();
262        assert_eq!(config_dir(), PathBuf::from("/etc/kanade"));
263        assert_eq!(data_dir(), PathBuf::from("/var/lib/kanade"));
264        assert_eq!(log_dir(), PathBuf::from("/var/log/kanade"));
265    }
266}