Skip to main content

rec/storage/
paths.rs

1use directories::ProjectDirs;
2use std::path::PathBuf;
3
4/// XDG-compliant paths for rec data, config, and state.
5///
6/// On Linux, uses the XDG Base Directory Specification:
7/// - Data: ~/.local/share/rec/sessions/
8/// - Config: ~/.config/rec/config.toml
9/// - State: ~/.local/state/rec/ (or ~/.local/share/rec/state)
10///
11/// If XDG directories are unavailable (`ProjectDirs` returns None),
12/// falls back to ~/.rec for all paths.
13#[derive(Debug, Clone)]
14pub struct Paths {
15    /// Directory for session data files
16    pub data_dir: PathBuf,
17
18    /// Directory for configuration files
19    pub config_dir: PathBuf,
20
21    /// Path to the main config file
22    pub config_file: PathBuf,
23
24    /// Directory for state files (recording state, PID files)
25    pub state_dir: PathBuf,
26}
27
28impl Paths {
29    /// Create new Paths using XDG directories with ~/.rec fallback.
30    ///
31    /// Attempts to use the XDG Base Directory Specification via the
32    /// `directories` crate. If that fails (e.g., on systems without
33    /// proper XDG support), falls back to ~/.rec for all paths.
34    ///
35    /// # Panics
36    ///
37    /// Panics if the home directory cannot be determined (XDG fallback path).
38    #[must_use]
39    pub fn new() -> Self {
40        // Try XDG first via directories crate
41        if let Some(proj_dirs) = ProjectDirs::from("", "", "rec") {
42            Self {
43                data_dir: proj_dirs.data_dir().join("sessions"),
44                config_dir: proj_dirs.config_dir().to_path_buf(),
45                config_file: proj_dirs.config_dir().join("config.toml"),
46                state_dir: proj_dirs.state_dir().map_or_else(
47                    || proj_dirs.data_dir().join("state"),
48                    std::path::Path::to_path_buf,
49                ),
50            }
51        } else {
52            // Fallback to ~/.rec
53            let home = directories::BaseDirs::new()
54                .expect("Could not determine home directory")
55                .home_dir()
56                .to_path_buf();
57            let rec_dir = home.join(".rec");
58            Self {
59                data_dir: rec_dir.join("sessions"),
60                config_dir: rec_dir.clone(),
61                config_file: rec_dir.join("config.toml"),
62                state_dir: rec_dir.join("state"),
63            }
64        }
65    }
66
67    /// Ensure all directories exist, creating them if necessary.
68    ///
69    /// Creates:
70    /// - `data_dir` (for session files)
71    /// - `config_dir` (for config.toml)
72    /// - `state_dir` (for recording state)
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if directory creation fails (e.g., permission denied).
77    pub fn ensure_dirs(&self) -> std::io::Result<()> {
78        std::fs::create_dir_all(&self.data_dir)?;
79        std::fs::create_dir_all(&self.config_dir)?;
80        std::fs::create_dir_all(&self.state_dir)?;
81        Ok(())
82    }
83
84    /// Get the path for a session file by ID.
85    ///
86    /// Session files use the `.ndjson` extension for NDJSON format.
87    #[must_use]
88    pub fn session_file(&self, id: &str) -> PathBuf {
89        self.data_dir.join(format!("{id}.ndjson"))
90    }
91
92    /// Get the path for a session backup file by ID.
93    ///
94    /// Backup files use the `.ndjson.bak` extension and are created
95    /// before modifying session data (rename, tag, etc.) to allow
96    /// recovery if the modification fails.
97    #[must_use]
98    pub fn backup_file(&self, id: &str) -> PathBuf {
99        self.data_dir.join(format!("{id}.ndjson.bak"))
100    }
101}
102
103impl Default for Paths {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109/// Global paths instance (lazy initialization).
110///
111/// Returns a static reference to the Paths instance, which is
112/// created once on first access using `OnceLock`.
113pub fn get_paths() -> &'static Paths {
114    use std::sync::OnceLock;
115    static PATHS: OnceLock<Paths> = OnceLock::new();
116    PATHS.get_or_init(Paths::new)
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn test_paths_new() {
125        let paths = Paths::new();
126
127        // Basic sanity checks - paths should not be empty
128        assert!(!paths.data_dir.as_os_str().is_empty());
129        assert!(!paths.config_dir.as_os_str().is_empty());
130        assert!(!paths.config_file.as_os_str().is_empty());
131        assert!(!paths.state_dir.as_os_str().is_empty());
132    }
133
134    #[test]
135    fn test_paths_session_file() {
136        let paths = Paths::new();
137        let session_path = paths.session_file("test-session-123");
138
139        assert!(session_path.to_string_lossy().contains("test-session-123"));
140        assert!(session_path.extension().is_some_and(|ext| ext == "ndjson"));
141    }
142
143    #[test]
144    fn test_get_paths_returns_same_instance() {
145        let paths1 = get_paths();
146        let paths2 = get_paths();
147
148        // Should be the same reference
149        assert!(std::ptr::eq(paths1, paths2));
150    }
151
152    #[test]
153    fn test_paths_xdg_structure() {
154        let paths = Paths::new();
155
156        // On Linux with XDG support, paths should follow XDG structure
157        // The data_dir should end with "sessions"
158        assert!(
159            paths
160                .data_dir
161                .file_name()
162                .is_some_and(|name| name == "sessions")
163        );
164
165        // The config_file should be config.toml
166        assert!(
167            paths
168                .config_file
169                .file_name()
170                .is_some_and(|name| name == "config.toml")
171        );
172    }
173}