1use std::fmt;
8use std::fs;
9use std::io::ErrorKind;
10use std::path::{Path, PathBuf};
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14
15use crate::error::PawError;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum SessionStatus {
25 Active,
27 Paused,
32 Stopped,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
39#[serde(rename_all = "lowercase")]
40pub enum SessionMode {
41 #[default]
44 Bare,
45 Supervisor,
48}
49
50impl fmt::Display for SessionStatus {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 match self {
53 Self::Active => write!(f, "active"),
54 Self::Paused => write!(f, "paused"),
55 Self::Stopped => write!(f, "stopped"),
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum DisplayStatus {
71 Active,
73 Paused,
75 Stopped,
77 Stale,
80}
81
82impl DisplayStatus {
83 #[must_use]
92 pub fn from_receipt(status: &SessionStatus, liveness: crate::tmux::SessionLiveness) -> Self {
93 use crate::tmux::SessionLiveness as L;
94 match (status, liveness) {
95 (SessionStatus::Active, L::Alive) => Self::Active,
96 (SessionStatus::Active, L::Stale) => Self::Stale,
97 (SessionStatus::Paused, L::Alive) => Self::Paused,
98 _ => Self::Stopped,
101 }
102 }
103
104 #[must_use]
106 pub fn icon(self) -> &'static str {
107 match self {
108 Self::Active => "\u{1f7e2}", Self::Paused => "\u{1f535}", Self::Stopped => "\u{1f7e1}", Self::Stale => "\u{1f534}", }
113 }
114
115 #[must_use]
117 pub fn as_str(self) -> &'static str {
118 match self {
119 Self::Active => "active",
120 Self::Paused => "paused",
121 Self::Stopped => "stopped",
122 Self::Stale => "stale",
123 }
124 }
125}
126
127impl fmt::Display for DisplayStatus {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 f.write_str(self.as_str())
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct WorktreeEntry {
136 pub branch: String,
138 pub worktree_path: PathBuf,
140 pub cli: String,
142 #[serde(default)]
145 pub branch_created: bool,
146
147 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub pending_boot_prompt: Option<String>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[allow(clippy::struct_field_names)]
163pub struct Session {
164 pub session_name: String,
166 pub repo_path: PathBuf,
168 pub project_name: String,
170 #[serde(
172 serialize_with = "serialize_system_time",
173 deserialize_with = "deserialize_system_time"
174 )]
175 pub created_at: SystemTime,
176 pub status: SessionStatus,
178 pub worktrees: Vec<WorktreeEntry>,
180
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub broker_port: Option<u16>,
184
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub broker_bind: Option<String>,
188
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub broker_log_path: Option<PathBuf>,
192
193 #[serde(default)]
197 pub mode: SessionMode,
198
199 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub dashboard_pane: Option<u32>,
204}
205
206impl Session {
207 pub fn effective_status(&self, is_tmux_alive: impl Fn(&str) -> bool) -> SessionStatus {
214 match self.status {
215 SessionStatus::Active | SessionStatus::Paused if !is_tmux_alive(&self.session_name) => {
216 SessionStatus::Stopped
217 }
218 _ => self.status.clone(),
219 }
220 }
221
222 #[must_use]
226 pub fn created_at_iso8601(&self) -> Option<String> {
227 format_iso8601(self.created_at).ok()
228 }
229}
230
231pub fn save_session(session: &Session) -> Result<(), PawError> {
241 save_session_in(session, &sessions_dir()?)
242}
243
244pub fn find_session_for_repo(repo_path: &Path) -> Result<Option<Session>, PawError> {
249 find_session_for_repo_in(repo_path, &sessions_dir()?)
250}
251
252pub fn delete_session(session_name: &str) -> Result<(), PawError> {
256 delete_session_in(session_name, &sessions_dir()?)
257}
258
259pub fn save_session_in(session: &Session, dir: &Path) -> Result<(), PawError> {
265 fs::create_dir_all(dir)
266 .map_err(|e| PawError::SessionError(format!("failed to create sessions dir: {e}")))?;
267
268 let json = serde_json::to_string_pretty(session)
269 .map_err(|e| PawError::SessionError(format!("failed to serialize session: {e}")))?;
270
271 let final_path = dir.join(format!("{}.json", session.session_name));
272 let tmp_path = dir.join(format!("{}.tmp", session.session_name));
273
274 fs::write(&tmp_path, json.as_bytes())
275 .map_err(|e| PawError::SessionError(format!("failed to write temp file: {e}")))?;
276
277 fs::rename(&tmp_path, &final_path)
278 .map_err(|e| PawError::SessionError(format!("failed to rename temp file: {e}")))?;
279
280 Ok(())
281}
282
283pub fn load_session_from(session_name: &str, dir: &Path) -> Result<Option<Session>, PawError> {
285 let path = dir.join(format!("{session_name}.json"));
286
287 let contents = match fs::read_to_string(&path) {
288 Ok(s) => s,
289 Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
290 Err(e) => {
291 return Err(PawError::SessionError(format!(
292 "failed to read session file: {e}"
293 )));
294 }
295 };
296
297 let session: Session = serde_json::from_str(&contents)
298 .map_err(|e| PawError::SessionError(format!("failed to parse session file: {e}")))?;
299
300 Ok(Some(session))
301}
302
303pub fn find_session_for_repo_in(repo_path: &Path, dir: &Path) -> Result<Option<Session>, PawError> {
305 let entries = match fs::read_dir(dir) {
306 Ok(e) => e,
307 Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
308 Err(e) => {
309 return Err(PawError::SessionError(format!(
310 "failed to read sessions dir: {e}"
311 )));
312 }
313 };
314
315 for entry in entries {
316 let entry =
317 entry.map_err(|e| PawError::SessionError(format!("failed to read dir entry: {e}")))?;
318 let path = entry.path();
319
320 if path.extension().and_then(|e| e.to_str()) != Some("json") {
321 continue;
322 }
323
324 let contents = fs::read_to_string(&path).map_err(|e| {
325 PawError::SessionError(format!("failed to read {}: {e}", path.display()))
326 })?;
327
328 let session: Session = match serde_json::from_str(&contents) {
329 Ok(s) => s,
330 Err(_) => continue, };
332
333 if session.repo_path == repo_path {
334 return Ok(Some(session));
335 }
336 }
337
338 Ok(None)
339}
340
341pub fn load_all_sessions_in(dir: &Path) -> Result<Vec<Session>, PawError> {
347 let entries = match fs::read_dir(dir) {
348 Ok(e) => e,
349 Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
350 Err(e) => {
351 return Err(PawError::SessionError(format!(
352 "failed to read sessions dir: {e}"
353 )));
354 }
355 };
356
357 let mut out = Vec::new();
358 for entry in entries {
359 let entry =
360 entry.map_err(|e| PawError::SessionError(format!("failed to read dir entry: {e}")))?;
361 let path = entry.path();
362
363 if path.extension().and_then(|e| e.to_str()) != Some("json") {
364 continue;
365 }
366
367 let Ok(contents) = fs::read_to_string(&path) else {
368 continue;
369 };
370 if let Ok(session) = serde_json::from_str::<Session>(&contents) {
371 out.push(session);
372 }
373 }
374
375 Ok(out)
376}
377
378pub fn delete_session_in(session_name: &str, dir: &Path) -> Result<(), PawError> {
380 let path = dir.join(format!("{session_name}.json"));
381
382 match fs::remove_file(&path) {
383 Ok(()) => Ok(()),
384 Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
385 Err(e) => Err(PawError::SessionError(format!(
386 "failed to delete session file: {e}"
387 ))),
388 }
389}
390
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
408pub struct RepoAgentEntry {
409 pub branch_id: String,
411 pub worktree_path: PathBuf,
413 pub cli: String,
415 pub pane_index: usize,
417}
418
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422pub struct RepoSessionFile {
423 pub session_name: String,
425 pub agents: Vec<RepoAgentEntry>,
427}
428
429#[must_use]
431pub fn repo_sessions_dir(repo_root: &Path) -> PathBuf {
432 repo_root.join(".git-paw").join("sessions")
433}
434
435#[must_use]
437pub fn repo_session_path(repo_root: &Path, session_name: &str) -> PathBuf {
438 repo_sessions_dir(repo_root).join(format!("{session_name}.json"))
439}
440
441pub fn write_repo_session_file(repo_root: &Path, file: &RepoSessionFile) -> Result<(), PawError> {
447 let dir = repo_sessions_dir(repo_root);
448 fs::create_dir_all(&dir).map_err(|e| {
449 PawError::SessionError(format!("failed to create per-repo sessions dir: {e}"))
450 })?;
451
452 let json = serde_json::to_string_pretty(file).map_err(|e| {
453 PawError::SessionError(format!("failed to serialize per-repo session file: {e}"))
454 })?;
455
456 let final_path = dir.join(format!("{}.json", file.session_name));
457 let tmp_path = dir.join(format!("{}.tmp", file.session_name));
458 fs::write(&tmp_path, json.as_bytes()).map_err(|e| {
459 PawError::SessionError(format!("failed to write per-repo session temp file: {e}"))
460 })?;
461 fs::rename(&tmp_path, &final_path).map_err(|e| {
462 PawError::SessionError(format!("failed to rename per-repo session temp file: {e}"))
463 })?;
464 Ok(())
465}
466
467pub fn remove_repo_session_file(repo_root: &Path, session_name: &str) -> Result<(), PawError> {
470 let path = repo_session_path(repo_root, session_name);
471 match fs::remove_file(&path) {
472 Ok(()) => Ok(()),
473 Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
474 Err(e) => Err(PawError::SessionError(format!(
475 "failed to remove per-repo session file: {e}"
476 ))),
477 }
478}
479
480pub fn session_state_dir() -> Result<PathBuf, PawError> {
488 sessions_dir()
489}
490
491fn sessions_dir() -> Result<PathBuf, PawError> {
493 let base = crate::dirs::data_dir().ok_or_else(|| {
494 PawError::SessionError("could not determine XDG data directory".to_string())
495 })?;
496 Ok(base.join("git-paw").join("sessions"))
497}
498
499fn format_iso8601(time: SystemTime) -> Result<String, PawError> {
505 let secs = time
506 .duration_since(UNIX_EPOCH)
507 .map_err(|e| PawError::SessionError(format!("time before unix epoch: {e}")))?
508 .as_secs();
509
510 let (year, month, day, hour, min, sec) = secs_to_civil(secs);
511 Ok(format!(
512 "{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z"
513 ))
514}
515
516fn parse_iso8601(s: &str) -> Result<SystemTime, PawError> {
518 let err = || PawError::SessionError(format!("invalid ISO 8601 timestamp: {s}"));
519
520 let s = s.strip_suffix('Z').ok_or_else(err)?;
522 let (date, time) = s.split_once('T').ok_or_else(err)?;
523
524 let date_parts: Vec<&str> = date.split('-').collect();
525 let time_parts: Vec<&str> = time.split(':').collect();
526
527 if date_parts.len() != 3 || time_parts.len() != 3 {
528 return Err(err());
529 }
530
531 let year: u64 = date_parts[0].parse().map_err(|_| err())?;
532 let month: u64 = date_parts[1].parse().map_err(|_| err())?;
533 let day: u64 = date_parts[2].parse().map_err(|_| err())?;
534 let hour: u64 = time_parts[0].parse().map_err(|_| err())?;
535 let min: u64 = time_parts[1].parse().map_err(|_| err())?;
536 let sec: u64 = time_parts[2].parse().map_err(|_| err())?;
537
538 let secs = civil_to_secs(year, month, day, hour, min, sec).ok_or_else(err)?;
539 Ok(UNIX_EPOCH + Duration::from_secs(secs))
540}
541
542fn secs_to_civil(secs: u64) -> (u64, u64, u64, u64, u64, u64) {
544 let sec_of_day = secs % 86400;
545 let hour = sec_of_day / 3600;
546 let min = (sec_of_day % 3600) / 60;
547 let sec = sec_of_day % 60;
548
549 #[allow(clippy::cast_possible_wrap)]
552 let mut days = (secs / 86400).cast_signed();
553
554 days += 719_468; let era = days / 146_097;
556 let doe = days - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
558 let y = yoe + era * 400;
559 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1;
562 let m = if mp < 10 { mp + 3 } else { mp - 9 };
563 let y = if m <= 2 { y + 1 } else { y };
564
565 #[allow(clippy::cast_sign_loss)]
566 (
567 y.cast_unsigned(),
568 m.cast_unsigned(),
569 d.cast_unsigned(),
570 hour,
571 min,
572 sec,
573 )
574}
575
576fn civil_to_secs(year: u64, month: u64, day: u64, hour: u64, min: u64, sec: u64) -> Option<u64> {
578 if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || min > 59 || sec > 59 {
579 return None;
580 }
581
582 #[allow(clippy::cast_possible_wrap)]
583 let y = year.cast_signed();
584 #[allow(clippy::cast_possible_wrap)]
585 let m = month.cast_signed();
586 #[allow(clippy::cast_possible_wrap)]
587 let d = day.cast_signed();
588
589 let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
591 let era = y / 400;
592 let yoe = y - era * 400;
593 let doy = (153 * m + 2) / 5 + d - 1;
594 let doe = 365 * yoe + yoe / 4 - yoe / 100 + doy;
595 let days = era * 146_097 + doe - 719_468;
596
597 if days < 0 {
598 return None;
599 }
600
601 #[allow(clippy::cast_sign_loss)]
602 Some(days.cast_unsigned() * 86400 + hour * 3600 + min * 60 + sec)
603}
604
605fn serialize_system_time<S: Serializer>(time: &SystemTime, ser: S) -> Result<S::Ok, S::Error> {
610 let s = format_iso8601(*time).map_err(serde::ser::Error::custom)?;
611 ser.serialize_str(&s)
612}
613
614fn deserialize_system_time<'de, D: Deserializer<'de>>(de: D) -> Result<SystemTime, D::Error> {
615 let s: String = Deserialize::deserialize(de)?;
616 parse_iso8601(&s).map_err(serde::de::Error::custom)
617}
618
619#[cfg(test)]
624mod tests {
625 use super::*;
626 use tempfile::TempDir;
627
628 fn sample_session() -> Session {
630 Session {
631 session_name: "paw-my-project".to_string(),
632 repo_path: PathBuf::from("/Users/test/code/my-project"),
633 project_name: "my-project".to_string(),
634 #[allow(clippy::duration_suboptimal_units)]
638 created_at: UNIX_EPOCH + Duration::from_secs(1_711_200_000),
639 status: SessionStatus::Active,
640 worktrees: vec![
641 WorktreeEntry {
642 branch: "feature/auth".to_string(),
643 worktree_path: PathBuf::from("/Users/test/code/my-project-feature-auth"),
644 cli: "claude".to_string(),
645 branch_created: false,
646 pending_boot_prompt: None,
647 },
648 WorktreeEntry {
649 branch: "fix/api".to_string(),
650 worktree_path: PathBuf::from("/Users/test/code/my-project-fix-api"),
651 cli: "gemini".to_string(),
652 branch_created: false,
653 pending_boot_prompt: None,
654 },
655 WorktreeEntry {
656 branch: "feature/logging".to_string(),
657 worktree_path: PathBuf::from("/Users/test/code/my-project-feature-logging"),
658 cli: "claude".to_string(),
659 branch_created: false,
660 pending_boot_prompt: None,
661 },
662 ],
663 broker_port: None,
664 broker_bind: None,
665 broker_log_path: None,
666 mode: SessionMode::Bare,
667 dashboard_pane: None,
668 }
669 }
670
671 #[test]
675 fn saved_session_can_be_loaded_with_all_fields_intact() {
676 let dir = TempDir::new().unwrap();
677 let session = sample_session();
678 save_session_in(&session, dir.path()).unwrap();
679
680 let loaded = load_session_from("paw-my-project", dir.path())
681 .unwrap()
682 .expect("session should exist");
683
684 assert_eq!(loaded.session_name, "paw-my-project");
685 assert_eq!(
686 loaded.repo_path,
687 PathBuf::from("/Users/test/code/my-project")
688 );
689 assert_eq!(loaded.project_name, "my-project");
690 assert_eq!(loaded.created_at, session.created_at);
691 assert_eq!(loaded.status, SessionStatus::Active);
692 assert_eq!(loaded.worktrees.len(), 3);
693 assert_eq!(loaded.worktrees[0].branch, "feature/auth");
694 assert_eq!(loaded.worktrees[0].cli, "claude");
695 assert_eq!(loaded.worktrees[1].branch, "fix/api");
696 assert_eq!(loaded.worktrees[1].cli, "gemini");
697 assert_eq!(loaded.worktrees[2].branch, "feature/logging");
698 }
699
700 #[test]
703 fn saving_again_replaces_previous_state() {
704 let dir = TempDir::new().unwrap();
705 let mut session = sample_session();
706 save_session_in(&session, dir.path()).unwrap();
707
708 session.status = SessionStatus::Stopped;
709 session.worktrees.pop();
710 save_session_in(&session, dir.path()).unwrap();
711
712 let loaded = load_session_from("paw-my-project", dir.path())
713 .unwrap()
714 .expect("session should exist");
715
716 assert_eq!(loaded.status, SessionStatus::Stopped);
717 assert_eq!(loaded.worktrees.len(), 2);
718 }
719
720 #[test]
723 fn loading_nonexistent_session_returns_none() {
724 let dir = TempDir::new().unwrap();
725 let result = load_session_from("nonexistent", dir.path()).unwrap();
726 assert!(result.is_none());
727 }
728
729 #[test]
733 fn finds_correct_session_among_multiple_by_repo_path() {
734 let dir = TempDir::new().unwrap();
735
736 let mut session_a = sample_session();
737 session_a.session_name = "paw-project-a".to_string();
738 session_a.repo_path = PathBuf::from("/Users/test/code/project-a");
739
740 let mut session_b = sample_session();
741 session_b.session_name = "paw-project-b".to_string();
742 session_b.repo_path = PathBuf::from("/Users/test/code/project-b");
743
744 save_session_in(&session_a, dir.path()).unwrap();
745 save_session_in(&session_b, dir.path()).unwrap();
746
747 let found = find_session_for_repo_in(Path::new("/Users/test/code/project-b"), dir.path())
748 .unwrap()
749 .expect("should find session for project-b");
750
751 assert_eq!(found.session_name, "paw-project-b");
752 assert_eq!(found.repo_path, PathBuf::from("/Users/test/code/project-b"));
753 }
754
755 #[test]
756 fn find_returns_none_when_no_repo_matches() {
757 let dir = TempDir::new().unwrap();
758 save_session_in(&sample_session(), dir.path()).unwrap();
759
760 let found =
761 find_session_for_repo_in(Path::new("/Users/test/code/other-project"), dir.path())
762 .unwrap();
763 assert!(found.is_none());
764 }
765
766 #[test]
767 fn find_returns_none_when_no_sessions_exist() {
768 let dir = TempDir::new().unwrap();
769 let missing = dir.path().join("does-not-exist");
770 let found = find_session_for_repo_in(Path::new("/any"), &missing).unwrap();
771 assert!(found.is_none());
772 }
773
774 #[test]
777 fn deleted_session_is_no_longer_loadable() {
778 let dir = TempDir::new().unwrap();
779 save_session_in(&sample_session(), dir.path()).unwrap();
780
781 delete_session_in("paw-my-project", dir.path()).unwrap();
782
783 let loaded = load_session_from("paw-my-project", dir.path()).unwrap();
784 assert!(loaded.is_none());
785 }
786
787 #[test]
788 fn deleting_nonexistent_session_succeeds() {
789 let dir = TempDir::new().unwrap();
790 delete_session_in("nonexistent", dir.path()).unwrap();
791 }
792
793 #[test]
796 fn file_says_active_and_tmux_alive_means_active() {
797 let session = sample_session();
798 assert_eq!(session.effective_status(|_| true), SessionStatus::Active);
799 }
800
801 #[test]
802 fn file_says_active_but_tmux_dead_means_stopped() {
803 let session = sample_session();
804 assert_eq!(session.effective_status(|_| false), SessionStatus::Stopped);
805 }
806
807 #[test]
808 fn file_says_stopped_stays_stopped_regardless_of_tmux() {
809 let mut session = sample_session();
810 session.status = SessionStatus::Stopped;
811 assert_eq!(session.effective_status(|_| true), SessionStatus::Stopped);
813 }
814
815 #[test]
818 fn session_status_displays_as_lowercase_string() {
819 assert_eq!(SessionStatus::Active.to_string(), "active");
820 assert_eq!(SessionStatus::Paused.to_string(), "paused");
821 assert_eq!(SessionStatus::Stopped.to_string(), "stopped");
822 }
823
824 #[test]
828 fn display_status_active_receipt_alive_tmux_is_active() {
829 use crate::tmux::SessionLiveness;
830 let d = DisplayStatus::from_receipt(&SessionStatus::Active, SessionLiveness::Alive);
831 assert_eq!(d, DisplayStatus::Active);
832 assert_eq!(d.as_str(), "active");
833 assert_eq!(d.icon(), "\u{1f7e2}");
834 }
835
836 #[test]
837 fn display_status_active_receipt_stale_tmux_is_stale() {
838 use crate::tmux::SessionLiveness;
839 let d = DisplayStatus::from_receipt(&SessionStatus::Active, SessionLiveness::Stale);
840 assert_eq!(d, DisplayStatus::Stale);
841 assert_eq!(d.as_str(), "stale");
842 assert_eq!(d.icon(), "\u{1f534}");
843 }
844
845 #[test]
846 fn display_status_stopped_receipt_is_stopped_regardless_of_tmux() {
847 use crate::tmux::SessionLiveness;
848 for liveness in [
849 SessionLiveness::Alive,
850 SessionLiveness::Stale,
851 SessionLiveness::Indeterminate,
852 ] {
853 let d = DisplayStatus::from_receipt(&SessionStatus::Stopped, liveness);
854 assert_eq!(d, DisplayStatus::Stopped, "liveness {liveness:?}");
855 assert_eq!(d.as_str(), "stopped");
856 }
857 }
858
859 #[test]
860 fn display_status_indeterminate_never_reports_stale() {
861 use crate::tmux::SessionLiveness;
862 let d = DisplayStatus::from_receipt(&SessionStatus::Active, SessionLiveness::Indeterminate);
865 assert_ne!(d, DisplayStatus::Stale);
866 assert_eq!(d, DisplayStatus::Stopped);
867 }
868
869 #[test]
870 fn display_status_paused_alive_is_paused_dead_is_stopped() {
871 use crate::tmux::SessionLiveness;
872 assert_eq!(
873 DisplayStatus::from_receipt(&SessionStatus::Paused, SessionLiveness::Alive),
874 DisplayStatus::Paused
875 );
876 assert_eq!(
877 DisplayStatus::from_receipt(&SessionStatus::Paused, SessionLiveness::Stale),
878 DisplayStatus::Stopped
879 );
880 }
881
882 #[test]
885 fn paused_status_serializes_lowercase() {
886 let dir = TempDir::new().unwrap();
887 let mut session = sample_session();
888 session.status = SessionStatus::Paused;
889 save_session_in(&session, dir.path()).unwrap();
890
891 let json = std::fs::read_to_string(dir.path().join("paw-my-project.json")).unwrap();
892 assert!(
893 json.contains("\"status\": \"paused\""),
894 "JSON should contain `\"status\": \"paused\"`, got: {json}"
895 );
896 }
897
898 #[test]
899 fn paused_session_round_trips() {
900 let dir = TempDir::new().unwrap();
901 let mut session = sample_session();
902 session.status = SessionStatus::Paused;
903 save_session_in(&session, dir.path()).unwrap();
904
905 let loaded = load_session_from("paw-my-project", dir.path())
906 .unwrap()
907 .expect("session should exist");
908 assert_eq!(loaded.status, SessionStatus::Paused);
909 }
910
911 #[test]
912 fn effective_status_paused_alive_remains_paused() {
913 let mut session = sample_session();
914 session.status = SessionStatus::Paused;
915 assert_eq!(session.effective_status(|_| true), SessionStatus::Paused);
916 }
917
918 #[test]
919 fn effective_status_paused_dead_downgrades_to_stopped() {
920 let mut session = sample_session();
921 session.status = SessionStatus::Paused;
922 assert_eq!(session.effective_status(|_| false), SessionStatus::Stopped);
923 }
924
925 #[test]
928 fn dashboard_pane_round_trips() {
929 let dir = TempDir::new().unwrap();
930 let mut session = sample_session();
931 session.dashboard_pane = Some(1);
932 save_session_in(&session, dir.path()).unwrap();
933
934 let loaded = load_session_from("paw-my-project", dir.path())
935 .unwrap()
936 .expect("session should exist");
937 assert_eq!(loaded.dashboard_pane, Some(1));
938 }
939
940 #[test]
941 fn v04_session_without_dashboard_pane_loads_as_none() {
942 let dir = TempDir::new().unwrap();
943 let json = r#"{
944 "session_name": "paw-legacy-dashboard",
945 "repo_path": "/tmp/legacy-repo",
946 "project_name": "legacy",
947 "created_at": "2024-03-23T12:00:00Z",
948 "status": "active",
949 "worktrees": []
950 }"#;
951 std::fs::write(dir.path().join("paw-legacy-dashboard.json"), json).unwrap();
952
953 let loaded = load_session_from("paw-legacy-dashboard", dir.path())
954 .unwrap()
955 .expect("session should load");
956 assert!(
957 loaded.dashboard_pane.is_none(),
958 "v0.4 session should load with dashboard_pane = None"
959 );
960 }
961
962 #[test]
963 fn dashboard_pane_none_is_omitted_from_json() {
964 let dir = TempDir::new().unwrap();
965 let session = sample_session(); save_session_in(&session, dir.path()).unwrap();
967
968 let json = std::fs::read_to_string(dir.path().join("paw-my-project.json")).unwrap();
969 assert!(
970 !json.contains("dashboard_pane"),
971 "JSON should not include dashboard_pane when None, got: {json}"
972 );
973 }
974
975 #[test]
980 fn session_with_broker_fields_round_trips() {
981 let dir = TempDir::new().unwrap();
982 let mut session = sample_session();
983 session.broker_port = Some(9119);
984 session.broker_bind = Some("127.0.0.1".to_string());
985 session.broker_log_path = Some(PathBuf::from("/tmp/broker.log"));
986
987 save_session_in(&session, dir.path()).unwrap();
988
989 let loaded = load_session_from("paw-my-project", dir.path())
990 .unwrap()
991 .expect("session should exist");
992
993 assert_eq!(loaded.broker_port, Some(9119));
994 assert_eq!(loaded.broker_bind.as_deref(), Some("127.0.0.1"));
995 assert_eq!(
996 loaded.broker_log_path,
997 Some(PathBuf::from("/tmp/broker.log"))
998 );
999 }
1000
1001 #[test]
1002 fn v020_session_json_loads_with_broker_fields_as_none() {
1003 let dir = TempDir::new().unwrap();
1004 let json = r#"{
1006 "session_name": "paw-legacy",
1007 "repo_path": "/tmp/legacy-repo",
1008 "project_name": "legacy",
1009 "created_at": "2024-03-23T12:00:00Z",
1010 "status": "active",
1011 "worktrees": []
1012 }"#;
1013 std::fs::write(dir.path().join("paw-legacy.json"), json).unwrap();
1014
1015 let loaded = load_session_from("paw-legacy", dir.path())
1016 .unwrap()
1017 .expect("session should load");
1018
1019 assert!(loaded.broker_port.is_none());
1020 assert!(loaded.broker_bind.is_none());
1021 assert!(loaded.broker_log_path.is_none());
1022 assert_eq!(loaded.session_name, "paw-legacy");
1023 }
1024
1025 #[test]
1026 fn session_with_broker_fields_serializes_them() {
1027 let dir = TempDir::new().unwrap();
1028 let mut session = sample_session();
1029 session.broker_port = Some(9119);
1030 session.broker_bind = Some("127.0.0.1".to_string());
1031 session.broker_log_path = Some(PathBuf::from("/tmp/broker.log"));
1032 save_session_in(&session, dir.path()).unwrap();
1033
1034 let json = std::fs::read_to_string(dir.path().join("paw-my-project.json")).unwrap();
1035 assert!(
1036 json.contains("broker_port"),
1037 "JSON should contain broker_port"
1038 );
1039 assert!(
1040 json.contains("broker_bind"),
1041 "JSON should contain broker_bind"
1042 );
1043 assert!(
1044 json.contains("broker_log_path"),
1045 "JSON should contain broker_log_path"
1046 );
1047 }
1048
1049 #[test]
1050 fn session_without_broker_fields_omits_them_from_json() {
1051 let dir = TempDir::new().unwrap();
1052 let session = sample_session(); save_session_in(&session, dir.path()).unwrap();
1054
1055 let json = std::fs::read_to_string(dir.path().join("paw-my-project.json")).unwrap();
1056 assert!(
1057 !json.contains("broker_port"),
1058 "JSON should not contain broker_port when None"
1059 );
1060 assert!(
1061 !json.contains("broker_bind"),
1062 "JSON should not contain broker_bind when None"
1063 );
1064 assert!(
1065 !json.contains("broker_log_path"),
1066 "JSON should not contain broker_log_path when None"
1067 );
1068 }
1069
1070 #[test]
1073 fn recovery_after_tmux_crash_has_all_data_to_reconstruct() {
1074 let dir = TempDir::new().unwrap();
1075 let session = sample_session();
1076 save_session_in(&session, dir.path()).unwrap();
1077
1078 let recovered = load_session_from("paw-my-project", dir.path())
1080 .unwrap()
1081 .expect("session state should survive tmux crash");
1082
1083 assert_eq!(recovered.session_name, "paw-my-project");
1085 assert_eq!(
1087 recovered.repo_path,
1088 PathBuf::from("/Users/test/code/my-project")
1089 );
1090 assert_eq!(recovered.worktrees.len(), 3);
1092 for wt in &recovered.worktrees {
1093 assert!(!wt.branch.is_empty());
1094 assert!(!wt.worktree_path.as_os_str().is_empty());
1095 assert!(!wt.cli.is_empty());
1096 }
1097 assert_eq!(
1099 recovered.effective_status(|_| false),
1100 SessionStatus::Stopped
1101 );
1102 }
1103
1104 #[test]
1107 fn session_with_broker_enabled_has_recovery_data() {
1108 let dir = TempDir::new().unwrap();
1109 let mut session = sample_session();
1110 session.broker_port = Some(9119);
1111 session.broker_bind = Some("127.0.0.1".to_string());
1112 save_session_in(&session, dir.path()).unwrap();
1113
1114 let recovered = load_session_from("paw-my-project", dir.path())
1115 .unwrap()
1116 .expect("session should load");
1117
1118 assert_eq!(recovered.broker_port, Some(9119));
1120 assert_eq!(recovered.broker_bind.as_deref(), Some("127.0.0.1"));
1121 }
1122
1123 #[test]
1124 fn session_without_broker_has_no_recovery_data() {
1125 let dir = TempDir::new().unwrap();
1126 let session = sample_session(); save_session_in(&session, dir.path()).unwrap();
1128
1129 let recovered = load_session_from("paw-my-project", dir.path())
1130 .unwrap()
1131 .expect("session should load");
1132
1133 assert!(recovered.broker_port.is_none());
1135 assert!(recovered.broker_bind.is_none());
1136 }
1137
1138 fn sample_repo_file() -> RepoSessionFile {
1143 RepoSessionFile {
1144 session_name: "paw-my-project".to_string(),
1145 agents: vec![
1146 RepoAgentEntry {
1147 branch_id: "feat-add-auth".to_string(),
1148 worktree_path: PathBuf::from("/repo-feat-add-auth"),
1149 cli: "claude".to_string(),
1150 pane_index: 2,
1151 },
1152 RepoAgentEntry {
1153 branch_id: "feat-fix-db".to_string(),
1154 worktree_path: PathBuf::from("/repo-feat-fix-db"),
1155 cli: "gemini".to_string(),
1156 pane_index: 3,
1157 },
1158 ],
1159 }
1160 }
1161
1162 #[test]
1163 fn write_repo_session_file_writes_sweep_compatible_shape() {
1164 let repo = TempDir::new().expect("repo");
1165 let file = sample_repo_file();
1166 write_repo_session_file(repo.path(), &file).expect("write");
1167
1168 let path = repo_session_path(repo.path(), "paw-my-project");
1170 assert_eq!(
1171 path,
1172 repo.path()
1173 .join(".git-paw")
1174 .join("sessions")
1175 .join("paw-my-project.json")
1176 );
1177 assert!(path.exists(), "discovery file should exist");
1178
1179 let raw = fs::read_to_string(&path).expect("read");
1181 let parsed: serde_json::Value = serde_json::from_str(&raw).expect("json");
1182 assert_eq!(parsed["session_name"], "paw-my-project");
1183 let agents = parsed["agents"].as_array().expect("agents array");
1184 assert_eq!(agents.len(), 2);
1185 assert_eq!(agents[0]["branch_id"], "feat-add-auth");
1186 assert_eq!(agents[0]["worktree_path"], "/repo-feat-add-auth");
1187 assert_eq!(agents[0]["cli"], "claude");
1188 assert_eq!(agents[0]["pane_index"], 2);
1189 }
1190
1191 #[test]
1192 fn remove_repo_session_file_is_idempotent() {
1193 let repo = TempDir::new().expect("repo");
1194 remove_repo_session_file(repo.path(), "paw-my-project").expect("remove-missing");
1196
1197 write_repo_session_file(repo.path(), &sample_repo_file()).expect("write");
1198 let path = repo_session_path(repo.path(), "paw-my-project");
1199 assert!(path.exists());
1200
1201 remove_repo_session_file(repo.path(), "paw-my-project").expect("remove");
1202 assert!(
1203 !path.exists(),
1204 "discovery file should be removed by purge path"
1205 );
1206 }
1207}