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