Skip to main content

dig_logging/
dirs.rs

1//! Log-directory resolution (SPEC §3).
2//!
3//! One machine-wide log root, one subdirectory per service, SYSTEM/root-writable and
4//! **user-readable** — deliberately unlike the owner-only #501 state dirs, because logs are operator
5//! diagnostics (secrets are barred at source and redacted at bundle). Resolution precedence:
6//!
7//! 1. `DIG_LOG_DIR` — its value is the log ROOT; the service dir is `$DIG_LOG_DIR/<service>`.
8//! 2. the per-OS machine root, when that service dir can be created + written (a privileged run).
9//! 3. the per-user dev fallback, when the machine root is not creatable/writable (an unprivileged
10//!    `cargo run`), mirroring dig-node's #501 dev-fallback pattern.
11//!
12//! The CLI and the service resolve identically, so `<bin> logs path` names the directory the service
13//! writes to. Resolution is a PURE function of an injected env-getter + a "can this dir be created?"
14//! probe, so every branch is table-testable without touching the real filesystem or environment.
15
16use std::path::PathBuf;
17
18/// The env var that overrides the log ROOT outright (tests, custom deploys).
19pub const ENV_LOG_DIR: &str = "DIG_LOG_DIR";
20
21/// Resolve the log directory for `service` from an injected env-getter and a dir-creatable probe.
22///
23/// `get` reads an env var (`None`/blank = unset). `can_create` answers "can this exact directory be
24/// created and written?" — the caller wires it to the real filesystem in [`log_dir`], and tests
25/// inject a closure to exercise the machine-root vs dev-fallback branch deterministically.
26pub fn resolve_log_dir<G, C>(service: &str, get: G, can_create: C) -> PathBuf
27where
28    G: Fn(&str) -> Option<String>,
29    C: Fn(&std::path::Path) -> bool,
30{
31    let read = |key: &str| {
32        get(key)
33            .map(|value| value.trim().to_string())
34            .filter(|value| !value.is_empty())
35    };
36
37    // 1. Explicit override wins unconditionally.
38    if let Some(root) = read(ENV_LOG_DIR) {
39        return PathBuf::from(root).join(service);
40    }
41
42    // 2. Machine root when its service dir is creatable; 3. else the per-user dev fallback.
43    let machine = machine_root(&read).join(service);
44    if can_create(&machine) {
45        return machine;
46    }
47    dev_root(&read).join(service)
48}
49
50/// The resolved service log directory for this process, wiring the real environment + a filesystem
51/// creatable-probe into [`resolve_log_dir`].
52pub fn log_dir(service: &str) -> PathBuf {
53    resolve_log_dir(
54        service,
55        |key| std::env::var(key).ok(),
56        |path| std::fs::create_dir_all(path).is_ok(),
57    )
58}
59
60/// The machine-wide log root (SPEC §3): `%PROGRAMDATA%\DigNetwork\logs` on Windows,
61/// `/Library/Logs/DigNetwork` on macOS, `/var/log/dig` on Linux.
62#[cfg(windows)]
63fn machine_root<R: Fn(&str) -> Option<String>>(read: &R) -> PathBuf {
64    let base = read("ProgramData").unwrap_or_else(|| r"C:\ProgramData".to_string());
65    PathBuf::from(base).join("DigNetwork").join("logs")
66}
67
68#[cfg(target_os = "macos")]
69fn machine_root<R: Fn(&str) -> Option<String>>(_read: &R) -> PathBuf {
70    PathBuf::from("/Library/Logs/DigNetwork")
71}
72
73#[cfg(all(unix, not(target_os = "macos")))]
74fn machine_root<R: Fn(&str) -> Option<String>>(_read: &R) -> PathBuf {
75    PathBuf::from("/var/log/dig")
76}
77
78/// The per-user dev-fallback log root (SPEC §3): `%LOCALAPPDATA%\DigNetwork\logs` on Windows,
79/// `~/Library/Logs/DigNetwork` on macOS, `${XDG_STATE_HOME:-~/.local/state}/dig/logs` on Linux.
80#[cfg(windows)]
81fn dev_root<R: Fn(&str) -> Option<String>>(read: &R) -> PathBuf {
82    let base = read("LOCALAPPDATA")
83        .or_else(|| read("ProgramData"))
84        .unwrap_or_else(|| r"C:\ProgramData".to_string());
85    PathBuf::from(base).join("DigNetwork").join("logs")
86}
87
88#[cfg(target_os = "macos")]
89fn dev_root<R: Fn(&str) -> Option<String>>(read: &R) -> PathBuf {
90    let home = read("HOME").unwrap_or_else(|| "/tmp".to_string());
91    PathBuf::from(home)
92        .join("Library")
93        .join("Logs")
94        .join("DigNetwork")
95}
96
97#[cfg(all(unix, not(target_os = "macos")))]
98fn dev_root<R: Fn(&str) -> Option<String>>(read: &R) -> PathBuf {
99    if let Some(state) = read("XDG_STATE_HOME") {
100        return PathBuf::from(state).join("dig").join("logs");
101    }
102    let home = read("HOME").unwrap_or_else(|| "/tmp".to_string());
103    PathBuf::from(home)
104        .join(".local")
105        .join("state")
106        .join("dig")
107        .join("logs")
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use std::collections::HashMap;
114    use std::path::Path;
115
116    /// Build an env-getter over a fixed map, so a test names exactly the vars it sets.
117    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
118        let map: HashMap<String, String> = pairs
119            .iter()
120            .map(|(k, v)| (k.to_string(), v.to_string()))
121            .collect();
122        move |key| map.get(key).cloned()
123    }
124
125    #[test]
126    fn override_wins_and_joins_service() {
127        let dir = resolve_log_dir("dig-node", env(&[(ENV_LOG_DIR, "/custom/root")]), |_| true);
128        assert_eq!(dir, Path::new("/custom/root").join("dig-node"));
129    }
130
131    #[test]
132    fn blank_override_is_ignored() {
133        // A blank override must NOT shadow the machine root (a common empty-env-var footgun).
134        let dir = resolve_log_dir("dig-dns", env(&[(ENV_LOG_DIR, "   ")]), |_| true);
135        assert!(dir.ends_with(Path::new("dig-dns")));
136        assert!(!dir.starts_with("/custom"));
137    }
138
139    #[test]
140    fn machine_root_used_when_creatable() {
141        let dir = resolve_log_dir("dig-updater", env(&[]), |_| true);
142        assert!(dir.ends_with(Path::new("dig-updater")));
143        #[cfg(all(unix, not(target_os = "macos")))]
144        assert_eq!(dir, Path::new("/var/log/dig/dig-updater"));
145        #[cfg(target_os = "macos")]
146        assert_eq!(dir, Path::new("/Library/Logs/DigNetwork/dig-updater"));
147    }
148
149    #[test]
150    fn dev_fallback_when_machine_root_not_creatable() {
151        // Simulate an unprivileged run: the machine root cannot be created, so we fall back.
152        let dir = resolve_log_dir(
153            "dig-node",
154            env(&[
155                ("HOME", "/home/dev"),
156                ("XDG_STATE_HOME", "/home/dev/.state"),
157                ("LOCALAPPDATA", r"C:\Users\dev\AppData\Local"),
158            ]),
159            |path: &Path| path.to_string_lossy().contains("dev"),
160        );
161        assert!(dir.ends_with(Path::new("dig-node")));
162        #[cfg(all(unix, not(target_os = "macos")))]
163        assert_eq!(dir, Path::new("/home/dev/.state/dig/logs/dig-node"));
164    }
165
166    #[cfg(all(unix, not(target_os = "macos")))]
167    #[test]
168    fn linux_dev_fallback_without_xdg_uses_local_state() {
169        let dir = resolve_log_dir("dig-dns", env(&[("HOME", "/home/dev")]), |_| false);
170        assert_eq!(dir, Path::new("/home/dev/.local/state/dig/logs/dig-dns"));
171    }
172}