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}
50
51/// Persisted session state for a git-paw session.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[allow(clippy::struct_field_names)]
54pub struct Session {
55    /// Tmux session name (also used as the filename stem).
56    pub session_name: String,
57    /// Absolute path to the repository root.
58    pub repo_path: PathBuf,
59    /// Human-readable project name (derived from the repo directory name).
60    pub project_name: String,
61    /// ISO 8601 timestamp of session creation (UTC).
62    #[serde(
63        serialize_with = "serialize_system_time",
64        deserialize_with = "deserialize_system_time"
65    )]
66    pub created_at: SystemTime,
67    /// Current session status.
68    pub status: SessionStatus,
69    /// Worktrees managed by this session.
70    pub worktrees: Vec<WorktreeEntry>,
71}
72
73impl Session {
74    /// Returns the effective status by combining the on-disk status with a
75    /// tmux liveness check.
76    ///
77    /// If the recorded status is `Active` but the tmux session is not alive,
78    /// returns `Stopped`.
79    pub fn effective_status(&self, is_tmux_alive: impl Fn(&str) -> bool) -> SessionStatus {
80        if self.status == SessionStatus::Active && !is_tmux_alive(&self.session_name) {
81            return SessionStatus::Stopped;
82        }
83        self.status.clone()
84    }
85}
86
87// ---------------------------------------------------------------------------
88// Public API
89// ---------------------------------------------------------------------------
90
91/// Atomically writes a session to disk.
92///
93/// Serializes the session to JSON, writes to a temporary file in the same
94/// directory, then renames to the final path. This prevents corruption if
95/// the process is killed mid-write.
96pub fn save_session(session: &Session) -> Result<(), PawError> {
97    save_session_in(session, &sessions_dir()?)
98}
99
100/// Finds the session associated with a given repository path.
101///
102/// Scans all `.json` files in the sessions directory and returns the first
103/// session whose `repo_path` matches the given path.
104pub fn find_session_for_repo(repo_path: &Path) -> Result<Option<Session>, PawError> {
105    find_session_for_repo_in(repo_path, &sessions_dir()?)
106}
107
108/// Deletes a session file by name.
109///
110/// Returns `Ok(())` even if the file does not exist (idempotent).
111pub fn delete_session(session_name: &str) -> Result<(), PawError> {
112    delete_session_in(session_name, &sessions_dir()?)
113}
114
115// ---------------------------------------------------------------------------
116// Directory-parameterized implementations (public for integration tests)
117// ---------------------------------------------------------------------------
118
119/// Atomically writes a session to the given directory.
120pub fn save_session_in(session: &Session, dir: &Path) -> Result<(), PawError> {
121    fs::create_dir_all(dir)
122        .map_err(|e| PawError::SessionError(format!("failed to create sessions dir: {e}")))?;
123
124    let json = serde_json::to_string_pretty(session)
125        .map_err(|e| PawError::SessionError(format!("failed to serialize session: {e}")))?;
126
127    let final_path = dir.join(format!("{}.json", session.session_name));
128    let tmp_path = dir.join(format!("{}.tmp", session.session_name));
129
130    fs::write(&tmp_path, json.as_bytes())
131        .map_err(|e| PawError::SessionError(format!("failed to write temp file: {e}")))?;
132
133    fs::rename(&tmp_path, &final_path)
134        .map_err(|e| PawError::SessionError(format!("failed to rename temp file: {e}")))?;
135
136    Ok(())
137}
138
139/// Loads a session by name from the given directory.
140pub fn load_session_from(session_name: &str, dir: &Path) -> Result<Option<Session>, PawError> {
141    let path = dir.join(format!("{session_name}.json"));
142
143    let contents = match fs::read_to_string(&path) {
144        Ok(s) => s,
145        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
146        Err(e) => {
147            return Err(PawError::SessionError(format!(
148                "failed to read session file: {e}"
149            )));
150        }
151    };
152
153    let session: Session = serde_json::from_str(&contents)
154        .map_err(|e| PawError::SessionError(format!("failed to parse session file: {e}")))?;
155
156    Ok(Some(session))
157}
158
159/// Finds the session for a repo path, scanning the given directory.
160pub fn find_session_for_repo_in(repo_path: &Path, dir: &Path) -> Result<Option<Session>, PawError> {
161    let entries = match fs::read_dir(dir) {
162        Ok(e) => e,
163        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
164        Err(e) => {
165            return Err(PawError::SessionError(format!(
166                "failed to read sessions dir: {e}"
167            )));
168        }
169    };
170
171    for entry in entries {
172        let entry =
173            entry.map_err(|e| PawError::SessionError(format!("failed to read dir entry: {e}")))?;
174        let path = entry.path();
175
176        if path.extension().and_then(|e| e.to_str()) != Some("json") {
177            continue;
178        }
179
180        let contents = fs::read_to_string(&path).map_err(|e| {
181            PawError::SessionError(format!("failed to read {}: {e}", path.display()))
182        })?;
183
184        let session: Session = match serde_json::from_str(&contents) {
185            Ok(s) => s,
186            Err(_) => continue, // skip malformed files
187        };
188
189        if session.repo_path == repo_path {
190            return Ok(Some(session));
191        }
192    }
193
194    Ok(None)
195}
196
197/// Deletes a session file by name from the given directory.
198pub fn delete_session_in(session_name: &str, dir: &Path) -> Result<(), PawError> {
199    let path = dir.join(format!("{session_name}.json"));
200
201    match fs::remove_file(&path) {
202        Ok(()) => Ok(()),
203        Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
204        Err(e) => Err(PawError::SessionError(format!(
205            "failed to delete session file: {e}"
206        ))),
207    }
208}
209
210// ---------------------------------------------------------------------------
211// Path helpers
212// ---------------------------------------------------------------------------
213
214/// Returns the sessions directory (`~/.local/share/git-paw/sessions/`).
215fn sessions_dir() -> Result<PathBuf, PawError> {
216    let base = crate::dirs::data_dir().ok_or_else(|| {
217        PawError::SessionError("could not determine XDG data directory".to_string())
218    })?;
219    Ok(base.join("git-paw").join("sessions"))
220}
221
222// ---------------------------------------------------------------------------
223// ISO 8601 helpers
224// ---------------------------------------------------------------------------
225
226/// Formats a `SystemTime` as an ISO 8601 UTC string (`YYYY-MM-DDTHH:MM:SSZ`).
227fn format_iso8601(time: SystemTime) -> Result<String, PawError> {
228    let secs = time
229        .duration_since(UNIX_EPOCH)
230        .map_err(|e| PawError::SessionError(format!("time before unix epoch: {e}")))?
231        .as_secs();
232
233    let (year, month, day, hour, min, sec) = secs_to_civil(secs);
234    Ok(format!(
235        "{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z"
236    ))
237}
238
239/// Parses an ISO 8601 UTC string (`YYYY-MM-DDTHH:MM:SSZ`) into a `SystemTime`.
240fn parse_iso8601(s: &str) -> Result<SystemTime, PawError> {
241    let err = || PawError::SessionError(format!("invalid ISO 8601 timestamp: {s}"));
242
243    // Expected format: YYYY-MM-DDTHH:MM:SSZ
244    let s = s.strip_suffix('Z').ok_or_else(err)?;
245    let (date, time) = s.split_once('T').ok_or_else(err)?;
246
247    let date_parts: Vec<&str> = date.split('-').collect();
248    let time_parts: Vec<&str> = time.split(':').collect();
249
250    if date_parts.len() != 3 || time_parts.len() != 3 {
251        return Err(err());
252    }
253
254    let year: u64 = date_parts[0].parse().map_err(|_| err())?;
255    let month: u64 = date_parts[1].parse().map_err(|_| err())?;
256    let day: u64 = date_parts[2].parse().map_err(|_| err())?;
257    let hour: u64 = time_parts[0].parse().map_err(|_| err())?;
258    let min: u64 = time_parts[1].parse().map_err(|_| err())?;
259    let sec: u64 = time_parts[2].parse().map_err(|_| err())?;
260
261    let secs = civil_to_secs(year, month, day, hour, min, sec).ok_or_else(err)?;
262    Ok(UNIX_EPOCH + Duration::from_secs(secs))
263}
264
265/// Converts seconds since Unix epoch to (year, month, day, hour, minute, second) in UTC.
266fn secs_to_civil(secs: u64) -> (u64, u64, u64, u64, u64, u64) {
267    let sec_of_day = secs % 86400;
268    let hour = sec_of_day / 3600;
269    let min = (sec_of_day % 3600) / 60;
270    let sec = sec_of_day % 60;
271
272    // Days since epoch (1970-01-01)
273    // Algorithm from Howard Hinnant's chrono-compatible date library.
274    #[allow(clippy::cast_possible_wrap)]
275    let mut days = (secs / 86400).cast_signed();
276
277    days += 719_468; // shift epoch from 1970-01-01 to 0000-03-01
278    let era = days / 146_097;
279    let doe = days - era * 146_097; // day of era [0, 146096]
280    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
281    let y = yoe + era * 400;
282    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year [0, 365]
283    let mp = (5 * doy + 2) / 153; // month index [0, 11]
284    let d = doy - (153 * mp + 2) / 5 + 1;
285    let m = if mp < 10 { mp + 3 } else { mp - 9 };
286    let y = if m <= 2 { y + 1 } else { y };
287
288    #[allow(clippy::cast_sign_loss)]
289    (
290        y.cast_unsigned(),
291        m.cast_unsigned(),
292        d.cast_unsigned(),
293        hour,
294        min,
295        sec,
296    )
297}
298
299/// Converts (year, month, day, hour, min, sec) to seconds since Unix epoch.
300fn civil_to_secs(year: u64, month: u64, day: u64, hour: u64, min: u64, sec: u64) -> Option<u64> {
301    if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || min > 59 || sec > 59 {
302        return None;
303    }
304
305    #[allow(clippy::cast_possible_wrap)]
306    let y = year.cast_signed();
307    #[allow(clippy::cast_possible_wrap)]
308    let m = month.cast_signed();
309    #[allow(clippy::cast_possible_wrap)]
310    let d = day.cast_signed();
311
312    // Shift to March-based year
313    let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
314    let era = y / 400;
315    let yoe = y - era * 400;
316    let doy = (153 * m + 2) / 5 + d - 1;
317    let doe = 365 * yoe + yoe / 4 - yoe / 100 + doy;
318    let days = era * 146_097 + doe - 719_468;
319
320    if days < 0 {
321        return None;
322    }
323
324    #[allow(clippy::cast_sign_loss)]
325    Some(days.cast_unsigned() * 86400 + hour * 3600 + min * 60 + sec)
326}
327
328// ---------------------------------------------------------------------------
329// Serde helpers for SystemTime ↔ ISO 8601
330// ---------------------------------------------------------------------------
331
332fn serialize_system_time<S: Serializer>(time: &SystemTime, ser: S) -> Result<S::Ok, S::Error> {
333    let s = format_iso8601(*time).map_err(serde::ser::Error::custom)?;
334    ser.serialize_str(&s)
335}
336
337fn deserialize_system_time<'de, D: Deserializer<'de>>(de: D) -> Result<SystemTime, D::Error> {
338    let s: String = Deserialize::deserialize(de)?;
339    parse_iso8601(&s).map_err(serde::de::Error::custom)
340}
341
342// ---------------------------------------------------------------------------
343// Tests
344// ---------------------------------------------------------------------------
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use tempfile::TempDir;
350
351    /// Creates a sample session with 3 worktrees for testing.
352    fn sample_session() -> Session {
353        Session {
354            session_name: "paw-my-project".to_string(),
355            repo_path: PathBuf::from("/Users/test/code/my-project"),
356            project_name: "my-project".to_string(),
357            created_at: UNIX_EPOCH + Duration::from_secs(1_711_200_000),
358            status: SessionStatus::Active,
359            worktrees: vec![
360                WorktreeEntry {
361                    branch: "feature/auth".to_string(),
362                    worktree_path: PathBuf::from("/Users/test/code/my-project-feature-auth"),
363                    cli: "claude".to_string(),
364                },
365                WorktreeEntry {
366                    branch: "fix/api".to_string(),
367                    worktree_path: PathBuf::from("/Users/test/code/my-project-fix-api"),
368                    cli: "gemini".to_string(),
369                },
370                WorktreeEntry {
371                    branch: "feature/logging".to_string(),
372                    worktree_path: PathBuf::from("/Users/test/code/my-project-feature-logging"),
373                    cli: "claude".to_string(),
374                },
375            ],
376        }
377    }
378
379    // -- save_session: GIVEN an active session with 3 worktrees,
380    //    WHEN save_session() is called, THEN JSON file created with all fields --
381
382    #[test]
383    fn saved_session_can_be_loaded_with_all_fields_intact() {
384        let dir = TempDir::new().unwrap();
385        let session = sample_session();
386        save_session_in(&session, dir.path()).unwrap();
387
388        let loaded = load_session_from("paw-my-project", dir.path())
389            .unwrap()
390            .expect("session should exist");
391
392        assert_eq!(loaded.session_name, "paw-my-project");
393        assert_eq!(
394            loaded.repo_path,
395            PathBuf::from("/Users/test/code/my-project")
396        );
397        assert_eq!(loaded.project_name, "my-project");
398        assert_eq!(loaded.created_at, session.created_at);
399        assert_eq!(loaded.status, SessionStatus::Active);
400        assert_eq!(loaded.worktrees.len(), 3);
401        assert_eq!(loaded.worktrees[0].branch, "feature/auth");
402        assert_eq!(loaded.worktrees[0].cli, "claude");
403        assert_eq!(loaded.worktrees[1].branch, "fix/api");
404        assert_eq!(loaded.worktrees[1].cli, "gemini");
405        assert_eq!(loaded.worktrees[2].branch, "feature/logging");
406    }
407
408    // -- save_session: saving again replaces the previous state --
409
410    #[test]
411    fn saving_again_replaces_previous_state() {
412        let dir = TempDir::new().unwrap();
413        let mut session = sample_session();
414        save_session_in(&session, dir.path()).unwrap();
415
416        session.status = SessionStatus::Stopped;
417        session.worktrees.pop();
418        save_session_in(&session, dir.path()).unwrap();
419
420        let loaded = load_session_from("paw-my-project", dir.path())
421            .unwrap()
422            .expect("session should exist");
423
424        assert_eq!(loaded.status, SessionStatus::Stopped);
425        assert_eq!(loaded.worktrees.len(), 2);
426    }
427
428    // -- load_session: WHEN load_session("nonexistent") is called, THEN returns None --
429
430    #[test]
431    fn loading_nonexistent_session_returns_none() {
432        let dir = TempDir::new().unwrap();
433        let result = load_session_from("nonexistent", dir.path()).unwrap();
434        assert!(result.is_none());
435    }
436
437    // -- find_session_for_repo: GIVEN two sessions,
438    //    WHEN find_session_for_repo is called, THEN returns the matching one --
439
440    #[test]
441    fn finds_correct_session_among_multiple_by_repo_path() {
442        let dir = TempDir::new().unwrap();
443
444        let mut session_a = sample_session();
445        session_a.session_name = "paw-project-a".to_string();
446        session_a.repo_path = PathBuf::from("/Users/test/code/project-a");
447
448        let mut session_b = sample_session();
449        session_b.session_name = "paw-project-b".to_string();
450        session_b.repo_path = PathBuf::from("/Users/test/code/project-b");
451
452        save_session_in(&session_a, dir.path()).unwrap();
453        save_session_in(&session_b, dir.path()).unwrap();
454
455        let found = find_session_for_repo_in(Path::new("/Users/test/code/project-b"), dir.path())
456            .unwrap()
457            .expect("should find session for project-b");
458
459        assert_eq!(found.session_name, "paw-project-b");
460        assert_eq!(found.repo_path, PathBuf::from("/Users/test/code/project-b"));
461    }
462
463    #[test]
464    fn find_returns_none_when_no_repo_matches() {
465        let dir = TempDir::new().unwrap();
466        save_session_in(&sample_session(), dir.path()).unwrap();
467
468        let found =
469            find_session_for_repo_in(Path::new("/Users/test/code/other-project"), dir.path())
470                .unwrap();
471        assert!(found.is_none());
472    }
473
474    #[test]
475    fn find_returns_none_when_no_sessions_exist() {
476        let dir = TempDir::new().unwrap();
477        let missing = dir.path().join("does-not-exist");
478        let found = find_session_for_repo_in(Path::new("/any"), &missing).unwrap();
479        assert!(found.is_none());
480    }
481
482    // -- delete_session: removes file, load returns None afterwards --
483
484    #[test]
485    fn deleted_session_is_no_longer_loadable() {
486        let dir = TempDir::new().unwrap();
487        save_session_in(&sample_session(), dir.path()).unwrap();
488
489        delete_session_in("paw-my-project", dir.path()).unwrap();
490
491        let loaded = load_session_from("paw-my-project", dir.path()).unwrap();
492        assert!(loaded.is_none());
493    }
494
495    #[test]
496    fn deleting_nonexistent_session_succeeds() {
497        let dir = TempDir::new().unwrap();
498        delete_session_in("nonexistent", dir.path()).unwrap();
499    }
500
501    // -- Status check: combines file existence + tmux liveness --
502
503    #[test]
504    fn file_says_active_and_tmux_alive_means_active() {
505        let session = sample_session();
506        assert_eq!(session.effective_status(|_| true), SessionStatus::Active);
507    }
508
509    #[test]
510    fn file_says_active_but_tmux_dead_means_stopped() {
511        let session = sample_session();
512        assert_eq!(session.effective_status(|_| false), SessionStatus::Stopped);
513    }
514
515    #[test]
516    fn file_says_stopped_stays_stopped_regardless_of_tmux() {
517        let mut session = sample_session();
518        session.status = SessionStatus::Stopped;
519        // Even if tmux is somehow alive, stopped means stopped.
520        assert_eq!(session.effective_status(|_| true), SessionStatus::Stopped);
521    }
522
523    // -- SessionStatus Display --
524
525    #[test]
526    fn session_status_displays_as_lowercase_string() {
527        assert_eq!(SessionStatus::Active.to_string(), "active");
528        assert_eq!(SessionStatus::Stopped.to_string(), "stopped");
529    }
530
531    // -- Recovery: save → tmux dies → state has everything to reconstruct --
532
533    #[test]
534    fn recovery_after_tmux_crash_has_all_data_to_reconstruct() {
535        let dir = TempDir::new().unwrap();
536        let session = sample_session();
537        save_session_in(&session, dir.path()).unwrap();
538
539        // Simulate: tmux crashed, we reload from disk.
540        let recovered = load_session_from("paw-my-project", dir.path())
541            .unwrap()
542            .expect("session state should survive tmux crash");
543
544        // Has the tmux session name to recreate.
545        assert_eq!(recovered.session_name, "paw-my-project");
546        // Has the repo path to cd into.
547        assert_eq!(
548            recovered.repo_path,
549            PathBuf::from("/Users/test/code/my-project")
550        );
551        // Has every worktree's branch, path, and CLI — enough to relaunch.
552        assert_eq!(recovered.worktrees.len(), 3);
553        for wt in &recovered.worktrees {
554            assert!(!wt.branch.is_empty());
555            assert!(!wt.worktree_path.as_os_str().is_empty());
556            assert!(!wt.cli.is_empty());
557        }
558        // Status correctly reflects that tmux is gone.
559        assert_eq!(
560            recovered.effective_status(|_| false),
561            SessionStatus::Stopped
562        );
563    }
564}