Skip to main content

perspt_core/
paths.rs

1//! Centralized platform-aware path helpers for Perspt.
2//!
3//! Three-tier model:
4//!   - **Config**: `dirs::config_dir()/perspt/` — config.toml, policy rules
5//!   - **Data**:   `dirs::data_local_dir()/perspt/` — perspt.db
6//!   - **Project**: `<working_dir>/.perspt/` — sandboxes, scratch state
7
8use std::path::PathBuf;
9
10/// Platform config directory: `~/.config/perspt/` (Linux) or `~/Library/Application Support/perspt/` (macOS).
11pub fn config_dir() -> Option<PathBuf> {
12    dirs::config_dir().map(|d| d.join("perspt"))
13}
14
15/// Path to the main configuration file: `<config_dir>/config.toml`.
16pub fn config_file() -> Option<PathBuf> {
17    config_dir().map(|d| d.join("config.toml"))
18}
19
20/// Path to the policy rules directory: `<config_dir>/rules/`.
21pub fn policy_dir() -> Option<PathBuf> {
22    config_dir().map(|d| d.join("rules"))
23}
24
25/// Platform data directory: `~/.local/share/perspt/` (Linux) or `~/Library/Application Support/perspt/` (macOS).
26pub fn data_dir() -> Option<PathBuf> {
27    dirs::data_local_dir().map(|d| d.join("perspt"))
28}
29
30/// Path to the DuckDB database file: `<data_dir>/perspt.db`.
31pub fn database_path() -> Option<PathBuf> {
32    data_dir().map(|d| d.join("perspt.db"))
33}
34
35/// Path to the shared history file: `<data_dir>/history.txt`.
36pub fn history_file() -> Option<PathBuf> {
37    data_dir().map(|d| d.join("history.txt"))
38}
39
40/// Returns the legacy `~/.perspt` directory if it exists on disk.
41pub fn legacy_dir() -> Option<PathBuf> {
42    dirs::home_dir()
43        .map(|h| h.join(".perspt"))
44        .filter(|p| p.is_dir())
45}
46
47/// Check for legacy paths and log migration warnings.
48///
49/// Call once at startup. If `~/.perspt/config.toml` or `~/.perspt/rules/` exist
50/// while the new platform directory is empty, warn the user.
51pub fn check_legacy_migration() {
52    let Some(legacy) = legacy_dir() else {
53        return;
54    };
55
56    let legacy_config = legacy.join("config.toml");
57    let legacy_rules = legacy.join("rules");
58
59    let new_config = config_file();
60    let new_rules = policy_dir();
61
62    if legacy_config.exists() {
63        let target = new_config
64            .as_deref()
65            .map(|p| p.display().to_string())
66            .unwrap_or_else(|| "<config_dir>/perspt/config.toml".into());
67        if new_config.as_ref().is_none_or(|p| !p.exists()) {
68            log::warn!(
69                "Legacy config found at {}. Consider moving it to {}",
70                legacy_config.display(),
71                target
72            );
73        }
74    }
75
76    if legacy_rules.is_dir() {
77        let target = new_rules
78            .as_deref()
79            .map(|p| p.display().to_string())
80            .unwrap_or_else(|| "<config_dir>/perspt/rules/".into());
81        if new_rules.as_ref().is_none_or(|p| !p.is_dir()) {
82            log::warn!(
83                "Legacy rules found at {}. Consider moving them to {}",
84                legacy_rules.display(),
85                target
86            );
87        }
88    }
89}
90
91/// Resolve the config file path, falling back to legacy `~/.perspt/config.toml` if
92/// the platform path doesn't exist yet.
93pub fn resolve_config_file() -> Option<PathBuf> {
94    // Prefer platform path if it exists
95    if let Some(path) = config_file() {
96        if path.exists() {
97            return Some(path);
98        }
99    }
100    // Fall back to legacy
101    legacy_dir()
102        .map(|d| d.join("config.toml"))
103        .filter(|p| p.exists())
104}
105
106/// Resolve the policy directory, falling back to legacy `~/.perspt/rules/` if
107/// the platform path doesn't exist yet.
108pub fn resolve_policy_dir() -> Option<PathBuf> {
109    // Prefer platform path if it exists
110    if let Some(path) = policy_dir() {
111        if path.is_dir() {
112            return Some(path);
113        }
114    }
115    // Fall back to legacy
116    legacy_dir().map(|d| d.join("rules")).filter(|p| p.is_dir())
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn config_dir_ends_with_perspt() {
125        if let Some(dir) = config_dir() {
126            assert!(dir.ends_with("perspt"));
127        }
128    }
129
130    #[test]
131    fn config_file_ends_with_config_toml() {
132        if let Some(path) = config_file() {
133            assert_eq!(path.file_name().unwrap(), "config.toml");
134        }
135    }
136
137    #[test]
138    fn policy_dir_ends_with_rules() {
139        if let Some(dir) = policy_dir() {
140            assert!(dir.ends_with("rules"));
141        }
142    }
143
144    #[test]
145    fn data_dir_ends_with_perspt() {
146        if let Some(dir) = data_dir() {
147            assert!(dir.ends_with("perspt"));
148        }
149    }
150
151    #[test]
152    fn database_path_ends_with_db() {
153        if let Some(path) = database_path() {
154            assert_eq!(path.file_name().unwrap(), "perspt.db");
155        }
156    }
157
158    #[test]
159    fn history_file_ends_with_history_txt() {
160        if let Some(path) = history_file() {
161            assert_eq!(path.file_name().unwrap(), "history.txt");
162        }
163    }
164}