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/// Returns the legacy `~/.perspt` directory if it exists on disk.
36pub fn legacy_dir() -> Option<PathBuf> {
37    dirs::home_dir()
38        .map(|h| h.join(".perspt"))
39        .filter(|p| p.is_dir())
40}
41
42/// Check for legacy paths and log migration warnings.
43///
44/// Call once at startup. If `~/.perspt/config.toml` or `~/.perspt/rules/` exist
45/// while the new platform directory is empty, warn the user.
46pub fn check_legacy_migration() {
47    let Some(legacy) = legacy_dir() else {
48        return;
49    };
50
51    let legacy_config = legacy.join("config.toml");
52    let legacy_rules = legacy.join("rules");
53
54    let new_config = config_file();
55    let new_rules = policy_dir();
56
57    if legacy_config.exists() {
58        let target = new_config
59            .as_deref()
60            .map(|p| p.display().to_string())
61            .unwrap_or_else(|| "<config_dir>/perspt/config.toml".into());
62        if new_config.as_ref().is_none_or(|p| !p.exists()) {
63            log::warn!(
64                "Legacy config found at {}. Consider moving it to {}",
65                legacy_config.display(),
66                target
67            );
68        }
69    }
70
71    if legacy_rules.is_dir() {
72        let target = new_rules
73            .as_deref()
74            .map(|p| p.display().to_string())
75            .unwrap_or_else(|| "<config_dir>/perspt/rules/".into());
76        if new_rules.as_ref().is_none_or(|p| !p.is_dir()) {
77            log::warn!(
78                "Legacy rules found at {}. Consider moving them to {}",
79                legacy_rules.display(),
80                target
81            );
82        }
83    }
84}
85
86/// Resolve the config file path, falling back to legacy `~/.perspt/config.toml` if
87/// the platform path doesn't exist yet.
88pub fn resolve_config_file() -> Option<PathBuf> {
89    // Prefer platform path if it exists
90    if let Some(path) = config_file() {
91        if path.exists() {
92            return Some(path);
93        }
94    }
95    // Fall back to legacy
96    legacy_dir()
97        .map(|d| d.join("config.toml"))
98        .filter(|p| p.exists())
99}
100
101/// Resolve the policy directory, falling back to legacy `~/.perspt/rules/` if
102/// the platform path doesn't exist yet.
103pub fn resolve_policy_dir() -> Option<PathBuf> {
104    // Prefer platform path if it exists
105    if let Some(path) = policy_dir() {
106        if path.is_dir() {
107            return Some(path);
108        }
109    }
110    // Fall back to legacy
111    legacy_dir().map(|d| d.join("rules")).filter(|p| p.is_dir())
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn config_dir_ends_with_perspt() {
120        if let Some(dir) = config_dir() {
121            assert!(dir.ends_with("perspt"));
122        }
123    }
124
125    #[test]
126    fn config_file_ends_with_config_toml() {
127        if let Some(path) = config_file() {
128            assert_eq!(path.file_name().unwrap(), "config.toml");
129        }
130    }
131
132    #[test]
133    fn policy_dir_ends_with_rules() {
134        if let Some(dir) = policy_dir() {
135            assert!(dir.ends_with("rules"));
136        }
137    }
138
139    #[test]
140    fn data_dir_ends_with_perspt() {
141        if let Some(dir) = data_dir() {
142            assert!(dir.ends_with("perspt"));
143        }
144    }
145
146    #[test]
147    fn database_path_ends_with_db() {
148        if let Some(path) = database_path() {
149            assert_eq!(path.file_name().unwrap(), "perspt.db");
150        }
151    }
152}