Skip to main content

rec/storage/
session_store.rs

1use crate::error::{RecError, Result};
2use crate::models::{Command, Session, SessionFooter, SessionHeader};
3use crate::storage::Paths;
4use std::fs::{self, File};
5use std::io::{BufRead, BufReader, BufWriter, Write};
6use std::path::Path;
7
8/// Set restrictive file permissions (0o600: read/write for owner only).
9///
10/// This is a security measure to prevent other users from reading
11/// potentially sensitive session data.
12///
13/// # Errors
14///
15/// Returns an error if setting permissions fails.
16#[cfg(unix)]
17pub fn set_restrictive_permissions(path: &Path) -> Result<()> {
18    use std::os::unix::fs::PermissionsExt;
19    let permissions = std::fs::Permissions::from_mode(0o600);
20    std::fs::set_permissions(path, permissions)?;
21    Ok(())
22}
23
24/// No-op on non-Unix platforms (Windows has a different permission model).
25#[cfg(not(unix))]
26pub fn set_restrictive_permissions(_path: &Path) -> Result<()> {
27    Ok(())
28}
29
30/// NDJSON line types for streaming writes.
31///
32/// Each line in a session file is one of these types, discriminated
33/// by the "type" field in the JSON.
34#[derive(serde::Serialize, serde::Deserialize)]
35#[serde(tag = "type", rename_all = "lowercase")]
36pub enum NdjsonLine {
37    /// Session header with metadata
38    Header(SessionHeader),
39    /// Individual command
40    Command(Command),
41    /// Session footer with summary
42    Footer(SessionFooter),
43}
44
45/// Session storage with NDJSON format.
46///
47/// Stores sessions as NDJSON (Newline Delimited JSON) files where each line
48/// is a valid JSON object. This format allows:
49/// - Streaming writes (append commands during recording)
50/// - Human readability (one JSON object per line)
51/// - Crash recovery (partial files are readable up to last complete line)
52///
53/// File structure:
54/// ```json
55/// {"type": "header", "version": 2, "id": "...", ...}
56/// {"type": "command", "index": 0, "command": "...", ...}
57/// {"type": "command", "index": 1, "command": "...", ...}
58/// {"type": "footer", "ended_at": ..., "command_count": ..., "status": "..."}
59/// ```
60pub struct SessionStore {
61    paths: Paths,
62}
63
64impl SessionStore {
65    /// Create a new `SessionStore` with the given paths.
66    #[must_use]
67    pub fn new(paths: Paths) -> Self {
68        Self { paths }
69    }
70
71    /// Save a complete session to NDJSON file atomically.
72    ///
73    /// Uses a write-to-temp-then-rename pattern for crash safety:
74    /// 1. Write to a temporary file (`{uuid}.ndjson.tmp`)
75    /// 2. Flush and sync to disk
76    /// 3. Atomically rename to final location (POSIX rename is atomic)
77    ///
78    /// This ensures the session file is never left in a partial state.
79    /// If a crash occurs during write, only the `.tmp` file is affected.
80    ///
81    /// # Errors
82    ///
83    /// Returns an error if:
84    /// - Directory creation fails
85    /// - File creation fails
86    /// - JSON serialization fails
87    /// - Atomic rename fails
88    pub fn save(&self, session: &Session) -> Result<()> {
89        self.paths.ensure_dirs()?;
90        let final_path = self.paths.session_file(&session.header.id.to_string());
91        let tmp_path = final_path.with_extension("ndjson.tmp");
92
93        // Write to temporary file
94        let write_result = (|| -> Result<()> {
95            let file = File::create(&tmp_path)?;
96            let mut writer = BufWriter::new(&file);
97
98            // Write header line
99            let header_line = NdjsonLine::Header(session.header.clone());
100            serde_json::to_writer(&mut writer, &header_line)?;
101            writeln!(writer)?;
102
103            // Write each command
104            for cmd in &session.commands {
105                let cmd_line = NdjsonLine::Command(cmd.clone());
106                serde_json::to_writer(&mut writer, &cmd_line)?;
107                writeln!(writer)?;
108            }
109
110            // Write footer if session is complete
111            if let Some(ref footer) = session.footer {
112                let footer_line = NdjsonLine::Footer(footer.clone());
113                serde_json::to_writer(&mut writer, &footer_line)?;
114                writeln!(writer)?;
115            }
116
117            // Ensure all data is flushed and synced to disk before rename
118            writer.flush()?;
119            file.sync_all()?;
120            Ok(())
121        })();
122
123        // Handle write errors: clean up temp file
124        if let Err(e) = write_result {
125            let _ = fs::remove_file(&tmp_path);
126            return Err(e);
127        }
128
129        // Atomic rename (POSIX guarantees atomicity for rename on same filesystem)
130        if let Err(e) = fs::rename(&tmp_path, &final_path) {
131            // Clean up temp file on rename failure
132            let _ = fs::remove_file(&tmp_path);
133            return Err(e.into());
134        }
135
136        // Set restrictive permissions (0o600) to prevent other users from reading
137        set_restrictive_permissions(&final_path)?;
138
139        Ok(())
140    }
141
142    /// Load a session from NDJSON file.
143    ///
144    /// Reads the NDJSON file line by line and reconstructs the Session
145    /// struct from the header, commands, and optional footer.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if:
150    /// - Session file doesn't exist
151    /// - File read fails
152    /// - JSON parsing fails
153    /// - File is missing the header
154    pub fn load(&self, id: &str) -> Result<Session> {
155        let path = self.paths.session_file(id);
156        if !path.exists() {
157            return Err(RecError::SessionNotFound(id.to_string()));
158        }
159
160        let file = File::open(&path)?;
161        let reader = BufReader::new(file);
162
163        let mut header: Option<SessionHeader> = None;
164        let mut commands: Vec<Command> = Vec::new();
165        let mut footer: Option<SessionFooter> = None;
166
167        for line in reader.lines() {
168            let line = line?;
169            if line.trim().is_empty() {
170                continue;
171            }
172
173            let parsed: NdjsonLine =
174                serde_json::from_str(&line).map_err(|e| RecError::InvalidSession(e.to_string()))?;
175
176            match parsed {
177                NdjsonLine::Header(h) => header = Some(h),
178                NdjsonLine::Command(c) => commands.push(c),
179                NdjsonLine::Footer(f) => footer = Some(f),
180            }
181        }
182
183        let header =
184            header.ok_or_else(|| RecError::InvalidSession("Missing header".to_string()))?;
185
186        Ok(Session {
187            header,
188            commands,
189            footer,
190        })
191    }
192
193    /// List all session IDs in the data directory.
194    ///
195    /// Returns the file stems (IDs) of all `.ndjson` files in the
196    /// sessions directory.
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if reading the directory fails.
201    pub fn list(&self) -> Result<Vec<String>> {
202        let mut sessions = Vec::new();
203
204        if !self.paths.data_dir.exists() {
205            return Ok(sessions);
206        }
207
208        for entry in fs::read_dir(&self.paths.data_dir)? {
209            let entry = entry?;
210            let path = entry.path();
211
212            if path.extension().is_some_and(|ext| ext == "ndjson") {
213                if let Some(stem) = path.file_stem() {
214                    sessions.push(stem.to_string_lossy().to_string());
215                }
216            }
217        }
218
219        Ok(sessions)
220    }
221
222    /// Delete a session by ID.
223    ///
224    /// Removes the session file from disk.
225    ///
226    /// # Errors
227    ///
228    /// Returns an error if the session doesn't exist or deletion fails.
229    pub fn delete(&self, id: &str) -> Result<()> {
230        let path = self.paths.session_file(id);
231        if !path.exists() {
232            return Err(RecError::SessionNotFound(id.to_string()));
233        }
234        fs::remove_file(path)?;
235        Ok(())
236    }
237
238    /// Load only the header and footer from a session file.
239    ///
240    /// Much more efficient than `load()` for listing because it skips
241    /// command deserialization. Reads the file line by line:
242    /// - Parses the first non-empty line as the header
243    /// - Tracks the last non-empty line and tries parsing it as a footer
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if:
248    /// - Session file doesn't exist
249    /// - File read fails
250    /// - Header is missing or invalid
251    pub fn load_header_and_footer(
252        &self,
253        id: &str,
254    ) -> Result<(SessionHeader, Option<SessionFooter>)> {
255        let path = self.paths.session_file(id);
256        if !path.exists() {
257            return Err(RecError::SessionNotFound(id.to_string()));
258        }
259
260        let file = File::open(&path)?;
261        let reader = BufReader::new(file);
262
263        let mut header: Option<SessionHeader> = None;
264        let mut last_line = String::new();
265
266        for line in reader.lines() {
267            let line = line?;
268            if line.trim().is_empty() {
269                continue;
270            }
271
272            if header.is_none() {
273                let parsed: NdjsonLine = serde_json::from_str(&line)
274                    .map_err(|e| RecError::InvalidSession(e.to_string()))?;
275                if let NdjsonLine::Header(h) = parsed {
276                    header = Some(h);
277                } else {
278                    return Err(RecError::InvalidSession(
279                        "First line is not a header".to_string(),
280                    ));
281                }
282            }
283
284            last_line = line;
285        }
286
287        let header =
288            header.ok_or_else(|| RecError::InvalidSession("Missing header".to_string()))?;
289
290        // Try to parse the last line as a footer
291        let footer = serde_json::from_str::<NdjsonLine>(&last_line)
292            .ok()
293            .and_then(|parsed| match parsed {
294                NdjsonLine::Footer(f) => Some(f),
295                _ => None,
296            });
297
298        Ok((header, footer))
299    }
300
301    /// Rename a session by updating the name in its NDJSON file.
302    ///
303    /// Creates a backup of the original file before modifying.
304    /// Loads the full session, updates the name, and saves it back.
305    ///
306    /// # Errors
307    ///
308    /// Returns an error if:
309    /// - Session doesn't exist
310    /// - Backup creation fails
311    /// - File write fails
312    pub fn rename(&self, id: &str, new_name: &str) -> Result<()> {
313        // Create backup before modifying
314        let source = self.paths.session_file(id);
315        let backup = self.paths.backup_file(id);
316        fs::copy(&source, &backup)?;
317
318        // Load, update, save — clean up backup on success, preserve on failure
319        let mut session = self.load(id)?;
320        session.header.name = new_name.to_string();
321
322        match self.save(&session) {
323            Ok(()) => {
324                let _ = fs::remove_file(&backup);
325                Ok(())
326            }
327            Err(e) => Err(e),
328        }
329    }
330
331    /// Add tags to a session, skipping duplicates.
332    ///
333    /// Creates a backup of the original file before modifying.
334    /// Returns the final list of all tags on the session.
335    ///
336    /// # Errors
337    ///
338    /// Returns an error if:
339    /// - Session doesn't exist
340    /// - Backup creation fails
341    /// - File write fails
342    #[allow(clippy::needless_pass_by_value)]
343    pub fn add_tags(&self, id: &str, tags: Vec<String>) -> Result<Vec<String>> {
344        // Create backup before modifying
345        let source = self.paths.session_file(id);
346        let backup = self.paths.backup_file(id);
347        fs::copy(&source, &backup)?;
348
349        // Load, update tags, save — clean up backup on success, preserve on failure
350        let mut session = self.load(id)?;
351        for tag in &tags {
352            if !session.header.tags.contains(tag) {
353                session.header.tags.push(tag.clone());
354            }
355        }
356        let final_tags = session.header.tags.clone();
357
358        match self.save(&session) {
359            Ok(()) => {
360                let _ = fs::remove_file(&backup);
361                Ok(final_tags)
362            }
363            Err(e) => Err(e),
364        }
365    }
366
367    /// Check if a session exists.
368    #[must_use]
369    pub fn exists(&self, id: &str) -> bool {
370        self.paths.session_file(id).exists()
371    }
372
373    /// Get the file path for a session by ID.
374    #[must_use]
375    pub fn session_file_path(&self, id: &str) -> std::path::PathBuf {
376        self.paths.session_file(id)
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::models::SessionStatus;
384    use std::path::PathBuf;
385    use tempfile::TempDir;
386
387    #[cfg(unix)]
388    use std::os::unix::fs::PermissionsExt;
389
390    fn create_test_paths(temp_dir: &TempDir) -> Paths {
391        Paths {
392            data_dir: temp_dir.path().join("sessions"),
393            config_dir: temp_dir.path().join("config"),
394            config_file: temp_dir.path().join("config").join("config.toml"),
395            state_dir: temp_dir.path().join("state"),
396        }
397    }
398
399    fn create_test_session(name: &str) -> Session {
400        let mut session = Session::new(name);
401        session.commands.push(Command::new(
402            0,
403            "echo hello".to_string(),
404            PathBuf::from("/tmp"),
405        ));
406        session
407            .commands
408            .push(Command::new(1, "ls -la".to_string(), PathBuf::from("/tmp")));
409        session.complete(SessionStatus::Completed);
410        session
411    }
412
413    #[test]
414    fn test_save_and_load_session() {
415        let temp_dir = TempDir::new().unwrap();
416        let paths = create_test_paths(&temp_dir);
417        let store = SessionStore::new(paths);
418
419        let session = create_test_session("test-session");
420        let session_id = session.header.id.to_string();
421
422        // Save
423        store.save(&session).unwrap();
424
425        // Verify file exists
426        assert!(store.exists(&session_id));
427
428        // Load
429        let loaded = store.load(&session_id).unwrap();
430
431        assert_eq!(loaded.header.name, "test-session");
432        assert_eq!(loaded.commands.len(), 2);
433        assert_eq!(loaded.commands[0].command, "echo hello");
434        assert_eq!(loaded.commands[1].command, "ls -la");
435        assert!(loaded.footer.is_some());
436        assert_eq!(
437            loaded.footer.as_ref().unwrap().status,
438            SessionStatus::Completed
439        );
440    }
441
442    #[test]
443    fn test_ndjson_format_is_human_readable() {
444        let temp_dir = TempDir::new().unwrap();
445        let paths = create_test_paths(&temp_dir);
446        let store = SessionStore::new(paths.clone());
447
448        let session = create_test_session("readable-test");
449        let session_id = session.header.id.to_string();
450
451        store.save(&session).unwrap();
452
453        // Read raw file and verify format
454        let path = paths.session_file(&session_id);
455        let contents = fs::read_to_string(path).unwrap();
456
457        // Each line should be valid JSON
458        for line in contents.lines() {
459            let _: serde_json::Value =
460                serde_json::from_str(line).expect("Each line should be valid JSON");
461        }
462
463        // Verify type discriminator
464        assert!(contents.contains("\"type\":\"header\""));
465        assert!(contents.contains("\"type\":\"command\""));
466        assert!(contents.contains("\"type\":\"footer\""));
467    }
468
469    #[test]
470    fn test_list_sessions() {
471        let temp_dir = TempDir::new().unwrap();
472        let paths = create_test_paths(&temp_dir);
473        let store = SessionStore::new(paths);
474
475        // Initially empty
476        let sessions = store.list().unwrap();
477        assert!(sessions.is_empty());
478
479        // Add sessions
480        let session1 = create_test_session("session-1");
481        let session2 = create_test_session("session-2");
482        let id1 = session1.header.id.to_string();
483        let id2 = session2.header.id.to_string();
484
485        store.save(&session1).unwrap();
486        store.save(&session2).unwrap();
487
488        // List should return both
489        let sessions = store.list().unwrap();
490        assert_eq!(sessions.len(), 2);
491        assert!(sessions.contains(&id1));
492        assert!(sessions.contains(&id2));
493    }
494
495    #[test]
496    fn test_delete_session() {
497        let temp_dir = TempDir::new().unwrap();
498        let paths = create_test_paths(&temp_dir);
499        let store = SessionStore::new(paths);
500
501        let session = create_test_session("to-delete");
502        let session_id = session.header.id.to_string();
503
504        store.save(&session).unwrap();
505        assert!(store.exists(&session_id));
506
507        store.delete(&session_id).unwrap();
508        assert!(!store.exists(&session_id));
509    }
510
511    #[test]
512    fn test_load_nonexistent_session() {
513        let temp_dir = TempDir::new().unwrap();
514        let paths = create_test_paths(&temp_dir);
515        let store = SessionStore::new(paths);
516
517        let result = store.load("nonexistent-id");
518        assert!(result.is_err());
519
520        match result {
521            Err(RecError::SessionNotFound(id)) => assert_eq!(id, "nonexistent-id"),
522            _ => panic!("Expected SessionNotFound error"),
523        }
524    }
525
526    #[test]
527    fn test_delete_nonexistent_session() {
528        let temp_dir = TempDir::new().unwrap();
529        let paths = create_test_paths(&temp_dir);
530        let store = SessionStore::new(paths);
531
532        let result = store.delete("nonexistent-id");
533        assert!(result.is_err());
534
535        match result {
536            Err(RecError::SessionNotFound(id)) => assert_eq!(id, "nonexistent-id"),
537            _ => panic!("Expected SessionNotFound error"),
538        }
539    }
540
541    #[test]
542    fn test_load_header_and_footer() {
543        let temp_dir = TempDir::new().unwrap();
544        let paths = create_test_paths(&temp_dir);
545        let store = SessionStore::new(paths);
546
547        let session = create_test_session("hf-test");
548        let session_id = session.header.id.to_string();
549        store.save(&session).unwrap();
550
551        let (header, footer) = store.load_header_and_footer(&session_id).unwrap();
552        assert_eq!(header.name, "hf-test");
553        assert!(footer.is_some());
554        assert_eq!(footer.unwrap().command_count, 2);
555    }
556
557    #[test]
558    fn test_load_header_and_footer_no_footer() {
559        let temp_dir = TempDir::new().unwrap();
560        let paths = create_test_paths(&temp_dir);
561        let store = SessionStore::new(paths);
562
563        // Session without footer (still recording)
564        let mut session = Session::new("in-progress-hf");
565        session.commands.push(Command::new(
566            0,
567            "echo test".to_string(),
568            PathBuf::from("/tmp"),
569        ));
570        let session_id = session.header.id.to_string();
571        store.save(&session).unwrap();
572
573        let (header, footer) = store.load_header_and_footer(&session_id).unwrap();
574        assert_eq!(header.name, "in-progress-hf");
575        assert!(footer.is_none());
576    }
577
578    #[test]
579    fn test_load_header_and_footer_nonexistent() {
580        let temp_dir = TempDir::new().unwrap();
581        let paths = create_test_paths(&temp_dir);
582        let store = SessionStore::new(paths);
583
584        let result = store.load_header_and_footer("nonexistent");
585        assert!(result.is_err());
586    }
587
588    #[test]
589    fn test_save_session_without_footer() {
590        let temp_dir = TempDir::new().unwrap();
591        let paths = create_test_paths(&temp_dir);
592        let store = SessionStore::new(paths);
593
594        // Session still recording (no footer)
595        let mut session = Session::new("in-progress");
596        session.commands.push(Command::new(
597            0,
598            "echo test".to_string(),
599            PathBuf::from("/tmp"),
600        ));
601
602        let session_id = session.header.id.to_string();
603
604        store.save(&session).unwrap();
605        let loaded = store.load(&session_id).unwrap();
606
607        assert!(loaded.footer.is_none());
608        assert_eq!(loaded.commands.len(), 1);
609    }
610
611    #[test]
612    fn test_rename_session() {
613        let temp_dir = TempDir::new().unwrap();
614        let paths = create_test_paths(&temp_dir);
615        let store = SessionStore::new(paths.clone());
616
617        let session = create_test_session("old-name");
618        let session_id = session.header.id.to_string();
619        store.save(&session).unwrap();
620
621        // Rename
622        store.rename(&session_id, "new-name").unwrap();
623
624        // Verify name updated
625        let loaded = store.load(&session_id).unwrap();
626        assert_eq!(loaded.header.name, "new-name");
627
628        // Verify backup is cleaned up after successful rename
629        let backup_path = paths.backup_file(&session_id);
630        assert!(
631            !backup_path.exists(),
632            "Backup should be cleaned up on success"
633        );
634    }
635
636    #[test]
637    fn test_rename_preserves_commands() {
638        let temp_dir = TempDir::new().unwrap();
639        let paths = create_test_paths(&temp_dir);
640        let store = SessionStore::new(paths);
641
642        let session = create_test_session("original");
643        let session_id = session.header.id.to_string();
644        store.save(&session).unwrap();
645
646        store.rename(&session_id, "renamed").unwrap();
647
648        let loaded = store.load(&session_id).unwrap();
649        assert_eq!(loaded.header.name, "renamed");
650        assert_eq!(loaded.commands.len(), 2);
651        assert_eq!(loaded.commands[0].command, "echo hello");
652        assert_eq!(loaded.commands[1].command, "ls -la");
653        assert!(loaded.footer.is_some());
654    }
655
656    #[test]
657    fn test_add_tags() {
658        let temp_dir = TempDir::new().unwrap();
659        let paths = create_test_paths(&temp_dir);
660        let store = SessionStore::new(paths);
661
662        let session = create_test_session("tagged-session");
663        let session_id = session.header.id.to_string();
664        store.save(&session).unwrap();
665
666        let tags = store
667            .add_tags(&session_id, vec!["deploy".to_string(), "setup".to_string()])
668            .unwrap();
669
670        assert_eq!(tags, vec!["deploy", "setup"]);
671
672        // Verify tags persisted
673        let loaded = store.load(&session_id).unwrap();
674        assert_eq!(loaded.header.tags, vec!["deploy", "setup"]);
675    }
676
677    #[test]
678    fn test_add_tags_skips_duplicates() {
679        let temp_dir = TempDir::new().unwrap();
680        let paths = create_test_paths(&temp_dir);
681        let store = SessionStore::new(paths);
682
683        let session = create_test_session("dup-tags");
684        let session_id = session.header.id.to_string();
685        store.save(&session).unwrap();
686
687        // Add initial tags
688        store
689            .add_tags(&session_id, vec!["deploy".to_string(), "setup".to_string()])
690            .unwrap();
691
692        // Add again with overlap
693        let tags = store
694            .add_tags(&session_id, vec!["deploy".to_string(), "rust".to_string()])
695            .unwrap();
696
697        // Should have 3 tags, not 4 (deploy not duplicated)
698        assert_eq!(tags, vec!["deploy", "setup", "rust"]);
699    }
700
701    #[test]
702    fn test_add_tags_cleans_up_backup_on_success() {
703        let temp_dir = TempDir::new().unwrap();
704        let paths = create_test_paths(&temp_dir);
705        let store = SessionStore::new(paths.clone());
706
707        let session = create_test_session("backup-tag");
708        let session_id = session.header.id.to_string();
709        store.save(&session).unwrap();
710
711        store
712            .add_tags(&session_id, vec!["test-tag".to_string()])
713            .unwrap();
714
715        // Verify backup is cleaned up after successful tag add
716        let backup_path = paths.backup_file(&session_id);
717        assert!(
718            !backup_path.exists(),
719            "Backup should be cleaned up on success"
720        );
721    }
722
723    #[test]
724    #[cfg(unix)]
725    fn test_rename_preserves_backup_on_failure() {
726        let temp_dir = TempDir::new().unwrap();
727        let paths = create_test_paths(&temp_dir);
728        let store = SessionStore::new(paths.clone());
729
730        let session = create_test_session("fail-rename");
731        let session_id = session.header.id.to_string();
732        store.save(&session).unwrap();
733
734        // Create the backup manually (simulating what rename() does before save)
735        let source = paths.session_file(&session_id);
736        let backup = paths.backup_file(&session_id);
737        fs::copy(&source, &backup).unwrap();
738
739        // Make the data directory read-only AFTER backup is created.
740        // This causes the atomic write (tmp file creation + rename) to fail.
741        let dir_perms = PermissionsExt::from_mode(0o555);
742        std::fs::set_permissions(&paths.data_dir, dir_perms).unwrap();
743
744        // Attempt save directly — should fail because atomic write can't create tmp file
745        let mut modified_session = session.clone();
746        modified_session.header.name = "should-fail".to_string();
747        let result = store.save(&modified_session);
748        assert!(
749            result.is_err(),
750            "Save should fail when directory is read-only"
751        );
752
753        // Verify backup is still preserved (simulating what rename() would leave behind)
754        assert!(backup.exists(), "Backup should be preserved on failure");
755
756        // Restore permissions so TempDir cleanup works
757        let dir_perms = PermissionsExt::from_mode(0o755);
758        std::fs::set_permissions(&paths.data_dir, dir_perms).unwrap();
759    }
760
761    #[test]
762    #[cfg(unix)]
763    fn test_add_tags_preserves_backup_on_failure() {
764        let temp_dir = TempDir::new().unwrap();
765        let paths = create_test_paths(&temp_dir);
766        let store = SessionStore::new(paths.clone());
767
768        let session = create_test_session("fail-tags");
769        let session_id = session.header.id.to_string();
770        store.save(&session).unwrap();
771
772        // Create the backup manually (simulating what add_tags() does before save)
773        let source = paths.session_file(&session_id);
774        let backup = paths.backup_file(&session_id);
775        fs::copy(&source, &backup).unwrap();
776
777        // Make the data directory read-only AFTER backup is created.
778        // This causes the atomic write (tmp file creation + rename) to fail.
779        let dir_perms = PermissionsExt::from_mode(0o555);
780        std::fs::set_permissions(&paths.data_dir, dir_perms).unwrap();
781
782        // Attempt save directly — should fail because atomic write can't create tmp file
783        let mut modified_session = session.clone();
784        modified_session.header.tags.push("fail-tag".to_string());
785        let result = store.save(&modified_session);
786        assert!(
787            result.is_err(),
788            "Save should fail when directory is read-only"
789        );
790
791        // Verify backup is still preserved (simulating what add_tags() would leave behind)
792        assert!(backup.exists(), "Backup should be preserved on failure");
793
794        // Restore permissions so TempDir cleanup works
795        let dir_perms = PermissionsExt::from_mode(0o755);
796        std::fs::set_permissions(&paths.data_dir, dir_perms).unwrap();
797    }
798
799    #[test]
800    fn test_add_multiple_tags_at_once() {
801        let temp_dir = TempDir::new().unwrap();
802        let paths = create_test_paths(&temp_dir);
803        let store = SessionStore::new(paths);
804
805        let session = create_test_session("multi-tag");
806        let session_id = session.header.id.to_string();
807        store.save(&session).unwrap();
808
809        let tags = store
810            .add_tags(
811                &session_id,
812                vec!["tag1".to_string(), "tag2".to_string(), "tag3".to_string()],
813            )
814            .unwrap();
815
816        assert_eq!(tags, vec!["tag1", "tag2", "tag3"]);
817    }
818
819    #[test]
820    #[cfg(unix)]
821    fn test_save_sets_restrictive_permissions() {
822        let temp_dir = TempDir::new().unwrap();
823        let paths = create_test_paths(&temp_dir);
824        let store = SessionStore::new(paths.clone());
825
826        let session = create_test_session("permissions-test");
827        let session_id = session.header.id.to_string();
828        store.save(&session).unwrap();
829
830        // Verify permissions are 0o600 (read/write for owner only)
831        let session_path = paths.session_file(&session_id);
832        let metadata = std::fs::metadata(&session_path).unwrap();
833        let mode = metadata.permissions().mode();
834
835        // On Unix, mode includes file type bits. We only care about permission bits (lower 9 bits)
836        let permission_bits = mode & 0o777;
837        assert_eq!(
838            permission_bits, 0o600,
839            "Session file should have 0o600 permissions, got 0o{permission_bits:o}"
840        );
841    }
842
843    #[test]
844    fn test_atomic_save_no_tmp_file_left_on_success() {
845        let temp_dir = TempDir::new().unwrap();
846        let paths = create_test_paths(&temp_dir);
847        let store = SessionStore::new(paths.clone());
848
849        let session = create_test_session("atomic-test");
850        let session_id = session.header.id.to_string();
851        store.save(&session).unwrap();
852
853        // Verify no .tmp file is left behind after successful save
854        let session_path = paths.session_file(&session_id);
855        let tmp_path = session_path.with_extension("ndjson.tmp");
856        assert!(
857            !tmp_path.exists(),
858            "Temporary file should be cleaned up after successful save"
859        );
860
861        // Verify the actual session file exists
862        assert!(session_path.exists(), "Session file should exist");
863    }
864
865    #[test]
866    #[cfg(unix)]
867    fn test_atomic_save_cleans_tmp_on_write_failure() {
868        let temp_dir = TempDir::new().unwrap();
869        let paths = create_test_paths(&temp_dir);
870        paths.ensure_dirs().unwrap();
871        let store = SessionStore::new(paths.clone());
872
873        let session = create_test_session("atomic-fail-test");
874        let session_id = session.header.id.to_string();
875
876        // Make the directory read-only so tmp file creation fails
877        let dir_perms = PermissionsExt::from_mode(0o555);
878        std::fs::set_permissions(&paths.data_dir, dir_perms).unwrap();
879
880        // Attempt save — should fail
881        let result = store.save(&session);
882        assert!(
883            result.is_err(),
884            "Save should fail when directory is read-only"
885        );
886
887        // Verify no tmp file is left behind
888        let session_path = paths.session_file(&session_id);
889        let tmp_path = session_path.with_extension("ndjson.tmp");
890        assert!(
891            !tmp_path.exists(),
892            "Temporary file should not exist after failed save"
893        );
894
895        // Restore permissions for cleanup
896        let dir_perms = PermissionsExt::from_mode(0o755);
897        std::fs::set_permissions(&paths.data_dir, dir_perms).unwrap();
898    }
899
900    #[test]
901    fn test_atomic_save_overwrites_existing_session() {
902        let temp_dir = TempDir::new().unwrap();
903        let paths = create_test_paths(&temp_dir);
904        let store = SessionStore::new(paths);
905
906        // Create and save initial session
907        let mut session = create_test_session("overwrite-test");
908        let session_id = session.header.id.to_string();
909        store.save(&session).unwrap();
910
911        // Modify and save again
912        session.header.name = "updated-name".to_string();
913        session.header.tags.push("new-tag".to_string());
914        store.save(&session).unwrap();
915
916        // Verify the session was updated atomically
917        let loaded = store.load(&session_id).unwrap();
918        assert_eq!(loaded.header.name, "updated-name");
919        assert!(loaded.header.tags.contains(&"new-tag".to_string()));
920    }
921}