Skip to main content

git_paw/
session.rs

1//! Session state persistence.
2//!
3//! Saves and loads session data to disk for recovery after crashes, reboots,
4//! or `stop`. One session per repository, stored as JSON under the XDG data
5//! directory (`~/.local/share/git-paw/sessions/`).
6
7use 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// ---------------------------------------------------------------------------
18// Types
19// ---------------------------------------------------------------------------
20
21/// Status of a persisted session.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum SessionStatus {
25    /// Tmux session is (believed to be) running.
26    Active,
27    /// Soft-stopped: tmux session and CLI panes remain running, but the
28    /// user's client is detached and the broker has been shut down. A
29    /// subsequent `git paw start` re-attaches and restarts the broker
30    /// without respawning any CLI processes.
31    Paused,
32    /// Tmux session has been stopped or crashed; state is recoverable.
33    Stopped,
34}
35
36/// Pane-layout shape used when the session was created. Drives recovery so
37/// `recover_session` rebuilds with the same layout it was launched with.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
39#[serde(rename_all = "lowercase")]
40pub enum SessionMode {
41    /// Bare-start mode: dashboard at pane 0 (when broker enabled), agents at
42    /// pane 1+. Same as v0.4.
43    #[default]
44    Bare,
45    /// Supervisor-as-pane mode (v0.5.0+): supervisor at pane 0, dashboard at
46    /// pane 1, agents at pane 2+.
47    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/// A worktree entry within a session.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct WorktreeEntry {
63    /// The branch checked out in this worktree.
64    pub branch: String,
65    /// Absolute path to the worktree directory.
66    pub worktree_path: PathBuf,
67    /// The AI CLI assigned to this worktree.
68    pub cli: String,
69    /// Whether git-paw created this branch (vs. it already existing).
70    /// When `true`, `purge` will delete the branch after removing the worktree.
71    #[serde(default)]
72    pub branch_created: bool,
73}
74
75/// Persisted session state for a git-paw session.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[allow(clippy::struct_field_names)]
78pub struct Session {
79    /// Tmux session name (also used as the filename stem).
80    pub session_name: String,
81    /// Absolute path to the repository root.
82    pub repo_path: PathBuf,
83    /// Human-readable project name (derived from the repo directory name).
84    pub project_name: String,
85    /// ISO 8601 timestamp of session creation (UTC).
86    #[serde(
87        serialize_with = "serialize_system_time",
88        deserialize_with = "deserialize_system_time"
89    )]
90    pub created_at: SystemTime,
91    /// Current session status.
92    pub status: SessionStatus,
93    /// Worktrees managed by this session.
94    pub worktrees: Vec<WorktreeEntry>,
95
96    /// Broker port (when broker is enabled).
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub broker_port: Option<u16>,
99
100    /// Broker bind address (when broker is enabled).
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub broker_bind: Option<String>,
103
104    /// Path to the broker log file (when broker is enabled).
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub broker_log_path: Option<PathBuf>,
107
108    /// Pane-layout shape this session was launched with. Missing on sessions
109    /// saved by v0.4 binaries, in which case [`SessionMode::Bare`] is assumed
110    /// for backwards compatibility.
111    #[serde(default)]
112    pub mode: SessionMode,
113
114    /// Pane index of the dashboard pane (when broker is enabled). Used by the
115    /// restart-from-pause flow to recreate the dashboard pane in its original
116    /// position. `None` on v0.4-saved sessions; consumers SHALL default to `0`.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub dashboard_pane: Option<u32>,
119}
120
121impl Session {
122    /// Returns the effective status by combining the on-disk status with a
123    /// tmux liveness check.
124    ///
125    /// `Active` or `Paused` downgrade to `Stopped` when tmux is not alive
126    /// (a paused session whose tmux server died has no live CLI panes to
127    /// resume into). `Stopped` is unchanged regardless of tmux liveness.
128    pub fn effective_status(&self, is_tmux_alive: impl Fn(&str) -> bool) -> SessionStatus {
129        match self.status {
130            SessionStatus::Active | SessionStatus::Paused if !is_tmux_alive(&self.session_name) => {
131                SessionStatus::Stopped
132            }
133            _ => self.status.clone(),
134        }
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Public API
140// ---------------------------------------------------------------------------
141
142/// Atomically writes a session to disk.
143///
144/// Serializes the session to JSON, writes to a temporary file in the same
145/// directory, then renames to the final path. This prevents corruption if
146/// the process is killed mid-write.
147pub fn save_session(session: &Session) -> Result<(), PawError> {
148    save_session_in(session, &sessions_dir()?)
149}
150
151/// Finds the session associated with a given repository path.
152///
153/// Scans all `.json` files in the sessions directory and returns the first
154/// session whose `repo_path` matches the given path.
155pub fn find_session_for_repo(repo_path: &Path) -> Result<Option<Session>, PawError> {
156    find_session_for_repo_in(repo_path, &sessions_dir()?)
157}
158
159/// Deletes a session file by name.
160///
161/// Returns `Ok(())` even if the file does not exist (idempotent).
162pub fn delete_session(session_name: &str) -> Result<(), PawError> {
163    delete_session_in(session_name, &sessions_dir()?)
164}
165
166// ---------------------------------------------------------------------------
167// Directory-parameterized implementations (public for integration tests)
168// ---------------------------------------------------------------------------
169
170/// Atomically writes a session to the given directory.
171pub fn save_session_in(session: &Session, dir: &Path) -> Result<(), PawError> {
172    fs::create_dir_all(dir)
173        .map_err(|e| PawError::SessionError(format!("failed to create sessions dir: {e}")))?;
174
175    let json = serde_json::to_string_pretty(session)
176        .map_err(|e| PawError::SessionError(format!("failed to serialize session: {e}")))?;
177
178    let final_path = dir.join(format!("{}.json", session.session_name));
179    let tmp_path = dir.join(format!("{}.tmp", session.session_name));
180
181    fs::write(&tmp_path, json.as_bytes())
182        .map_err(|e| PawError::SessionError(format!("failed to write temp file: {e}")))?;
183
184    fs::rename(&tmp_path, &final_path)
185        .map_err(|e| PawError::SessionError(format!("failed to rename temp file: {e}")))?;
186
187    Ok(())
188}
189
190/// Loads a session by name from the given directory.
191pub fn load_session_from(session_name: &str, dir: &Path) -> Result<Option<Session>, PawError> {
192    let path = dir.join(format!("{session_name}.json"));
193
194    let contents = match fs::read_to_string(&path) {
195        Ok(s) => s,
196        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
197        Err(e) => {
198            return Err(PawError::SessionError(format!(
199                "failed to read session file: {e}"
200            )));
201        }
202    };
203
204    let session: Session = serde_json::from_str(&contents)
205        .map_err(|e| PawError::SessionError(format!("failed to parse session file: {e}")))?;
206
207    Ok(Some(session))
208}
209
210/// Finds the session for a repo path, scanning the given directory.
211pub fn find_session_for_repo_in(repo_path: &Path, dir: &Path) -> Result<Option<Session>, PawError> {
212    let entries = match fs::read_dir(dir) {
213        Ok(e) => e,
214        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
215        Err(e) => {
216            return Err(PawError::SessionError(format!(
217                "failed to read sessions dir: {e}"
218            )));
219        }
220    };
221
222    for entry in entries {
223        let entry =
224            entry.map_err(|e| PawError::SessionError(format!("failed to read dir entry: {e}")))?;
225        let path = entry.path();
226
227        if path.extension().and_then(|e| e.to_str()) != Some("json") {
228            continue;
229        }
230
231        let contents = fs::read_to_string(&path).map_err(|e| {
232            PawError::SessionError(format!("failed to read {}: {e}", path.display()))
233        })?;
234
235        let session: Session = match serde_json::from_str(&contents) {
236            Ok(s) => s,
237            Err(_) => continue, // skip malformed files
238        };
239
240        if session.repo_path == repo_path {
241            return Ok(Some(session));
242        }
243    }
244
245    Ok(None)
246}
247
248/// Deletes a session file by name from the given directory.
249pub fn delete_session_in(session_name: &str, dir: &Path) -> Result<(), PawError> {
250    let path = dir.join(format!("{session_name}.json"));
251
252    match fs::remove_file(&path) {
253        Ok(()) => Ok(()),
254        Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
255        Err(e) => Err(PawError::SessionError(format!(
256            "failed to delete session file: {e}"
257        ))),
258    }
259}
260
261// ---------------------------------------------------------------------------
262// Path helpers
263// ---------------------------------------------------------------------------
264
265/// Returns the sessions directory (`~/.local/share/git-paw/sessions/`).
266///
267/// Also used by the broker to place `broker.log` alongside session state.
268pub fn session_state_dir() -> Result<PathBuf, PawError> {
269    sessions_dir()
270}
271
272/// Returns the sessions directory (`~/.local/share/git-paw/sessions/`).
273fn sessions_dir() -> Result<PathBuf, PawError> {
274    let base = crate::dirs::data_dir().ok_or_else(|| {
275        PawError::SessionError("could not determine XDG data directory".to_string())
276    })?;
277    Ok(base.join("git-paw").join("sessions"))
278}
279
280// ---------------------------------------------------------------------------
281// ISO 8601 helpers
282// ---------------------------------------------------------------------------
283
284/// Formats a `SystemTime` as an ISO 8601 UTC string (`YYYY-MM-DDTHH:MM:SSZ`).
285fn format_iso8601(time: SystemTime) -> Result<String, PawError> {
286    let secs = time
287        .duration_since(UNIX_EPOCH)
288        .map_err(|e| PawError::SessionError(format!("time before unix epoch: {e}")))?
289        .as_secs();
290
291    let (year, month, day, hour, min, sec) = secs_to_civil(secs);
292    Ok(format!(
293        "{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z"
294    ))
295}
296
297/// Parses an ISO 8601 UTC string (`YYYY-MM-DDTHH:MM:SSZ`) into a `SystemTime`.
298fn parse_iso8601(s: &str) -> Result<SystemTime, PawError> {
299    let err = || PawError::SessionError(format!("invalid ISO 8601 timestamp: {s}"));
300
301    // Expected format: YYYY-MM-DDTHH:MM:SSZ
302    let s = s.strip_suffix('Z').ok_or_else(err)?;
303    let (date, time) = s.split_once('T').ok_or_else(err)?;
304
305    let date_parts: Vec<&str> = date.split('-').collect();
306    let time_parts: Vec<&str> = time.split(':').collect();
307
308    if date_parts.len() != 3 || time_parts.len() != 3 {
309        return Err(err());
310    }
311
312    let year: u64 = date_parts[0].parse().map_err(|_| err())?;
313    let month: u64 = date_parts[1].parse().map_err(|_| err())?;
314    let day: u64 = date_parts[2].parse().map_err(|_| err())?;
315    let hour: u64 = time_parts[0].parse().map_err(|_| err())?;
316    let min: u64 = time_parts[1].parse().map_err(|_| err())?;
317    let sec: u64 = time_parts[2].parse().map_err(|_| err())?;
318
319    let secs = civil_to_secs(year, month, day, hour, min, sec).ok_or_else(err)?;
320    Ok(UNIX_EPOCH + Duration::from_secs(secs))
321}
322
323/// Converts seconds since Unix epoch to (year, month, day, hour, minute, second) in UTC.
324fn secs_to_civil(secs: u64) -> (u64, u64, u64, u64, u64, u64) {
325    let sec_of_day = secs % 86400;
326    let hour = sec_of_day / 3600;
327    let min = (sec_of_day % 3600) / 60;
328    let sec = sec_of_day % 60;
329
330    // Days since epoch (1970-01-01)
331    // Algorithm from Howard Hinnant's chrono-compatible date library.
332    #[allow(clippy::cast_possible_wrap)]
333    let mut days = (secs / 86400).cast_signed();
334
335    days += 719_468; // shift epoch from 1970-01-01 to 0000-03-01
336    let era = days / 146_097;
337    let doe = days - era * 146_097; // day of era [0, 146096]
338    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
339    let y = yoe + era * 400;
340    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year [0, 365]
341    let mp = (5 * doy + 2) / 153; // month index [0, 11]
342    let d = doy - (153 * mp + 2) / 5 + 1;
343    let m = if mp < 10 { mp + 3 } else { mp - 9 };
344    let y = if m <= 2 { y + 1 } else { y };
345
346    #[allow(clippy::cast_sign_loss)]
347    (
348        y.cast_unsigned(),
349        m.cast_unsigned(),
350        d.cast_unsigned(),
351        hour,
352        min,
353        sec,
354    )
355}
356
357/// Converts (year, month, day, hour, min, sec) to seconds since Unix epoch.
358fn civil_to_secs(year: u64, month: u64, day: u64, hour: u64, min: u64, sec: u64) -> Option<u64> {
359    if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || min > 59 || sec > 59 {
360        return None;
361    }
362
363    #[allow(clippy::cast_possible_wrap)]
364    let y = year.cast_signed();
365    #[allow(clippy::cast_possible_wrap)]
366    let m = month.cast_signed();
367    #[allow(clippy::cast_possible_wrap)]
368    let d = day.cast_signed();
369
370    // Shift to March-based year
371    let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
372    let era = y / 400;
373    let yoe = y - era * 400;
374    let doy = (153 * m + 2) / 5 + d - 1;
375    let doe = 365 * yoe + yoe / 4 - yoe / 100 + doy;
376    let days = era * 146_097 + doe - 719_468;
377
378    if days < 0 {
379        return None;
380    }
381
382    #[allow(clippy::cast_sign_loss)]
383    Some(days.cast_unsigned() * 86400 + hour * 3600 + min * 60 + sec)
384}
385
386// ---------------------------------------------------------------------------
387// Serde helpers for SystemTime ↔ ISO 8601
388// ---------------------------------------------------------------------------
389
390fn serialize_system_time<S: Serializer>(time: &SystemTime, ser: S) -> Result<S::Ok, S::Error> {
391    let s = format_iso8601(*time).map_err(serde::ser::Error::custom)?;
392    ser.serialize_str(&s)
393}
394
395fn deserialize_system_time<'de, D: Deserializer<'de>>(de: D) -> Result<SystemTime, D::Error> {
396    let s: String = Deserialize::deserialize(de)?;
397    parse_iso8601(&s).map_err(serde::de::Error::custom)
398}
399
400// ---------------------------------------------------------------------------
401// Tests
402// ---------------------------------------------------------------------------
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use tempfile::TempDir;
408
409    /// Creates a sample session with 3 worktrees for testing.
410    fn sample_session() -> Session {
411        Session {
412            session_name: "paw-my-project".to_string(),
413            repo_path: PathBuf::from("/Users/test/code/my-project"),
414            project_name: "my-project".to_string(),
415            // Fixed unix epoch (2024-03-23 13:20:00 UTC); seconds is the
416            // canonical unit for unix timestamps so the literal stays
417            // human-readable as a date.
418            #[allow(clippy::duration_suboptimal_units)]
419            created_at: UNIX_EPOCH + Duration::from_secs(1_711_200_000),
420            status: SessionStatus::Active,
421            worktrees: vec![
422                WorktreeEntry {
423                    branch: "feature/auth".to_string(),
424                    worktree_path: PathBuf::from("/Users/test/code/my-project-feature-auth"),
425                    cli: "claude".to_string(),
426                    branch_created: false,
427                },
428                WorktreeEntry {
429                    branch: "fix/api".to_string(),
430                    worktree_path: PathBuf::from("/Users/test/code/my-project-fix-api"),
431                    cli: "gemini".to_string(),
432                    branch_created: false,
433                },
434                WorktreeEntry {
435                    branch: "feature/logging".to_string(),
436                    worktree_path: PathBuf::from("/Users/test/code/my-project-feature-logging"),
437                    cli: "claude".to_string(),
438                    branch_created: false,
439                },
440            ],
441            broker_port: None,
442            broker_bind: None,
443            broker_log_path: None,
444            mode: SessionMode::Bare,
445            dashboard_pane: None,
446        }
447    }
448
449    // -- save_session: GIVEN an active session with 3 worktrees,
450    //    WHEN save_session() is called, THEN JSON file created with all fields --
451
452    #[test]
453    fn saved_session_can_be_loaded_with_all_fields_intact() {
454        let dir = TempDir::new().unwrap();
455        let session = sample_session();
456        save_session_in(&session, dir.path()).unwrap();
457
458        let loaded = load_session_from("paw-my-project", dir.path())
459            .unwrap()
460            .expect("session should exist");
461
462        assert_eq!(loaded.session_name, "paw-my-project");
463        assert_eq!(
464            loaded.repo_path,
465            PathBuf::from("/Users/test/code/my-project")
466        );
467        assert_eq!(loaded.project_name, "my-project");
468        assert_eq!(loaded.created_at, session.created_at);
469        assert_eq!(loaded.status, SessionStatus::Active);
470        assert_eq!(loaded.worktrees.len(), 3);
471        assert_eq!(loaded.worktrees[0].branch, "feature/auth");
472        assert_eq!(loaded.worktrees[0].cli, "claude");
473        assert_eq!(loaded.worktrees[1].branch, "fix/api");
474        assert_eq!(loaded.worktrees[1].cli, "gemini");
475        assert_eq!(loaded.worktrees[2].branch, "feature/logging");
476    }
477
478    // -- save_session: saving again replaces the previous state --
479
480    #[test]
481    fn saving_again_replaces_previous_state() {
482        let dir = TempDir::new().unwrap();
483        let mut session = sample_session();
484        save_session_in(&session, dir.path()).unwrap();
485
486        session.status = SessionStatus::Stopped;
487        session.worktrees.pop();
488        save_session_in(&session, dir.path()).unwrap();
489
490        let loaded = load_session_from("paw-my-project", dir.path())
491            .unwrap()
492            .expect("session should exist");
493
494        assert_eq!(loaded.status, SessionStatus::Stopped);
495        assert_eq!(loaded.worktrees.len(), 2);
496    }
497
498    // -- load_session: WHEN load_session("nonexistent") is called, THEN returns None --
499
500    #[test]
501    fn loading_nonexistent_session_returns_none() {
502        let dir = TempDir::new().unwrap();
503        let result = load_session_from("nonexistent", dir.path()).unwrap();
504        assert!(result.is_none());
505    }
506
507    // -- find_session_for_repo: GIVEN two sessions,
508    //    WHEN find_session_for_repo is called, THEN returns the matching one --
509
510    #[test]
511    fn finds_correct_session_among_multiple_by_repo_path() {
512        let dir = TempDir::new().unwrap();
513
514        let mut session_a = sample_session();
515        session_a.session_name = "paw-project-a".to_string();
516        session_a.repo_path = PathBuf::from("/Users/test/code/project-a");
517
518        let mut session_b = sample_session();
519        session_b.session_name = "paw-project-b".to_string();
520        session_b.repo_path = PathBuf::from("/Users/test/code/project-b");
521
522        save_session_in(&session_a, dir.path()).unwrap();
523        save_session_in(&session_b, dir.path()).unwrap();
524
525        let found = find_session_for_repo_in(Path::new("/Users/test/code/project-b"), dir.path())
526            .unwrap()
527            .expect("should find session for project-b");
528
529        assert_eq!(found.session_name, "paw-project-b");
530        assert_eq!(found.repo_path, PathBuf::from("/Users/test/code/project-b"));
531    }
532
533    #[test]
534    fn find_returns_none_when_no_repo_matches() {
535        let dir = TempDir::new().unwrap();
536        save_session_in(&sample_session(), dir.path()).unwrap();
537
538        let found =
539            find_session_for_repo_in(Path::new("/Users/test/code/other-project"), dir.path())
540                .unwrap();
541        assert!(found.is_none());
542    }
543
544    #[test]
545    fn find_returns_none_when_no_sessions_exist() {
546        let dir = TempDir::new().unwrap();
547        let missing = dir.path().join("does-not-exist");
548        let found = find_session_for_repo_in(Path::new("/any"), &missing).unwrap();
549        assert!(found.is_none());
550    }
551
552    // -- delete_session: removes file, load returns None afterwards --
553
554    #[test]
555    fn deleted_session_is_no_longer_loadable() {
556        let dir = TempDir::new().unwrap();
557        save_session_in(&sample_session(), dir.path()).unwrap();
558
559        delete_session_in("paw-my-project", dir.path()).unwrap();
560
561        let loaded = load_session_from("paw-my-project", dir.path()).unwrap();
562        assert!(loaded.is_none());
563    }
564
565    #[test]
566    fn deleting_nonexistent_session_succeeds() {
567        let dir = TempDir::new().unwrap();
568        delete_session_in("nonexistent", dir.path()).unwrap();
569    }
570
571    // -- Status check: combines file existence + tmux liveness --
572
573    #[test]
574    fn file_says_active_and_tmux_alive_means_active() {
575        let session = sample_session();
576        assert_eq!(session.effective_status(|_| true), SessionStatus::Active);
577    }
578
579    #[test]
580    fn file_says_active_but_tmux_dead_means_stopped() {
581        let session = sample_session();
582        assert_eq!(session.effective_status(|_| false), SessionStatus::Stopped);
583    }
584
585    #[test]
586    fn file_says_stopped_stays_stopped_regardless_of_tmux() {
587        let mut session = sample_session();
588        session.status = SessionStatus::Stopped;
589        // Even if tmux is somehow alive, stopped means stopped.
590        assert_eq!(session.effective_status(|_| true), SessionStatus::Stopped);
591    }
592
593    // -- SessionStatus Display --
594
595    #[test]
596    fn session_status_displays_as_lowercase_string() {
597        assert_eq!(SessionStatus::Active.to_string(), "active");
598        assert_eq!(SessionStatus::Paused.to_string(), "paused");
599        assert_eq!(SessionStatus::Stopped.to_string(), "stopped");
600    }
601
602    // -- Paused variant: round-trip + effective_status --
603
604    #[test]
605    fn paused_status_serializes_lowercase() {
606        let dir = TempDir::new().unwrap();
607        let mut session = sample_session();
608        session.status = SessionStatus::Paused;
609        save_session_in(&session, dir.path()).unwrap();
610
611        let json = std::fs::read_to_string(dir.path().join("paw-my-project.json")).unwrap();
612        assert!(
613            json.contains("\"status\": \"paused\""),
614            "JSON should contain `\"status\": \"paused\"`, got: {json}"
615        );
616    }
617
618    #[test]
619    fn paused_session_round_trips() {
620        let dir = TempDir::new().unwrap();
621        let mut session = sample_session();
622        session.status = SessionStatus::Paused;
623        save_session_in(&session, dir.path()).unwrap();
624
625        let loaded = load_session_from("paw-my-project", dir.path())
626            .unwrap()
627            .expect("session should exist");
628        assert_eq!(loaded.status, SessionStatus::Paused);
629    }
630
631    #[test]
632    fn effective_status_paused_alive_remains_paused() {
633        let mut session = sample_session();
634        session.status = SessionStatus::Paused;
635        assert_eq!(session.effective_status(|_| true), SessionStatus::Paused);
636    }
637
638    #[test]
639    fn effective_status_paused_dead_downgrades_to_stopped() {
640        let mut session = sample_session();
641        session.status = SessionStatus::Paused;
642        assert_eq!(session.effective_status(|_| false), SessionStatus::Stopped);
643    }
644
645    // -- dashboard_pane field --
646
647    #[test]
648    fn dashboard_pane_round_trips() {
649        let dir = TempDir::new().unwrap();
650        let mut session = sample_session();
651        session.dashboard_pane = Some(1);
652        save_session_in(&session, dir.path()).unwrap();
653
654        let loaded = load_session_from("paw-my-project", dir.path())
655            .unwrap()
656            .expect("session should exist");
657        assert_eq!(loaded.dashboard_pane, Some(1));
658    }
659
660    #[test]
661    fn v04_session_without_dashboard_pane_loads_as_none() {
662        let dir = TempDir::new().unwrap();
663        let json = r#"{
664            "session_name": "paw-legacy-dashboard",
665            "repo_path": "/tmp/legacy-repo",
666            "project_name": "legacy",
667            "created_at": "2024-03-23T12:00:00Z",
668            "status": "active",
669            "worktrees": []
670        }"#;
671        std::fs::write(dir.path().join("paw-legacy-dashboard.json"), json).unwrap();
672
673        let loaded = load_session_from("paw-legacy-dashboard", dir.path())
674            .unwrap()
675            .expect("session should load");
676        assert!(
677            loaded.dashboard_pane.is_none(),
678            "v0.4 session should load with dashboard_pane = None"
679        );
680    }
681
682    #[test]
683    fn dashboard_pane_none_is_omitted_from_json() {
684        let dir = TempDir::new().unwrap();
685        let session = sample_session(); // dashboard_pane is None by default
686        save_session_in(&session, dir.path()).unwrap();
687
688        let json = std::fs::read_to_string(dir.path().join("paw-my-project.json")).unwrap();
689        assert!(
690            !json.contains("dashboard_pane"),
691            "JSON should not include dashboard_pane when None, got: {json}"
692        );
693    }
694
695    // -- Recovery: save → tmux dies → state has everything to reconstruct --
696
697    // -- Broker fields --
698
699    #[test]
700    fn session_with_broker_fields_round_trips() {
701        let dir = TempDir::new().unwrap();
702        let mut session = sample_session();
703        session.broker_port = Some(9119);
704        session.broker_bind = Some("127.0.0.1".to_string());
705        session.broker_log_path = Some(PathBuf::from("/tmp/broker.log"));
706
707        save_session_in(&session, dir.path()).unwrap();
708
709        let loaded = load_session_from("paw-my-project", dir.path())
710            .unwrap()
711            .expect("session should exist");
712
713        assert_eq!(loaded.broker_port, Some(9119));
714        assert_eq!(loaded.broker_bind.as_deref(), Some("127.0.0.1"));
715        assert_eq!(
716            loaded.broker_log_path,
717            Some(PathBuf::from("/tmp/broker.log"))
718        );
719    }
720
721    #[test]
722    fn v020_session_json_loads_with_broker_fields_as_none() {
723        let dir = TempDir::new().unwrap();
724        // Simulate a v0.2.0 session JSON that has no broker fields
725        let json = r#"{
726            "session_name": "paw-legacy",
727            "repo_path": "/tmp/legacy-repo",
728            "project_name": "legacy",
729            "created_at": "2024-03-23T12:00:00Z",
730            "status": "active",
731            "worktrees": []
732        }"#;
733        std::fs::write(dir.path().join("paw-legacy.json"), json).unwrap();
734
735        let loaded = load_session_from("paw-legacy", dir.path())
736            .unwrap()
737            .expect("session should load");
738
739        assert!(loaded.broker_port.is_none());
740        assert!(loaded.broker_bind.is_none());
741        assert!(loaded.broker_log_path.is_none());
742        assert_eq!(loaded.session_name, "paw-legacy");
743    }
744
745    #[test]
746    fn session_with_broker_fields_serializes_them() {
747        let dir = TempDir::new().unwrap();
748        let mut session = sample_session();
749        session.broker_port = Some(9119);
750        session.broker_bind = Some("127.0.0.1".to_string());
751        session.broker_log_path = Some(PathBuf::from("/tmp/broker.log"));
752        save_session_in(&session, dir.path()).unwrap();
753
754        let json = std::fs::read_to_string(dir.path().join("paw-my-project.json")).unwrap();
755        assert!(
756            json.contains("broker_port"),
757            "JSON should contain broker_port"
758        );
759        assert!(
760            json.contains("broker_bind"),
761            "JSON should contain broker_bind"
762        );
763        assert!(
764            json.contains("broker_log_path"),
765            "JSON should contain broker_log_path"
766        );
767    }
768
769    #[test]
770    fn session_without_broker_fields_omits_them_from_json() {
771        let dir = TempDir::new().unwrap();
772        let session = sample_session(); // broker fields are all None
773        save_session_in(&session, dir.path()).unwrap();
774
775        let json = std::fs::read_to_string(dir.path().join("paw-my-project.json")).unwrap();
776        assert!(
777            !json.contains("broker_port"),
778            "JSON should not contain broker_port when None"
779        );
780        assert!(
781            !json.contains("broker_bind"),
782            "JSON should not contain broker_bind when None"
783        );
784        assert!(
785            !json.contains("broker_log_path"),
786            "JSON should not contain broker_log_path when None"
787        );
788    }
789
790    // -- Recovery with broker fields --
791
792    #[test]
793    fn recovery_after_tmux_crash_has_all_data_to_reconstruct() {
794        let dir = TempDir::new().unwrap();
795        let session = sample_session();
796        save_session_in(&session, dir.path()).unwrap();
797
798        // Simulate: tmux crashed, we reload from disk.
799        let recovered = load_session_from("paw-my-project", dir.path())
800            .unwrap()
801            .expect("session state should survive tmux crash");
802
803        // Has the tmux session name to recreate.
804        assert_eq!(recovered.session_name, "paw-my-project");
805        // Has the repo path to cd into.
806        assert_eq!(
807            recovered.repo_path,
808            PathBuf::from("/Users/test/code/my-project")
809        );
810        // Has every worktree's branch, path, and CLI — enough to relaunch.
811        assert_eq!(recovered.worktrees.len(), 3);
812        for wt in &recovered.worktrees {
813            assert!(!wt.branch.is_empty());
814            assert!(!wt.worktree_path.as_os_str().is_empty());
815            assert!(!wt.cli.is_empty());
816        }
817        // Status correctly reflects that tmux is gone.
818        assert_eq!(
819            recovered.effective_status(|_| false),
820            SessionStatus::Stopped
821        );
822    }
823
824    // -- Recovery with broker enabled --
825
826    #[test]
827    fn session_with_broker_enabled_has_recovery_data() {
828        let dir = TempDir::new().unwrap();
829        let mut session = sample_session();
830        session.broker_port = Some(9119);
831        session.broker_bind = Some("127.0.0.1".to_string());
832        save_session_in(&session, dir.path()).unwrap();
833
834        let recovered = load_session_from("paw-my-project", dir.path())
835            .unwrap()
836            .expect("session should load");
837
838        // Broker fields are preserved for recovery
839        assert_eq!(recovered.broker_port, Some(9119));
840        assert_eq!(recovered.broker_bind.as_deref(), Some("127.0.0.1"));
841    }
842
843    #[test]
844    fn session_without_broker_has_no_recovery_data() {
845        let dir = TempDir::new().unwrap();
846        let session = sample_session(); // broker fields are None by default
847        save_session_in(&session, dir.path()).unwrap();
848
849        let recovered = load_session_from("paw-my-project", dir.path())
850            .unwrap()
851            .expect("session should load");
852
853        // No broker fields to recover
854        assert!(recovered.broker_port.is_none());
855        assert!(recovered.broker_bind.is_none());
856    }
857}