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 #[serde(default)]
52 pub branch_created: bool,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[allow(clippy::struct_field_names)]
58pub struct Session {
59 pub session_name: String,
61 pub repo_path: PathBuf,
63 pub project_name: String,
65 #[serde(
67 serialize_with = "serialize_system_time",
68 deserialize_with = "deserialize_system_time"
69 )]
70 pub created_at: SystemTime,
71 pub status: SessionStatus,
73 pub worktrees: Vec<WorktreeEntry>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub broker_port: Option<u16>,
79
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub broker_bind: Option<String>,
83
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub broker_log_path: Option<PathBuf>,
87}
88
89impl Session {
90 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
103pub fn save_session(session: &Session) -> Result<(), PawError> {
113 save_session_in(session, &sessions_dir()?)
114}
115
116pub fn find_session_for_repo(repo_path: &Path) -> Result<Option<Session>, PawError> {
121 find_session_for_repo_in(repo_path, &sessions_dir()?)
122}
123
124pub fn delete_session(session_name: &str) -> Result<(), PawError> {
128 delete_session_in(session_name, &sessions_dir()?)
129}
130
131pub 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
155pub 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
175pub 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, };
204
205 if session.repo_path == repo_path {
206 return Ok(Some(session));
207 }
208 }
209
210 Ok(None)
211}
212
213pub 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
226pub fn session_state_dir() -> Result<PathBuf, PawError> {
234 sessions_dir()
235}
236
237fn 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
245fn 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
262fn parse_iso8601(s: &str) -> Result<SystemTime, PawError> {
264 let err = || PawError::SessionError(format!("invalid ISO 8601 timestamp: {s}"));
265
266 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
288fn 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 #[allow(clippy::cast_possible_wrap)]
298 let mut days = (secs / 86400).cast_signed();
299
300 days += 719_468; let era = days / 146_097;
302 let doe = days - era * 146_097; 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); let mp = (5 * doy + 2) / 153; 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
322fn 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 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
351fn 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#[cfg(test)]
370mod tests {
371 use super::*;
372 use tempfile::TempDir;
373
374 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 created_at: UNIX_EPOCH + Duration::from_secs(1_711_200_000),
381 status: SessionStatus::Active,
382 worktrees: vec![
383 WorktreeEntry {
384 branch: "feature/auth".to_string(),
385 worktree_path: PathBuf::from("/Users/test/code/my-project-feature-auth"),
386 cli: "claude".to_string(),
387 branch_created: false,
388 },
389 WorktreeEntry {
390 branch: "fix/api".to_string(),
391 worktree_path: PathBuf::from("/Users/test/code/my-project-fix-api"),
392 cli: "gemini".to_string(),
393 branch_created: false,
394 },
395 WorktreeEntry {
396 branch: "feature/logging".to_string(),
397 worktree_path: PathBuf::from("/Users/test/code/my-project-feature-logging"),
398 cli: "claude".to_string(),
399 branch_created: false,
400 },
401 ],
402 broker_port: None,
403 broker_bind: None,
404 broker_log_path: None,
405 }
406 }
407
408 #[test]
412 fn saved_session_can_be_loaded_with_all_fields_intact() {
413 let dir = TempDir::new().unwrap();
414 let session = sample_session();
415 save_session_in(&session, dir.path()).unwrap();
416
417 let loaded = load_session_from("paw-my-project", dir.path())
418 .unwrap()
419 .expect("session should exist");
420
421 assert_eq!(loaded.session_name, "paw-my-project");
422 assert_eq!(
423 loaded.repo_path,
424 PathBuf::from("/Users/test/code/my-project")
425 );
426 assert_eq!(loaded.project_name, "my-project");
427 assert_eq!(loaded.created_at, session.created_at);
428 assert_eq!(loaded.status, SessionStatus::Active);
429 assert_eq!(loaded.worktrees.len(), 3);
430 assert_eq!(loaded.worktrees[0].branch, "feature/auth");
431 assert_eq!(loaded.worktrees[0].cli, "claude");
432 assert_eq!(loaded.worktrees[1].branch, "fix/api");
433 assert_eq!(loaded.worktrees[1].cli, "gemini");
434 assert_eq!(loaded.worktrees[2].branch, "feature/logging");
435 }
436
437 #[test]
440 fn saving_again_replaces_previous_state() {
441 let dir = TempDir::new().unwrap();
442 let mut session = sample_session();
443 save_session_in(&session, dir.path()).unwrap();
444
445 session.status = SessionStatus::Stopped;
446 session.worktrees.pop();
447 save_session_in(&session, dir.path()).unwrap();
448
449 let loaded = load_session_from("paw-my-project", dir.path())
450 .unwrap()
451 .expect("session should exist");
452
453 assert_eq!(loaded.status, SessionStatus::Stopped);
454 assert_eq!(loaded.worktrees.len(), 2);
455 }
456
457 #[test]
460 fn loading_nonexistent_session_returns_none() {
461 let dir = TempDir::new().unwrap();
462 let result = load_session_from("nonexistent", dir.path()).unwrap();
463 assert!(result.is_none());
464 }
465
466 #[test]
470 fn finds_correct_session_among_multiple_by_repo_path() {
471 let dir = TempDir::new().unwrap();
472
473 let mut session_a = sample_session();
474 session_a.session_name = "paw-project-a".to_string();
475 session_a.repo_path = PathBuf::from("/Users/test/code/project-a");
476
477 let mut session_b = sample_session();
478 session_b.session_name = "paw-project-b".to_string();
479 session_b.repo_path = PathBuf::from("/Users/test/code/project-b");
480
481 save_session_in(&session_a, dir.path()).unwrap();
482 save_session_in(&session_b, dir.path()).unwrap();
483
484 let found = find_session_for_repo_in(Path::new("/Users/test/code/project-b"), dir.path())
485 .unwrap()
486 .expect("should find session for project-b");
487
488 assert_eq!(found.session_name, "paw-project-b");
489 assert_eq!(found.repo_path, PathBuf::from("/Users/test/code/project-b"));
490 }
491
492 #[test]
493 fn find_returns_none_when_no_repo_matches() {
494 let dir = TempDir::new().unwrap();
495 save_session_in(&sample_session(), dir.path()).unwrap();
496
497 let found =
498 find_session_for_repo_in(Path::new("/Users/test/code/other-project"), dir.path())
499 .unwrap();
500 assert!(found.is_none());
501 }
502
503 #[test]
504 fn find_returns_none_when_no_sessions_exist() {
505 let dir = TempDir::new().unwrap();
506 let missing = dir.path().join("does-not-exist");
507 let found = find_session_for_repo_in(Path::new("/any"), &missing).unwrap();
508 assert!(found.is_none());
509 }
510
511 #[test]
514 fn deleted_session_is_no_longer_loadable() {
515 let dir = TempDir::new().unwrap();
516 save_session_in(&sample_session(), dir.path()).unwrap();
517
518 delete_session_in("paw-my-project", dir.path()).unwrap();
519
520 let loaded = load_session_from("paw-my-project", dir.path()).unwrap();
521 assert!(loaded.is_none());
522 }
523
524 #[test]
525 fn deleting_nonexistent_session_succeeds() {
526 let dir = TempDir::new().unwrap();
527 delete_session_in("nonexistent", dir.path()).unwrap();
528 }
529
530 #[test]
533 fn file_says_active_and_tmux_alive_means_active() {
534 let session = sample_session();
535 assert_eq!(session.effective_status(|_| true), SessionStatus::Active);
536 }
537
538 #[test]
539 fn file_says_active_but_tmux_dead_means_stopped() {
540 let session = sample_session();
541 assert_eq!(session.effective_status(|_| false), SessionStatus::Stopped);
542 }
543
544 #[test]
545 fn file_says_stopped_stays_stopped_regardless_of_tmux() {
546 let mut session = sample_session();
547 session.status = SessionStatus::Stopped;
548 assert_eq!(session.effective_status(|_| true), SessionStatus::Stopped);
550 }
551
552 #[test]
555 fn session_status_displays_as_lowercase_string() {
556 assert_eq!(SessionStatus::Active.to_string(), "active");
557 assert_eq!(SessionStatus::Stopped.to_string(), "stopped");
558 }
559
560 #[test]
565 fn session_with_broker_fields_round_trips() {
566 let dir = TempDir::new().unwrap();
567 let mut session = sample_session();
568 session.broker_port = Some(9119);
569 session.broker_bind = Some("127.0.0.1".to_string());
570 session.broker_log_path = Some(PathBuf::from("/tmp/broker.log"));
571
572 save_session_in(&session, dir.path()).unwrap();
573
574 let loaded = load_session_from("paw-my-project", dir.path())
575 .unwrap()
576 .expect("session should exist");
577
578 assert_eq!(loaded.broker_port, Some(9119));
579 assert_eq!(loaded.broker_bind.as_deref(), Some("127.0.0.1"));
580 assert_eq!(
581 loaded.broker_log_path,
582 Some(PathBuf::from("/tmp/broker.log"))
583 );
584 }
585
586 #[test]
587 fn v020_session_json_loads_with_broker_fields_as_none() {
588 let dir = TempDir::new().unwrap();
589 let json = r#"{
591 "session_name": "paw-legacy",
592 "repo_path": "/tmp/legacy-repo",
593 "project_name": "legacy",
594 "created_at": "2024-03-23T12:00:00Z",
595 "status": "active",
596 "worktrees": []
597 }"#;
598 std::fs::write(dir.path().join("paw-legacy.json"), json).unwrap();
599
600 let loaded = load_session_from("paw-legacy", dir.path())
601 .unwrap()
602 .expect("session should load");
603
604 assert!(loaded.broker_port.is_none());
605 assert!(loaded.broker_bind.is_none());
606 assert!(loaded.broker_log_path.is_none());
607 assert_eq!(loaded.session_name, "paw-legacy");
608 }
609
610 #[test]
611 fn session_with_broker_fields_serializes_them() {
612 let dir = TempDir::new().unwrap();
613 let mut session = sample_session();
614 session.broker_port = Some(9119);
615 session.broker_bind = Some("127.0.0.1".to_string());
616 session.broker_log_path = Some(PathBuf::from("/tmp/broker.log"));
617 save_session_in(&session, dir.path()).unwrap();
618
619 let json = std::fs::read_to_string(dir.path().join("paw-my-project.json")).unwrap();
620 assert!(
621 json.contains("broker_port"),
622 "JSON should contain broker_port"
623 );
624 assert!(
625 json.contains("broker_bind"),
626 "JSON should contain broker_bind"
627 );
628 assert!(
629 json.contains("broker_log_path"),
630 "JSON should contain broker_log_path"
631 );
632 }
633
634 #[test]
635 fn session_without_broker_fields_omits_them_from_json() {
636 let dir = TempDir::new().unwrap();
637 let session = sample_session(); save_session_in(&session, dir.path()).unwrap();
639
640 let json = std::fs::read_to_string(dir.path().join("paw-my-project.json")).unwrap();
641 assert!(
642 !json.contains("broker_port"),
643 "JSON should not contain broker_port when None"
644 );
645 assert!(
646 !json.contains("broker_bind"),
647 "JSON should not contain broker_bind when None"
648 );
649 assert!(
650 !json.contains("broker_log_path"),
651 "JSON should not contain broker_log_path when None"
652 );
653 }
654
655 #[test]
658 fn recovery_after_tmux_crash_has_all_data_to_reconstruct() {
659 let dir = TempDir::new().unwrap();
660 let session = sample_session();
661 save_session_in(&session, dir.path()).unwrap();
662
663 let recovered = load_session_from("paw-my-project", dir.path())
665 .unwrap()
666 .expect("session state should survive tmux crash");
667
668 assert_eq!(recovered.session_name, "paw-my-project");
670 assert_eq!(
672 recovered.repo_path,
673 PathBuf::from("/Users/test/code/my-project")
674 );
675 assert_eq!(recovered.worktrees.len(), 3);
677 for wt in &recovered.worktrees {
678 assert!(!wt.branch.is_empty());
679 assert!(!wt.worktree_path.as_os_str().is_empty());
680 assert!(!wt.cli.is_empty());
681 }
682 assert_eq!(
684 recovered.effective_status(|_| false),
685 SessionStatus::Stopped
686 );
687 }
688}