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/// `%ProgramData%\Kanade\data\` on Windows, `/var/lib/kanade/` on Linux.
45pub fn data_dir() -> PathBuf {
46    #[cfg(target_os = "windows")]
47    {
48        program_data().join("Kanade").join("data")
49    }
50    #[cfg(not(target_os = "windows"))]
51    {
52        PathBuf::from("/var/lib/kanade")
53    }
54}
55
56/// `%ProgramData%\Kanade\logs\` on Windows, `/var/log/kanade/` on Linux.
57pub fn log_dir() -> PathBuf {
58    #[cfg(target_os = "windows")]
59    {
60        program_data().join("Kanade").join("logs")
61    }
62    #[cfg(not(target_os = "windows"))]
63    {
64        PathBuf::from("/var/log/kanade")
65    }
66}
67
68#[cfg(target_os = "windows")]
69fn program_data() -> PathBuf {
70    std::env::var_os("ProgramData")
71        .map(PathBuf::from)
72        .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
73}
74
75/// Resolve the config file path through the fallback chain:
76///
77/// 1. `flag` (e.g. `--config <path>`) — honored verbatim, even when the
78///    file does not exist (caller's choice).
79/// 2. `env_var` value (e.g. `KANADE_AGENT_CONFIG`) — honored verbatim
80///    when set to a non-empty string.
81/// 3. OS-standard location `<config_dir>/<basename>` — only used when
82///    the file is actually present.
83///
84/// Returns an error when none of the above produced a usable path; the
85/// message lists every option an operator can take to fix it.
86pub fn find_config(flag: Option<&Path>, env_var: &str, basename: &str) -> anyhow::Result<PathBuf> {
87    if let Some(p) = flag {
88        return Ok(p.to_path_buf());
89    }
90    if let Ok(raw) = std::env::var(env_var)
91        && !raw.is_empty()
92    {
93        return Ok(PathBuf::from(raw));
94    }
95    let std_path = config_dir().join(basename);
96    if std_path.exists() {
97        return Ok(std_path);
98    }
99    Err(anyhow::anyhow!(
100        "config not found — pass `--config <path>`, set `{env_var}`, or place `{basename}` at `{}`",
101        std_path.display(),
102    ))
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use std::path::PathBuf;
109
110    /// Unique env var per test so the global-environment writes don't race.
111    /// (Cargo runs tests within a binary on a single thread by default for
112    /// unit tests, but be explicit to avoid surprises.)
113    fn unique_env(test_name: &str) -> String {
114        format!("KANADE_TEST_CFG_{test_name}_{}", std::process::id())
115    }
116
117    #[test]
118    fn find_config_prefers_flag_over_env_and_default() {
119        let env = unique_env("flag_wins");
120        // SAFETY: single-threaded test, env var owned by us.
121        unsafe {
122            std::env::set_var(&env, "env-path.toml");
123        }
124        let flag = PathBuf::from("flag-path.toml");
125        let got = find_config(Some(&flag), &env, "agent.toml").expect("ok");
126        assert_eq!(got, flag);
127        unsafe { std::env::remove_var(&env) };
128    }
129
130    #[test]
131    fn find_config_uses_env_when_flag_missing() {
132        let env = unique_env("env_wins");
133        unsafe {
134            std::env::set_var(&env, "env-path.toml");
135        }
136        let got = find_config(None, &env, "agent.toml").expect("ok");
137        assert_eq!(got, PathBuf::from("env-path.toml"));
138        unsafe { std::env::remove_var(&env) };
139    }
140
141    #[test]
142    fn find_config_skips_empty_env() {
143        let env = unique_env("empty_env");
144        unsafe {
145            std::env::set_var(&env, "");
146        }
147        // No flag, env is empty, and the OS-standard path almost certainly
148        // doesn't exist on the test machine — should return an error.
149        let r = find_config(None, &env, "non-existent-kanade-test.toml");
150        assert!(r.is_err(), "expected error, got {:?}", r);
151        unsafe { std::env::remove_var(&env) };
152    }
153
154    #[test]
155    fn find_config_errors_with_helpful_message() {
156        let env = unique_env("missing");
157        let r = find_config(None, &env, "definitely-not-here-kanade-test.toml");
158        let err = r.expect_err("should error");
159        let msg = format!("{err}");
160        assert!(msg.contains("--config"), "msg: {msg}");
161        assert!(msg.contains(&env), "msg: {msg}");
162        assert!(
163            msg.contains("definitely-not-here-kanade-test.toml"),
164            "msg: {msg}"
165        );
166    }
167
168    #[test]
169    fn dirs_are_os_appropriate() {
170        let cfg = config_dir();
171        let data = data_dir();
172        let logs = log_dir();
173        // Sanity: all three are absolute paths.
174        assert!(cfg.is_absolute(), "config_dir = {cfg:?}");
175        assert!(data.is_absolute(), "data_dir = {data:?}");
176        assert!(logs.is_absolute(), "log_dir = {logs:?}");
177        // And distinct from each other.
178        assert_ne!(cfg, data);
179        assert_ne!(data, logs);
180        assert_ne!(cfg, logs);
181    }
182
183    #[cfg(target_os = "windows")]
184    #[test]
185    fn windows_dirs_root_at_program_data_kanade() {
186        let cfg = config_dir();
187        let data = data_dir();
188        let logs = log_dir();
189        assert!(cfg.ends_with("Kanade\\config"), "{cfg:?}");
190        assert!(data.ends_with("Kanade\\data"), "{data:?}");
191        assert!(logs.ends_with("Kanade\\logs"), "{logs:?}");
192    }
193
194    #[cfg(not(target_os = "windows"))]
195    #[test]
196    fn unix_dirs_match_fhs_conventions() {
197        assert_eq!(config_dir(), PathBuf::from("/etc/kanade"));
198        assert_eq!(data_dir(), PathBuf::from("/var/lib/kanade"));
199        assert_eq!(log_dir(), PathBuf::from("/var/log/kanade"));
200    }
201}