rec/storage/
alias_store.rs1use 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#[derive(Serialize, Deserialize, Default)]
19struct AliasMap {
20 aliases: HashMap<String, String>,
21}
22
23pub struct AliasStore {
29 path: PathBuf,
30}
31
32impl AliasStore {
33 #[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 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 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 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 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 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 fn save_map(&self, map: &AliasMap) -> Result<()> {
121 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(&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 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 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 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 {
261 let store = create_test_store(&temp_dir);
262 store.set("persist", "session-persist").unwrap();
263 }
264
265 {
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 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 let metadata = fs::metadata(&store.path).unwrap();
295 let mode = metadata.permissions().mode();
296
297 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}