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}