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#[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#[cfg(not(unix))]
26pub fn set_restrictive_permissions(_path: &Path) -> Result<()> {
27 Ok(())
28}
29
30#[derive(serde::Serialize, serde::Deserialize)]
35#[serde(tag = "type", rename_all = "lowercase")]
36pub enum NdjsonLine {
37 Header(SessionHeader),
39 Command(Command),
41 Footer(SessionFooter),
43}
44
45pub struct SessionStore {
61 paths: Paths,
62}
63
64impl SessionStore {
65 #[must_use]
67 pub fn new(paths: Paths) -> Self {
68 Self { paths }
69 }
70
71 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 let write_result = (|| -> Result<()> {
95 let file = File::create(&tmp_path)?;
96 let mut writer = BufWriter::new(&file);
97
98 let header_line = NdjsonLine::Header(session.header.clone());
100 serde_json::to_writer(&mut writer, &header_line)?;
101 writeln!(writer)?;
102
103 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 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 writer.flush()?;
119 file.sync_all()?;
120 Ok(())
121 })();
122
123 if let Err(e) = write_result {
125 let _ = fs::remove_file(&tmp_path);
126 return Err(e);
127 }
128
129 if let Err(e) = fs::rename(&tmp_path, &final_path) {
131 let _ = fs::remove_file(&tmp_path);
133 return Err(e.into());
134 }
135
136 set_restrictive_permissions(&final_path)?;
138
139 Ok(())
140 }
141
142 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 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 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 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 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 pub fn rename(&self, id: &str, new_name: &str) -> Result<()> {
313 let source = self.paths.session_file(id);
315 let backup = self.paths.backup_file(id);
316 fs::copy(&source, &backup)?;
317
318 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 #[allow(clippy::needless_pass_by_value)]
343 pub fn add_tags(&self, id: &str, tags: Vec<String>) -> Result<Vec<String>> {
344 let source = self.paths.session_file(id);
346 let backup = self.paths.backup_file(id);
347 fs::copy(&source, &backup)?;
348
349 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 #[must_use]
369 pub fn exists(&self, id: &str) -> bool {
370 self.paths.session_file(id).exists()
371 }
372
373 #[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 store.save(&session).unwrap();
424
425 assert!(store.exists(&session_id));
427
428 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 let path = paths.session_file(&session_id);
455 let contents = fs::read_to_string(path).unwrap();
456
457 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 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 let sessions = store.list().unwrap();
477 assert!(sessions.is_empty());
478
479 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 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 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 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 store.rename(&session_id, "new-name").unwrap();
623
624 let loaded = store.load(&session_id).unwrap();
626 assert_eq!(loaded.header.name, "new-name");
627
628 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 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 store
689 .add_tags(&session_id, vec!["deploy".to_string(), "setup".to_string()])
690 .unwrap();
691
692 let tags = store
694 .add_tags(&session_id, vec!["deploy".to_string(), "rust".to_string()])
695 .unwrap();
696
697 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 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 let source = paths.session_file(&session_id);
736 let backup = paths.backup_file(&session_id);
737 fs::copy(&source, &backup).unwrap();
738
739 let dir_perms = PermissionsExt::from_mode(0o555);
742 std::fs::set_permissions(&paths.data_dir, dir_perms).unwrap();
743
744 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 assert!(backup.exists(), "Backup should be preserved on failure");
755
756 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 let source = paths.session_file(&session_id);
774 let backup = paths.backup_file(&session_id);
775 fs::copy(&source, &backup).unwrap();
776
777 let dir_perms = PermissionsExt::from_mode(0o555);
780 std::fs::set_permissions(&paths.data_dir, dir_perms).unwrap();
781
782 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 assert!(backup.exists(), "Backup should be preserved on failure");
793
794 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 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 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 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 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 let dir_perms = PermissionsExt::from_mode(0o555);
878 std::fs::set_permissions(&paths.data_dir, dir_perms).unwrap();
879
880 let result = store.save(&session);
882 assert!(
883 result.is_err(),
884 "Save should fail when directory is read-only"
885 );
886
887 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 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 let mut session = create_test_session("overwrite-test");
908 let session_id = session.header.id.to_string();
909 store.save(&session).unwrap();
910
911 session.header.name = "updated-name".to_string();
913 session.header.tags.push("new-tag".to_string());
914 store.save(&session).unwrap();
915
916 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}