1use std::fmt;
8use std::fs;
9use std::io::ErrorKind;
10use std::path::{Path, PathBuf};
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14
15use crate::error::PawError;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum SessionStatus {
25 Active,
27 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct WorktreeEntry {
43 pub branch: String,
45 pub worktree_path: PathBuf,
47 pub cli: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[allow(clippy::struct_field_names)]
54pub struct Session {
55 pub session_name: String,
57 pub repo_path: PathBuf,
59 pub project_name: String,
61 #[serde(
63 serialize_with = "serialize_system_time",
64 deserialize_with = "deserialize_system_time"
65 )]
66 pub created_at: SystemTime,
67 pub status: SessionStatus,
69 pub worktrees: Vec<WorktreeEntry>,
71}
72
73impl Session {
74 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
87pub fn save_session(session: &Session) -> Result<(), PawError> {
97 save_session_in(session, &sessions_dir()?)
98}
99
100pub fn find_session_for_repo(repo_path: &Path) -> Result<Option<Session>, PawError> {
105 find_session_for_repo_in(repo_path, &sessions_dir()?)
106}
107
108pub fn delete_session(session_name: &str) -> Result<(), PawError> {
112 delete_session_in(session_name, &sessions_dir()?)
113}
114
115pub 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
139pub 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
159pub 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, };
188
189 if session.repo_path == repo_path {
190 return Ok(Some(session));
191 }
192 }
193
194 Ok(None)
195}
196
197pub 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
210fn 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
222fn 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
239fn parse_iso8601(s: &str) -> Result<SystemTime, PawError> {
241 let err = || PawError::SessionError(format!("invalid ISO 8601 timestamp: {s}"));
242
243 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
265fn 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 #[allow(clippy::cast_possible_wrap)]
275 let mut days = (secs / 86400).cast_signed();
276
277 days += 719_468; let era = days / 146_097;
279 let doe = days - era * 146_097; 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); let mp = (5 * doy + 2) / 153; 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
299fn 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 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
328fn 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#[cfg(test)]
347mod tests {
348 use super::*;
349 use tempfile::TempDir;
350
351 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 #[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 #[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 #[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 #[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 #[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 #[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 assert_eq!(session.effective_status(|_| true), SessionStatus::Stopped);
521 }
522
523 #[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 #[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 let recovered = load_session_from("paw-my-project", dir.path())
541 .unwrap()
542 .expect("session state should survive tmux crash");
543
544 assert_eq!(recovered.session_name, "paw-my-project");
546 assert_eq!(
548 recovered.repo_path,
549 PathBuf::from("/Users/test/code/my-project")
550 );
551 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 assert_eq!(
560 recovered.effective_status(|_| false),
561 SessionStatus::Stopped
562 );
563 }
564}