Skip to main content

linkmarks_core/
paths.rs

1//! XDG-aware filesystem paths for LinkMarks.
2//!
3//! All defaults follow the XDG Base Directory specification:
4//! - Data: `${XDG_DATA_HOME:-~/.local/share}/linkmarks/`
5//! - Config: `${XDG_CONFIG_HOME:-~/.config}/linkmarks/`
6//!
7//! On macOS / Windows the `dirs` crate falls back to `~/Library/Application Support/linkmarks`
8//! and `%APPDATA%\linkmarks` respectively. The CLI exposes `--data-dir`,
9//! `--store`, and `--config` flags to override these defaults at runtime.
10
11use std::path::{Path, PathBuf};
12
13/// Application name used as the XDG leaf directory.
14pub const APP_DIR: &str = "linkmarks";
15
16/// Store filename inside the data directory.
17pub const STORE_FILENAME: &str = "store.db";
18
19/// Config filename inside the config directory.
20pub const CONFIG_FILENAME: &str = "config.toml";
21
22/// Returns the data directory (`${XDG_DATA_HOME:-~/.local/share}/linkmarks`).
23///
24/// The directory is **not** created — callers should use
25/// [`ensure_data_dir`] when they need write access.
26#[must_use]
27pub fn linkmarks_data_dir() -> PathBuf {
28    if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") {
29        let trimmed = xdg.to_string_lossy().trim().to_string();
30        if !trimmed.is_empty() {
31            return PathBuf::from(trimmed).join(APP_DIR);
32        }
33    }
34    let base = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".local/share"));
35    base.join(APP_DIR)
36}
37
38/// Returns the config directory (`${XDG_CONFIG_HOME:-~/.config}/linkmarks`).
39///
40/// The directory is **not** created — callers should use
41/// [`ensure_config_dir`] when they need write access.
42#[must_use]
43pub fn linkmarks_config_dir() -> PathBuf {
44    if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
45        let trimmed = xdg.to_string_lossy().trim().to_string();
46        if !trimmed.is_empty() {
47            return PathBuf::from(trimmed).join(APP_DIR);
48        }
49    }
50    let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from(".config"));
51    base.join(APP_DIR)
52}
53
54/// Returns the default SQLite store path (`<data_dir>/store.db`).
55#[must_use]
56pub fn linkmarks_store_path() -> PathBuf {
57    linkmarks_data_dir().join(STORE_FILENAME)
58}
59
60/// Returns the default config file path (`<config_dir>/config.toml`).
61#[must_use]
62pub fn linkmarks_config_path() -> PathBuf {
63    linkmarks_config_dir().join(CONFIG_FILENAME)
64}
65
66/// Create the data directory (and parents) if it does not exist.
67pub fn ensure_data_dir() -> std::io::Result<PathBuf> {
68    let dir = linkmarks_data_dir();
69    std::fs::create_dir_all(&dir)?;
70    Ok(dir)
71}
72
73/// Create the config directory (and parents) if it does not exist.
74pub fn ensure_config_dir() -> std::io::Result<PathBuf> {
75    let dir = linkmarks_config_dir();
76    std::fs::create_dir_all(&dir)?;
77    Ok(dir)
78}
79
80/// Convenience helper: returns the default config directory as `&str` for
81/// error messages; returns the literal string when the path cannot be
82/// represented.
83#[must_use]
84pub fn default_data_dir_display() -> String {
85    linkmarks_data_dir().to_string_lossy().into_owned()
86}
87
88/// Convenience helper: returns the default config directory as `String` for
89/// error messages; returns the literal string when the path cannot be
90/// represented.
91#[must_use]
92pub fn default_config_dir_display() -> String {
93    linkmarks_config_dir().to_string_lossy().into_owned()
94}
95
96/// Returns whether the path lives under the configured data directory.
97#[must_use]
98pub fn is_inside_data_dir(path: &Path) -> bool {
99    path.starts_with(linkmarks_data_dir())
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn store_path_lives_under_data_dir() {
108        let store = linkmarks_store_path();
109        assert!(store.ends_with(STORE_FILENAME));
110        assert!(is_inside_data_dir(&store));
111    }
112
113    #[test]
114    fn config_path_lives_under_config_dir() {
115        let cfg = linkmarks_config_path();
116        assert!(cfg.ends_with(CONFIG_FILENAME));
117        assert!(cfg.starts_with(linkmarks_config_dir()));
118    }
119
120    #[test]
121    fn data_and_config_dirs_are_distinct() {
122        assert_ne!(linkmarks_data_dir(), linkmarks_config_dir());
123    }
124
125    #[test]
126    fn ensure_data_dir_is_idempotent() {
127        let dir = ensure_data_dir().expect("create data dir");
128        assert!(dir.is_dir());
129        // Second call must be a no-op and still succeed.
130        let again = ensure_data_dir().expect("idempotent create");
131        assert_eq!(dir, again);
132    }
133
134    #[test]
135    fn ensure_config_dir_is_idempotent() {
136        let dir = ensure_config_dir().expect("create config dir");
137        assert!(dir.is_dir());
138        let again = ensure_config_dir().expect("idempotent create");
139        assert_eq!(dir, again);
140    }
141}