Skip to main content

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}