Skip to main content

rec/storage/
alias_store.rs

1//! Alias storage with atomic JSON persistence.
2//!
3//! Provides CRUD operations for session aliases — short names that map
4//! to session names. Aliases are stored as JSON in the data directory
5//! with atomic write-tmp-rename for safe persistence.
6
7use std::collections::HashMap;
8use std::fs;
9use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::Result;
14use crate::storage::Paths;
15use crate::storage::session_store::set_restrictive_permissions;
16
17/// Serializable alias map (`alias_name` → `session_name`).
18#[derive(Serialize, Deserialize, Default)]
19struct AliasMap {
20    aliases: HashMap<String, String>,
21}
22
23/// Persistent alias storage backed by a JSON file.
24///
25/// Aliases are stored at `~/.local/share/rec/aliases.json` (the parent
26/// of the sessions data directory). All writes use atomic tmp-rename
27/// to prevent corruption.
28pub struct AliasStore {
29    path: PathBuf,
30}
31
32impl AliasStore {
33    /// Create a new `AliasStore` using the given paths.
34    ///
35    /// The alias file is placed in the parent of `data_dir` (which points
36    /// to `~/.local/share/rec/sessions/`), giving `~/.local/share/rec/aliases.json`.
37    #[must_use]
38    pub fn new(paths: &Paths) -> Self {
39        let path = paths
40            .data_dir
41            .parent()
42            .unwrap_or(&paths.data_dir)
43            .join("aliases.json");
44        Self { path }
45    }
46
47    /// Look up an alias by name.
48    ///
49    /// Returns `Ok(None)` if the alias doesn't exist or the file hasn't
50    /// been created yet.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error if the alias file exists but cannot be read or parsed.
55    pub fn get(&self, alias: &str) -> Result<Option<String>> {
56        let map = self.load_map()?;
57        Ok(map.aliases.get(alias).cloned())
58    }
59
60    /// Set an alias to point to a session name.
61    ///
62    /// Overwrites any existing alias with the same name.
63    ///
64    /// # Errors
65    ///
66    /// Returns an error if the alias file cannot be read or written.
67    pub fn set(&self, alias: &str, session: &str) -> Result<()> {
68        let mut map = self.load_map()?;
69        map.aliases.insert(alias.to_string(), session.to_string());
70        self.save_map(&map)
71    }
72
73    /// Remove an alias by name.
74    ///
75    /// Returns `Ok(true)` if the alias was removed, `Ok(false)` if it
76    /// didn't exist.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if the alias file cannot be read or written.
81    pub fn remove(&self, alias: &str) -> Result<bool> {
82        let mut map = self.load_map()?;
83        let removed = map.aliases.remove(alias).is_some();
84        if removed {
85            self.save_map(&map)?;
86        }
87        Ok(removed)
88    }
89
90    /// List all aliases sorted alphabetically by alias name.
91    ///
92    /// Returns pairs of (`alias_name`, `session_name`).
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if the alias file cannot be read or parsed.
97    pub fn list(&self) -> Result<Vec<(String, String)>> {
98        let map = self.load_map()?;
99        let mut entries: Vec<(String, String)> = map.aliases.into_iter().collect();
100        entries.sort_by(|a, b| a.0.cmp(&b.0));
101        Ok(entries)
102    }
103
104    /// Load the alias map from disk.
105    ///
106    /// Returns an empty map if the file doesn't exist yet.
107    fn load_map(&self) -> Result<AliasMap> {
108        if !self.path.exists() {
109            return Ok(AliasMap::default());
110        }
111        let content = fs::read_to_string(&self.path)?;
112        let map: AliasMap = serde_json::from_str(&content)?;
113        Ok(map)
114    }
115
116    /// Save the alias map to disk atomically.
117    ///
118    /// Writes to a temporary file first, then renames to the target path.
119    /// This prevents corruption if the process is interrupted mid-write.
120    fn save_map(&self, map: &AliasMap) -> Result<()> {
121        // Ensure parent directory exists
122        if let Some(parent) = self.path.parent() {
123            fs::create_dir_all(parent)?;
124        }
125
126        let tmp_path = self.path.with_extension("json.tmp");
127        let content = serde_json::to_string_pretty(map)?;
128        fs::write(&tmp_path, content)?;
129        fs::rename(&tmp_path, &self.path)?;
130
131        // Set restrictive permissions (0o600) to prevent other users from reading
132        set_restrictive_permissions(&self.path)?;
133
134        Ok(())
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use tempfile::TempDir;
142
143    fn create_test_store(temp_dir: &TempDir) -> AliasStore {
144        let paths = Paths {
145            data_dir: temp_dir.path().join("sessions"),
146            config_dir: temp_dir.path().join("config"),
147            config_file: temp_dir.path().join("config").join("config.toml"),
148            state_dir: temp_dir.path().join("state"),
149        };
150        AliasStore::new(&paths)
151    }
152
153    #[test]
154    fn test_alias_set_and_get() {
155        let temp_dir = TempDir::new().unwrap();
156        let store = create_test_store(&temp_dir);
157
158        store.set("deploy", "session-2026-01-15-deploy").unwrap();
159        let result = store.get("deploy").unwrap();
160        assert_eq!(result, Some("session-2026-01-15-deploy".to_string()));
161    }
162
163    #[test]
164    fn test_alias_get_nonexistent() {
165        let temp_dir = TempDir::new().unwrap();
166        let store = create_test_store(&temp_dir);
167
168        let result = store.get("nonexistent").unwrap();
169        assert_eq!(result, None);
170    }
171
172    #[test]
173    fn test_alias_remove() {
174        let temp_dir = TempDir::new().unwrap();
175        let store = create_test_store(&temp_dir);
176
177        store.set("deploy", "session-deploy").unwrap();
178        let removed = store.remove("deploy").unwrap();
179        assert!(removed);
180
181        let result = store.get("deploy").unwrap();
182        assert_eq!(result, None);
183    }
184
185    #[test]
186    fn test_alias_remove_nonexistent() {
187        let temp_dir = TempDir::new().unwrap();
188        let store = create_test_store(&temp_dir);
189
190        let removed = store.remove("nonexistent").unwrap();
191        assert!(!removed);
192    }
193
194    #[test]
195    fn test_alias_list_sorted() {
196        let temp_dir = TempDir::new().unwrap();
197        let store = create_test_store(&temp_dir);
198
199        store.set("zebra", "session-z").unwrap();
200        store.set("alpha", "session-a").unwrap();
201        store.set("middle", "session-m").unwrap();
202
203        let list = store.list().unwrap();
204        assert_eq!(list.len(), 3);
205        assert_eq!(list[0], ("alpha".to_string(), "session-a".to_string()));
206        assert_eq!(list[1], ("middle".to_string(), "session-m".to_string()));
207        assert_eq!(list[2], ("zebra".to_string(), "session-z".to_string()));
208    }
209
210    #[test]
211    fn test_alias_list_empty() {
212        let temp_dir = TempDir::new().unwrap();
213        let store = create_test_store(&temp_dir);
214
215        let list = store.list().unwrap();
216        assert!(list.is_empty());
217    }
218
219    #[test]
220    fn test_alias_overwrite() {
221        let temp_dir = TempDir::new().unwrap();
222        let store = create_test_store(&temp_dir);
223
224        store.set("deploy", "session-old").unwrap();
225        store.set("deploy", "session-new").unwrap();
226
227        let result = store.get("deploy").unwrap();
228        assert_eq!(result, Some("session-new".to_string()));
229
230        // Should still be only one entry
231        let list = store.list().unwrap();
232        assert_eq!(list.len(), 1);
233    }
234
235    #[test]
236    fn test_alias_atomic_write() {
237        let temp_dir = TempDir::new().unwrap();
238        let store = create_test_store(&temp_dir);
239
240        store.set("test", "session-test").unwrap();
241
242        // Verify the tmp file doesn't linger
243        let tmp_path = store.path.with_extension("json.tmp");
244        assert!(
245            !tmp_path.exists(),
246            "Temporary file should not exist after successful write"
247        );
248
249        // Verify the actual file exists and is valid JSON
250        assert!(store.path.exists(), "Alias file should exist");
251        let content = fs::read_to_string(&store.path).unwrap();
252        let _: AliasMap = serde_json::from_str(&content).expect("Should be valid JSON");
253    }
254
255    #[test]
256    fn test_alias_persistence_across_instances() {
257        let temp_dir = TempDir::new().unwrap();
258
259        // Write with one instance
260        {
261            let store = create_test_store(&temp_dir);
262            store.set("persist", "session-persist").unwrap();
263        }
264
265        // Read with a new instance
266        {
267            let store = create_test_store(&temp_dir);
268            let result = store.get("persist").unwrap();
269            assert_eq!(result, Some("session-persist".to_string()));
270        }
271    }
272
273    #[test]
274    fn test_alias_store_path_location() {
275        let temp_dir = TempDir::new().unwrap();
276        let store = create_test_store(&temp_dir);
277
278        // Path should be in the parent of data_dir (which is sessions/)
279        // So it should be at temp_dir/aliases.json
280        assert_eq!(store.path, temp_dir.path().join("aliases.json"));
281    }
282
283    #[test]
284    #[cfg(unix)]
285    fn test_alias_file_has_restrictive_permissions() {
286        use std::os::unix::fs::PermissionsExt;
287
288        let temp_dir = TempDir::new().unwrap();
289        let store = create_test_store(&temp_dir);
290
291        store.set("deploy", "session-deploy").unwrap();
292
293        // Verify permissions are 0o600 (read/write for owner only)
294        let metadata = fs::metadata(&store.path).unwrap();
295        let mode = metadata.permissions().mode();
296
297        // On Unix, mode includes file type bits. We only care about permission bits (lower 9 bits)
298        let permission_bits = mode & 0o777;
299        assert_eq!(
300            permission_bits, 0o600,
301            "Alias file should have 0o600 permissions, got 0o{permission_bits:o}"
302        );
303    }
304}