Skip to main content

mnm_core/
paths.rs

1//! Shared XDG path helpers (D18).
2//!
3//! Every binary in the workspace (CLI, MCP, server) walks the same path
4//! precedence to find on-disk state: explicit env override first, then
5//! `$XDG_CONFIG_HOME/midnight-manual/`, then `$HOME/.config/midnight-manual/`.
6//! These helpers consolidate that walk so the CLI commands, the MCP server,
7//! and tests all agree on where files live.
8//!
9//! Each helper takes a [`crate::config::ConfigEnv`] so tests can drive the
10//! resolver with a fake environment.
11//!
12//! Files this module resolves:
13//!
14//! - `auth.toml` — bearer / JWT store (see [`crate::auth_file`]).
15//! - `keys/<user_id>.private` — Ed25519 signing-key seed for `mnm login`.
16//! - `users.toml` — local user-store (CLI mutates; server reads as TOML
17//!   *body* via env per FR-057, not as a path).
18
19use std::path::PathBuf;
20
21use crate::config::ConfigEnv;
22
23/// Resolve the on-disk directory that holds CLI / server state.
24///
25/// Precedence:
26///
27/// 1. `$XDG_CONFIG_HOME/midnight-manual/`
28///
29/// 2. `$HOME/.config/midnight-manual/`
30///
31/// 3. `None` when neither env var is set.
32#[must_use]
33pub fn config_home(env: &impl ConfigEnv) -> Option<PathBuf> {
34    if let Some(xdg) = env.var("XDG_CONFIG_HOME") {
35        return Some(PathBuf::from(xdg).join("midnight-manual"));
36    }
37    if let Some(home) = env.var("HOME") {
38        return Some(PathBuf::from(home).join(".config").join("midnight-manual"));
39    }
40    None
41}
42
43/// Resolve the auth-file path (`<config_home>/auth.toml`).
44#[must_use]
45pub fn auth_file_path(env: &impl ConfigEnv) -> Option<PathBuf> {
46    config_home(env).map(|p| p.join("auth.toml"))
47}
48
49/// Resolve the keypair-storage directory (`<config_home>/keys/`).
50///
51/// Callers should `create_dir_all(...)` before writing into it.
52#[must_use]
53pub fn keys_dir(env: &impl ConfigEnv) -> Option<PathBuf> {
54    config_home(env).map(|p| p.join("keys"))
55}
56
57/// Resolve the on-disk path to a user's signing-key seed
58/// (`<config_home>/keys/<user_id>.private`).
59#[must_use]
60pub fn private_key_path(env: &impl ConfigEnv, user_id: &str) -> Option<PathBuf> {
61    keys_dir(env).map(|p| p.join(format!("{user_id}.private")))
62}
63
64/// Resolve the persistent telemetry-opt-out marker path.
65///
66/// Returns `<config_home>/telemetry-disabled`. The presence of this file is
67/// the third opt-out mechanism (FR-107 mechanism #3); the CLI / MCP / server
68/// consult it at startup. The file contents are irrelevant — only existence
69/// matters.
70#[must_use]
71pub fn telemetry_marker_path(env: &impl ConfigEnv) -> Option<PathBuf> {
72    config_home(env).map(|p| p.join("telemetry-disabled"))
73}
74
75/// Resolve the user-store path on the CLI side.
76///
77/// On the CLI side, `MIDNIGHT_MANUAL_USER_STORE` is read as a **file path**;
78/// the server boot path treats the same env var as the in-memory TOML body
79/// (see `crates/midnight-manual-server/src/config.rs`). This asymmetry is deliberate per
80/// D18 so deployed servers can boot from a single secret-mounted env var
81/// while admin operators edit a real file locally.
82#[must_use]
83pub fn user_store_path(env: &impl ConfigEnv) -> Option<PathBuf> {
84    if let Some(p) = env.var("MIDNIGHT_MANUAL_USER_STORE") {
85        return Some(PathBuf::from(p));
86    }
87    config_home(env).map(|p| p.join("users.toml"))
88}
89
90#[cfg(test)]
91mod tests {
92    use std::collections::HashMap;
93
94    use super::*;
95
96    #[derive(Default)]
97    struct FakeEnv(HashMap<String, String>);
98
99    impl FakeEnv {
100        fn set(mut self, k: &str, v: &str) -> Self {
101            self.0.insert(k.into(), v.into());
102            self
103        }
104    }
105
106    impl ConfigEnv for FakeEnv {
107        fn var(&self, name: &str) -> Option<String> {
108            self.0.get(name).cloned()
109        }
110    }
111
112    #[test]
113    fn xdg_beats_home_for_config_dir() {
114        let env = FakeEnv::default()
115            .set("XDG_CONFIG_HOME", "/x")
116            .set("HOME", "/h");
117        assert_eq!(config_home(&env), Some(PathBuf::from("/x/midnight-manual")));
118    }
119
120    #[test]
121    fn home_fallback_for_config_dir() {
122        let env = FakeEnv::default().set("HOME", "/h");
123        assert_eq!(config_home(&env), Some(PathBuf::from("/h/.config/midnight-manual")),);
124    }
125
126    #[test]
127    fn none_when_no_env_at_all() {
128        let env = FakeEnv::default();
129        assert!(config_home(&env).is_none());
130        assert!(auth_file_path(&env).is_none());
131        assert!(keys_dir(&env).is_none());
132        assert!(private_key_path(&env, "aaron").is_none());
133    }
134
135    #[test]
136    fn auth_file_under_config_home() {
137        let env = FakeEnv::default().set("HOME", "/h");
138        assert_eq!(
139            auth_file_path(&env),
140            Some(PathBuf::from("/h/.config/midnight-manual/auth.toml")),
141        );
142    }
143
144    #[test]
145    fn private_key_path_uses_user_id() {
146        let env = FakeEnv::default().set("XDG_CONFIG_HOME", "/x");
147        assert_eq!(
148            private_key_path(&env, "aaron"),
149            Some(PathBuf::from("/x/midnight-manual/keys/aaron.private")),
150        );
151    }
152
153    #[test]
154    fn user_store_env_var_wins() {
155        let env = FakeEnv::default()
156            .set("MIDNIGHT_MANUAL_USER_STORE", "/etc/users.toml")
157            .set("XDG_CONFIG_HOME", "/x");
158        assert_eq!(user_store_path(&env), Some(PathBuf::from("/etc/users.toml")));
159    }
160
161    #[test]
162    fn telemetry_marker_under_config_home() {
163        let env = FakeEnv::default().set("XDG_CONFIG_HOME", "/x");
164        assert_eq!(
165            telemetry_marker_path(&env),
166            Some(PathBuf::from("/x/midnight-manual/telemetry-disabled")),
167        );
168    }
169
170    #[test]
171    fn telemetry_marker_none_without_env() {
172        let env = FakeEnv::default();
173        assert!(telemetry_marker_path(&env).is_none());
174    }
175
176    #[test]
177    fn user_store_xdg_fallback() {
178        let env = FakeEnv::default().set("XDG_CONFIG_HOME", "/x");
179        assert_eq!(user_store_path(&env), Some(PathBuf::from("/x/midnight-manual/users.toml")),);
180    }
181}