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/// The status git-paw displays for a session, derived from the persisted
61/// receipt status combined with a live tmux probe (design D4 of
62/// `session-bugfixes`).
63///
64/// Distinct from [`SessionStatus`] (the on-disk receipt value): a receipt
65/// that claims `Active` but whose tmux session has vanished surfaces as
66/// [`DisplayStatus::Stale`] rather than silently downgrading to `Stopped`,
67/// so the user can tell a clean stop apart from a crashed / carried-over
68/// session.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum DisplayStatus {
71    /// Receipt says active and the tmux session is alive.
72    Active,
73    /// Receipt says paused and the tmux session is alive.
74    Paused,
75    /// Receipt says stopped, or a paused session whose tmux server died.
76    Stopped,
77    /// Receipt claims active but the tmux session is gone (crash or
78    /// release-boundary carry-over).
79    Stale,
80}
81
82impl DisplayStatus {
83    /// Resolves the display status from the persisted receipt status and a
84    /// tmux-liveness probe.
85    ///
86    /// `🔴 Stale` surfaces only for `Active` receipts whose probe returns
87    /// [`crate::tmux::SessionLiveness::Stale`]. An `Indeterminate` probe (the
88    /// tmux binary is missing) never yields `Stale`: it preserves the
89    /// pre-existing "tmux not alive" display by downgrading active/paused to
90    /// `Stopped`.
91    #[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            // Stopped receipt (any probe), paused-with-dead-tmux, and every
99            // Indeterminate case fall through to the unchanged Stopped display.
100            _ => Self::Stopped,
101        }
102    }
103
104    /// Returns the coloured status icon for terminal display.
105    #[must_use]
106    pub fn icon(self) -> &'static str {
107        match self {
108            Self::Active => "\u{1f7e2}",  // 🟢
109            Self::Paused => "\u{1f535}",  // 🔵
110            Self::Stopped => "\u{1f7e1}", // 🟡
111            Self::Stale => "\u{1f534}",   // 🔴
112        }
113    }
114
115    /// Returns the lowercase status string used in JSON output and logs.
116    #[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/// A worktree entry within a session.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct WorktreeEntry {
136    /// The branch checked out in this worktree.
137    pub branch: String,
138    /// Absolute path to the worktree directory.
139    pub worktree_path: PathBuf,
140    /// The AI CLI assigned to this worktree.
141    pub cli: String,
142    /// Whether git-paw created this branch (vs. it already existing).
143    /// When `true`, `purge` will delete the branch after removing the worktree.
144    #[serde(default)]
145    pub branch_created: bool,
146
147    /// Boot+task prompt awaiting submission.
148    ///
149    /// Set only when an agent is attached via `git paw add` to a *paused*
150    /// session (design D4): the pane is created and the boot block injected,
151    /// but the prompt is held unsubmitted so the new agent stays paused with
152    /// the rest of the session. `git paw resume` (restart-from-pause) submits
153    /// any entry carrying a pending prompt and clears the field. `None` for
154    /// every start-time agent and for adds to an active session (submitted
155    /// immediately). Omitted from JSON when `None`.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub pending_boot_prompt: Option<String>,
158}
159
160/// Persisted session state for a git-paw session.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[allow(clippy::struct_field_names)]
163pub struct Session {
164    /// Tmux session name (also used as the filename stem).
165    pub session_name: String,
166    /// Absolute path to the repository root.
167    pub repo_path: PathBuf,
168    /// Human-readable project name (derived from the repo directory name).
169    pub project_name: String,
170    /// ISO 8601 timestamp of session creation (UTC).
171    #[serde(
172        serialize_with = "serialize_system_time",
173        deserialize_with = "deserialize_system_time"
174    )]
175    pub created_at: SystemTime,
176    /// Current session status.
177    pub status: SessionStatus,
178    /// Worktrees managed by this session.
179    pub worktrees: Vec<WorktreeEntry>,
180
181    /// Broker port (when broker is enabled).
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub broker_port: Option<u16>,
184
185    /// Broker bind address (when broker is enabled).
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub broker_bind: Option<String>,
188
189    /// Path to the broker log file (when broker is enabled).
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub broker_log_path: Option<PathBuf>,
192
193    /// Pane-layout shape this session was launched with. Missing on sessions
194    /// saved by v0.4 binaries, in which case [`SessionMode::Bare`] is assumed
195    /// for backwards compatibility.
196    #[serde(default)]
197    pub mode: SessionMode,
198
199    /// Pane index of the dashboard pane (when broker is enabled). Used by the
200    /// restart-from-pause flow to recreate the dashboard pane in its original
201    /// position. `None` on v0.4-saved sessions; consumers SHALL default to `0`.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub dashboard_pane: Option<u32>,
204}
205
206impl Session {
207    /// Returns the effective status by combining the on-disk status with a
208    /// tmux liveness check.
209    ///
210    /// `Active` or `Paused` downgrade to `Stopped` when tmux is not alive
211    /// (a paused session whose tmux server died has no live CLI panes to
212    /// resume into). `Stopped` is unchanged regardless of tmux liveness.
213    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    /// Returns the session's creation timestamp formatted as an ISO 8601 UTC
223    /// string, or `None` if the timestamp is before the Unix epoch. Used by
224    /// the stale-receipt invalidation notice (design D5).
225    #[must_use]
226    pub fn created_at_iso8601(&self) -> Option<String> {
227        format_iso8601(self.created_at).ok()
228    }
229}
230
231// ---------------------------------------------------------------------------
232// Public API
233// ---------------------------------------------------------------------------
234
235/// Atomically writes a session to disk.
236///
237/// Serializes the session to JSON, writes to a temporary file in the same
238/// directory, then renames to the final path. This prevents corruption if
239/// the process is killed mid-write.
240pub fn save_session(session: &Session) -> Result<(), PawError> {
241    save_session_in(session, &sessions_dir()?)
242}
243
244/// Finds the session associated with a given repository path.
245///
246/// Scans all `.json` files in the sessions directory and returns the first
247/// session whose `repo_path` matches the given path.
248pub fn find_session_for_repo(repo_path: &Path) -> Result<Option<Session>, PawError> {
249    find_session_for_repo_in(repo_path, &sessions_dir()?)
250}
251
252/// Deletes a session file by name.
253///
254/// Returns `Ok(())` even if the file does not exist (idempotent).
255pub fn delete_session(session_name: &str) -> Result<(), PawError> {
256    delete_session_in(session_name, &sessions_dir()?)
257}
258
259// ---------------------------------------------------------------------------
260// Directory-parameterized implementations (public for integration tests)
261// ---------------------------------------------------------------------------
262
263/// Atomically writes a session to the given directory.
264pub 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
283/// Loads a session by name from the given directory.
284pub 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
303/// Finds the session for a repo path, scanning the given directory.
304pub 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, // skip malformed files
331        };
332
333        if session.repo_path == repo_path {
334            return Ok(Some(session));
335        }
336    }
337
338    Ok(None)
339}
340
341/// Loads every session receipt in the given directory.
342///
343/// Used by `purge --stale` to enumerate all sessions on the machine and probe
344/// each for staleness. Malformed files are skipped (matching
345/// [`find_session_for_repo_in`]). A missing directory yields an empty list.
346pub 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
378/// Deletes a session file by name from the given directory.
379pub 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// ---------------------------------------------------------------------------
392// Per-repo session discovery file (`<repo>/.git-paw/sessions/<name>.json`)
393// ---------------------------------------------------------------------------
394//
395// Distinct from the global receipt above: this is the lightweight discovery
396// surface the bundled `assets/scripts/sweep.sh` helper reads to find the
397// active session name and its agent roster from inside the repo, without
398// reaching into the XDG state dir. `git paw start` writes it; `purge` removes
399// it (design `session-json-location`).
400
401/// One agent entry in the per-repo discovery file.
402///
403/// The field set and names match exactly what `sweep.sh` expects:
404/// `branch_id` (the broker agent id / slugified branch), `worktree_path`,
405/// `cli`, and `pane_index`. Adding fields is backwards-compatible — consumers
406/// ignore unknown keys.
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
408pub struct RepoAgentEntry {
409    /// Broker agent id (slugified branch), e.g. `feat-add-auth`.
410    pub branch_id: String,
411    /// Absolute path to the agent's worktree.
412    pub worktree_path: PathBuf,
413    /// The AI CLI assigned to the agent's pane.
414    pub cli: String,
415    /// The agent's tmux pane index within the session window.
416    pub pane_index: usize,
417}
418
419/// The per-repo discovery document written to
420/// `<repo>/.git-paw/sessions/<session_name>.json`.
421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422pub struct RepoSessionFile {
423    /// The tmux session name (also the file stem).
424    pub session_name: String,
425    /// The coding-agent roster for the session.
426    pub agents: Vec<RepoAgentEntry>,
427}
428
429/// Returns the per-repo sessions directory: `<repo>/.git-paw/sessions/`.
430#[must_use]
431pub fn repo_sessions_dir(repo_root: &Path) -> PathBuf {
432    repo_root.join(".git-paw").join("sessions")
433}
434
435/// Returns the per-repo session file path for a session name.
436#[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
441/// Atomically writes the per-repo discovery file for a session.
442///
443/// Creates `<repo>/.git-paw/sessions/` if absent, then writes
444/// `<session_name>.json` via a temp-file-and-rename so a concurrent
445/// `sweep.sh` read never sees a partial document.
446pub 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
467/// Removes the per-repo discovery file for a session. Idempotent — a missing
468/// file is not an error.
469pub 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
480// ---------------------------------------------------------------------------
481// Path helpers
482// ---------------------------------------------------------------------------
483
484/// Returns the sessions directory (`~/.local/share/git-paw/sessions/`).
485///
486/// Also used by the broker to place `broker.log` alongside session state.
487pub fn session_state_dir() -> Result<PathBuf, PawError> {
488    sessions_dir()
489}
490
491/// Returns the sessions directory (`~/.local/share/git-paw/sessions/`).
492fn 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
499// ---------------------------------------------------------------------------
500// ISO 8601 helpers
501// ---------------------------------------------------------------------------
502
503/// Formats a `SystemTime` as an ISO 8601 UTC string (`YYYY-MM-DDTHH:MM:SSZ`).
504fn 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
516/// Parses an ISO 8601 UTC string (`YYYY-MM-DDTHH:MM:SSZ`) into a `SystemTime`.
517fn parse_iso8601(s: &str) -> Result<SystemTime, PawError> {
518    let err = || PawError::SessionError(format!("invalid ISO 8601 timestamp: {s}"));
519
520    // Expected format: YYYY-MM-DDTHH:MM:SSZ
521    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
542/// Converts seconds since Unix epoch to (year, month, day, hour, minute, second) in UTC.
543fn 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    // Days since epoch (1970-01-01)
550    // Algorithm from Howard Hinnant's chrono-compatible date library.
551    #[allow(clippy::cast_possible_wrap)]
552    let mut days = (secs / 86400).cast_signed();
553
554    days += 719_468; // shift epoch from 1970-01-01 to 0000-03-01
555    let era = days / 146_097;
556    let doe = days - era * 146_097; // day of era [0, 146096]
557    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); // day of year [0, 365]
560    let mp = (5 * doy + 2) / 153; // month index [0, 11]
561    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
576/// Converts (year, month, day, hour, min, sec) to seconds since Unix epoch.
577fn 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    // Shift to March-based year
590    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
605// ---------------------------------------------------------------------------
606// Serde helpers for SystemTime ↔ ISO 8601
607// ---------------------------------------------------------------------------
608
609fn 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// ---------------------------------------------------------------------------
620// Tests
621// ---------------------------------------------------------------------------
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use tempfile::TempDir;
627
628    /// Creates a sample session with 3 worktrees for testing.
629    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            // Fixed unix epoch (2024-03-23 13:20:00 UTC); seconds is the
635            // canonical unit for unix timestamps so the literal stays
636            // human-readable as a date.
637            #[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    // -- save_session: GIVEN an active session with 3 worktrees,
672    //    WHEN save_session() is called, THEN JSON file created with all fields --
673
674    #[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    // -- save_session: saving again replaces the previous state --
701
702    #[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    // -- load_session: WHEN load_session("nonexistent") is called, THEN returns None --
721
722    #[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    // -- find_session_for_repo: GIVEN two sessions,
730    //    WHEN find_session_for_repo is called, THEN returns the matching one --
731
732    #[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    // -- delete_session: removes file, load returns None afterwards --
775
776    #[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    // -- Status check: combines file existence + tmux liveness --
794
795    #[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        // Even if tmux is somehow alive, stopped means stopped.
812        assert_eq!(session.effective_status(|_| true), SessionStatus::Stopped);
813    }
814
815    // -- SessionStatus Display --
816
817    #[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    // -- DisplayStatus: receipt × liveness → display (session-bugfixes Bug 2,
825    //    tasks 4.1–4.3) --
826
827    #[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        // tmux binary missing: an active receipt must NOT surface as stale —
863        // it preserves the pre-existing "not alive" display (stopped).
864        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    // -- Paused variant: round-trip + effective_status --
883
884    #[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    // -- dashboard_pane field --
926
927    #[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(); // dashboard_pane is None by default
966        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    // -- Recovery: save → tmux dies → state has everything to reconstruct --
976
977    // -- Broker fields --
978
979    #[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        // Simulate a v0.2.0 session JSON that has no broker fields
1005        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(); // broker fields are all None
1053        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    // -- Recovery with broker fields --
1071
1072    #[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        // Simulate: tmux crashed, we reload from disk.
1079        let recovered = load_session_from("paw-my-project", dir.path())
1080            .unwrap()
1081            .expect("session state should survive tmux crash");
1082
1083        // Has the tmux session name to recreate.
1084        assert_eq!(recovered.session_name, "paw-my-project");
1085        // Has the repo path to cd into.
1086        assert_eq!(
1087            recovered.repo_path,
1088            PathBuf::from("/Users/test/code/my-project")
1089        );
1090        // Has every worktree's branch, path, and CLI — enough to relaunch.
1091        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        // Status correctly reflects that tmux is gone.
1098        assert_eq!(
1099            recovered.effective_status(|_| false),
1100            SessionStatus::Stopped
1101        );
1102    }
1103
1104    // -- Recovery with broker enabled --
1105
1106    #[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        // Broker fields are preserved for recovery
1119        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(); // broker fields are None by default
1127        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        // No broker fields to recover
1134        assert!(recovered.broker_port.is_none());
1135        assert!(recovered.broker_bind.is_none());
1136    }
1137
1138    // -----------------------------------------------------------------------
1139    // Per-repo discovery file (session-json-location, Bug 3)
1140    // -----------------------------------------------------------------------
1141
1142    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        // Written at the path sweep.sh reads.
1169        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        // Shape round-trips with the exact field names sweep.sh expects.
1180        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        // Removing when nothing exists is a no-op (not an error).
1195        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}