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