hyperdb_mcp/paths.rs
1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Cross-platform resolution for the persistent-database default path.
5//!
6//! The persistent database lives in the platform-standard data directory:
7//!
8//! - **macOS:** `~/Library/Application Support/hyperdb/workspace.hyper`
9//! - **Linux:** `$XDG_DATA_HOME/hyperdb/workspace.hyper`
10//! (defaults to `~/.local/share/hyperdb/workspace.hyper`)
11//! - **Windows:** `%APPDATA%\hyperdb\workspace.hyper`
12//!
13//! Note this is intentionally distinct from `~/.hyperdb/`, which is the
14//! daemon's state directory (`daemon.json`, `logs/`). Daemon coordination
15//! and user data have different lifecycles, so they live in different
16//! places.
17//!
18//! Resolution precedence:
19//! 1. Explicit CLI value (`--persistent-db <PATH>` or the deprecated
20//! `--workspace <PATH>`).
21//! 2. `HYPERDB_PERSISTENT_DB` environment variable.
22//! 3. Platform default via [`dirs::data_dir`].
23
24use std::path::PathBuf;
25
26/// Application directory name used inside the platform data dir.
27const APP_DIR_NAME: &str = "hyperdb";
28
29/// Filename of the persistent workspace inside the app dir.
30const PERSISTENT_DB_FILENAME: &str = "workspace.hyper";
31
32/// Environment variable that overrides the platform-default path.
33pub const ENV_PERSISTENT_DB: &str = "HYPERDB_PERSISTENT_DB";
34
35/// Returns the platform-default path for the persistent database. Returns
36/// `None` if the home / data directory cannot be determined (rare; usually
37/// indicates a misconfigured environment).
38#[must_use]
39pub fn default_persistent_db_path() -> Option<PathBuf> {
40 Some(
41 dirs::data_dir()?
42 .join(APP_DIR_NAME)
43 .join(PERSISTENT_DB_FILENAME),
44 )
45}
46
47/// Resolve where the persistent database should live, applying the
48/// CLI > env-var > platform-default precedence. Returns `None` only when
49/// no source supplied a path (the platform default failed *and* nothing
50/// was set explicitly), which the caller should treat as an error.
51///
52/// `cli_value` is the value of `--persistent-db` (or `--workspace` after
53/// deprecation translation). When `Some`, takes precedence over both
54/// the env var and the platform default.
55#[must_use]
56pub fn resolve_persistent_db_path(cli_value: Option<&str>) -> Option<PathBuf> {
57 if let Some(p) = cli_value {
58 return Some(PathBuf::from(p));
59 }
60 if let Some(p) = std::env::var_os(ENV_PERSISTENT_DB) {
61 return Some(PathBuf::from(p));
62 }
63 default_persistent_db_path()
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69
70 /// Process-wide lock for env-var tests. `std::env::set_var` is
71 /// `unsafe` in newer toolchains because it's not thread-safe; we
72 /// serialize all env-touching tests to keep them sound.
73 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
74
75 fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
76 let _guard = ENV_LOCK
77 .lock()
78 .unwrap_or_else(std::sync::PoisonError::into_inner);
79 f()
80 }
81
82 /// Sets an env var. Marked `unsafe` because [`std::env::set_var`] is
83 /// `unsafe` in newer toolchains; callers hold `ENV_LOCK`.
84 unsafe fn set_env(key: &str, value: &str) {
85 // SAFETY: serialized by ENV_LOCK; matches std::env contract.
86 unsafe { std::env::set_var(key, value) }
87 }
88
89 /// Removes an env var. Marked `unsafe` for the same reason as
90 /// [`set_env`]; callers hold `ENV_LOCK`.
91 unsafe fn remove_env(key: &str) {
92 // SAFETY: serialized by ENV_LOCK; matches std::env contract.
93 unsafe { std::env::remove_var(key) }
94 }
95
96 #[test]
97 fn default_persistent_db_path_returns_some_on_supported_platforms() {
98 // On macOS, Linux, and Windows the platform helpers always
99 // resolve to a usable path. CI runs on these three; if this
100 // fails on a new platform we want a loud signal.
101 let p = default_persistent_db_path().expect("platform data_dir resolves");
102 assert!(p.ends_with("hyperdb/workspace.hyper") || p.ends_with("hyperdb\\workspace.hyper"));
103 }
104
105 #[cfg(target_os = "macos")]
106 #[test]
107 fn default_persistent_db_path_uses_app_support_on_macos() {
108 let p = default_persistent_db_path().unwrap();
109 let s = p.to_string_lossy();
110 assert!(
111 s.contains("Library/Application Support/hyperdb/"),
112 "expected macOS Application Support path, got {s}"
113 );
114 }
115
116 #[cfg(target_os = "linux")]
117 #[test]
118 fn default_persistent_db_path_uses_xdg_share_on_linux() {
119 let p = default_persistent_db_path().unwrap();
120 let s = p.to_string_lossy();
121 assert!(
122 s.contains(".local/share/hyperdb/") || s.contains("share/hyperdb/"),
123 "expected XDG share path, got {s}"
124 );
125 }
126
127 #[cfg(windows)]
128 #[test]
129 fn default_persistent_db_path_uses_appdata_on_windows() {
130 let p = default_persistent_db_path().unwrap();
131 let s = p.to_string_lossy();
132 assert!(
133 s.contains("hyperdb"),
134 "expected APPDATA path containing hyperdb, got {s}"
135 );
136 }
137
138 #[test]
139 fn resolve_persistent_db_path_cli_takes_precedence() {
140 with_env_lock(|| {
141 // SAFETY: serialized by ENV_LOCK.
142 unsafe { set_env(ENV_PERSISTENT_DB, "/from/env.hyper") };
143 let p = resolve_persistent_db_path(Some("/from/cli.hyper"))
144 .expect("CLI path always resolves");
145 assert_eq!(p, PathBuf::from("/from/cli.hyper"));
146 // SAFETY: serialized by ENV_LOCK.
147 unsafe { remove_env(ENV_PERSISTENT_DB) };
148 });
149 }
150
151 #[test]
152 fn resolve_persistent_db_path_env_used_when_no_cli() {
153 with_env_lock(|| {
154 // SAFETY: serialized by ENV_LOCK.
155 unsafe { set_env(ENV_PERSISTENT_DB, "/from/env.hyper") };
156 let p = resolve_persistent_db_path(None).expect("env path resolves");
157 assert_eq!(p, PathBuf::from("/from/env.hyper"));
158 // SAFETY: serialized by ENV_LOCK.
159 unsafe { remove_env(ENV_PERSISTENT_DB) };
160 });
161 }
162
163 #[test]
164 fn resolve_persistent_db_path_falls_back_to_default() {
165 with_env_lock(|| {
166 // SAFETY: serialized by ENV_LOCK.
167 unsafe { remove_env(ENV_PERSISTENT_DB) };
168 let p = resolve_persistent_db_path(None).expect("default resolves");
169 // Just check it's under hyperdb/ — exact location varies by
170 // platform and is covered by the default-path tests above.
171 assert!(p.to_string_lossy().contains("hyperdb"));
172 });
173 }
174}