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 Paused,
32 Stopped,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
39#[serde(rename_all = "lowercase")]
40pub enum SessionMode {
41 #[default]
44 Bare,
45 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct WorktreeEntry {
63 pub branch: String,
65 pub worktree_path: PathBuf,
67 pub cli: String,
69 #[serde(default)]
72 pub branch_created: bool,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[allow(clippy::struct_field_names)]
78pub struct Session {
79 pub session_name: String,
81 pub repo_path: PathBuf,
83 pub project_name: String,
85 #[serde(
87 serialize_with = "serialize_system_time",
88 deserialize_with = "deserialize_system_time"
89 )]
90 pub created_at: SystemTime,
91 pub status: SessionStatus,
93 pub worktrees: Vec<WorktreeEntry>,
95
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub broker_port: Option<u16>,
99
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub broker_bind: Option<String>,
103
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub broker_log_path: Option<PathBuf>,
107
108 #[serde(default)]
112 pub mode: SessionMode,
113
114 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub dashboard_pane: Option<u32>,
119}
120
121impl Session {
122 pub fn effective_status(&self, is_tmux_alive: impl Fn(&str) -> bool) -> SessionStatus {
129 match self.status {
130 SessionStatus::Active | SessionStatus::Paused if !is_tmux_alive(&self.session_name) => {
131 SessionStatus::Stopped
132 }
133 _ => self.status.clone(),
134 }
135 }
136}
137
138pub fn save_session(session: &Session) -> Result<(), PawError> {
148 save_session_in(session, &sessions_dir()?)
149}
150
151pub fn find_session_for_repo(repo_path: &Path) -> Result<Option<Session>, PawError> {
156 find_session_for_repo_in(repo_path, &sessions_dir()?)
157}
158
159pub fn delete_session(session_name: &str) -> Result<(), PawError> {
163 delete_session_in(session_name, &sessions_dir()?)
164}
165
166pub fn save_session_in(session: &Session, dir: &Path) -> Result<(), PawError> {
172 fs::create_dir_all(dir)
173 .map_err(|e| PawError::SessionError(format!("failed to create sessions dir: {e}")))?;
174
175 let json = serde_json::to_string_pretty(session)
176 .map_err(|e| PawError::SessionError(format!("failed to serialize session: {e}")))?;
177
178 let final_path = dir.join(format!("{}.json", session.session_name));
179 let tmp_path = dir.join(format!("{}.tmp", session.session_name));
180
181 fs::write(&tmp_path, json.as_bytes())
182 .map_err(|e| PawError::SessionError(format!("failed to write temp file: {e}")))?;
183
184 fs::rename(&tmp_path, &final_path)
185 .map_err(|e| PawError::SessionError(format!("failed to rename temp file: {e}")))?;
186
187 Ok(())
188}
189
190pub fn load_session_from(session_name: &str, dir: &Path) -> Result<Option<Session>, PawError> {
192 let path = dir.join(format!("{session_name}.json"));
193
194 let contents = match fs::read_to_string(&path) {
195 Ok(s) => s,
196 Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
197 Err(e) => {
198 return Err(PawError::SessionError(format!(
199 "failed to read session file: {e}"
200 )));
201 }
202 };
203
204 let session: Session = serde_json::from_str(&contents)
205 .map_err(|e| PawError::SessionError(format!("failed to parse session file: {e}")))?;
206
207 Ok(Some(session))
208}
209
210pub fn find_session_for_repo_in(repo_path: &Path, dir: &Path) -> Result<Option<Session>, PawError> {
212 let entries = match fs::read_dir(dir) {
213 Ok(e) => e,
214 Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
215 Err(e) => {
216 return Err(PawError::SessionError(format!(
217 "failed to read sessions dir: {e}"
218 )));
219 }
220 };
221
222 for entry in entries {
223 let entry =
224 entry.map_err(|e| PawError::SessionError(format!("failed to read dir entry: {e}")))?;
225 let path = entry.path();
226
227 if path.extension().and_then(|e| e.to_str()) != Some("json") {
228 continue;
229 }
230
231 let contents = fs::read_to_string(&path).map_err(|e| {
232 PawError::SessionError(format!("failed to read {}: {e}", path.display()))
233 })?;
234
235 let session: Session = match serde_json::from_str(&contents) {
236 Ok(s) => s,
237 Err(_) => continue, };
239
240 if session.repo_path == repo_path {
241 return Ok(Some(session));
242 }
243 }
244
245 Ok(None)
246}
247
248pub fn delete_session_in(session_name: &str, dir: &Path) -> Result<(), PawError> {
250 let path = dir.join(format!("{session_name}.json"));
251
252 match fs::remove_file(&path) {
253 Ok(()) => Ok(()),
254 Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
255 Err(e) => Err(PawError::SessionError(format!(
256 "failed to delete session file: {e}"
257 ))),
258 }
259}
260
261pub fn session_state_dir() -> Result<PathBuf, PawError> {
269 sessions_dir()
270}
271
272fn sessions_dir() -> Result<PathBuf, PawError> {
274 let base = crate::dirs::data_dir().ok_or_else(|| {
275 PawError::SessionError("could not determine XDG data directory".to_string())
276 })?;
277 Ok(base.join("git-paw").join("sessions"))
278}
279
280fn format_iso8601(time: SystemTime) -> Result<String, PawError> {
286 let secs = time
287 .duration_since(UNIX_EPOCH)
288 .map_err(|e| PawError::SessionError(format!("time before unix epoch: {e}")))?
289 .as_secs();
290
291 let (year, month, day, hour, min, sec) = secs_to_civil(secs);
292 Ok(format!(
293 "{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z"
294 ))
295}
296
297fn parse_iso8601(s: &str) -> Result<SystemTime, PawError> {
299 let err = || PawError::SessionError(format!("invalid ISO 8601 timestamp: {s}"));
300
301 let s = s.strip_suffix('Z').ok_or_else(err)?;
303 let (date, time) = s.split_once('T').ok_or_else(err)?;
304
305 let date_parts: Vec<&str> = date.split('-').collect();
306 let time_parts: Vec<&str> = time.split(':').collect();
307
308 if date_parts.len() != 3 || time_parts.len() != 3 {
309 return Err(err());
310 }
311
312 let year: u64 = date_parts[0].parse().map_err(|_| err())?;
313 let month: u64 = date_parts[1].parse().map_err(|_| err())?;
314 let day: u64 = date_parts[2].parse().map_err(|_| err())?;
315 let hour: u64 = time_parts[0].parse().map_err(|_| err())?;
316 let min: u64 = time_parts[1].parse().map_err(|_| err())?;
317 let sec: u64 = time_parts[2].parse().map_err(|_| err())?;
318
319 let secs = civil_to_secs(year, month, day, hour, min, sec).ok_or_else(err)?;
320 Ok(UNIX_EPOCH + Duration::from_secs(secs))
321}
322
323fn secs_to_civil(secs: u64) -> (u64, u64, u64, u64, u64, u64) {
325 let sec_of_day = secs % 86400;
326 let hour = sec_of_day / 3600;
327 let min = (sec_of_day % 3600) / 60;
328 let sec = sec_of_day % 60;
329
330 #[allow(clippy::cast_possible_wrap)]
333 let mut days = (secs / 86400).cast_signed();
334
335 days += 719_468; let era = days / 146_097;
337 let doe = days - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
339 let y = yoe + era * 400;
340 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1;
343 let m = if mp < 10 { mp + 3 } else { mp - 9 };
344 let y = if m <= 2 { y + 1 } else { y };
345
346 #[allow(clippy::cast_sign_loss)]
347 (
348 y.cast_unsigned(),
349 m.cast_unsigned(),
350 d.cast_unsigned(),
351 hour,
352 min,
353 sec,
354 )
355}
356
357fn civil_to_secs(year: u64, month: u64, day: u64, hour: u64, min: u64, sec: u64) -> Option<u64> {
359 if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || min > 59 || sec > 59 {
360 return None;
361 }
362
363 #[allow(clippy::cast_possible_wrap)]
364 let y = year.cast_signed();
365 #[allow(clippy::cast_possible_wrap)]
366 let m = month.cast_signed();
367 #[allow(clippy::cast_possible_wrap)]
368 let d = day.cast_signed();
369
370 let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
372 let era = y / 400;
373 let yoe = y - era * 400;
374 let doy = (153 * m + 2) / 5 + d - 1;
375 let doe = 365 * yoe + yoe / 4 - yoe / 100 + doy;
376 let days = era * 146_097 + doe - 719_468;
377
378 if days < 0 {
379 return None;
380 }
381
382 #[allow(clippy::cast_sign_loss)]
383 Some(days.cast_unsigned() * 86400 + hour * 3600 + min * 60 + sec)
384}
385
386fn serialize_system_time<S: Serializer>(time: &SystemTime, ser: S) -> Result<S::Ok, S::Error> {
391 let s = format_iso8601(*time).map_err(serde::ser::Error::custom)?;
392 ser.serialize_str(&s)
393}
394
395fn deserialize_system_time<'de, D: Deserializer<'de>>(de: D) -> Result<SystemTime, D::Error> {
396 let s: String = Deserialize::deserialize(de)?;
397 parse_iso8601(&s).map_err(serde::de::Error::custom)
398}
399
400#[cfg(test)]
405mod tests {
406 use super::*;
407 use tempfile::TempDir;
408
409 fn sample_session() -> Session {
411 Session {
412 session_name: "paw-my-project".to_string(),
413 repo_path: PathBuf::from("/Users/test/code/my-project"),
414 project_name: "my-project".to_string(),
415 #[allow(clippy::duration_suboptimal_units)]
419 created_at: UNIX_EPOCH + Duration::from_secs(1_711_200_000),
420 status: SessionStatus::Active,
421 worktrees: vec![
422 WorktreeEntry {
423 branch: "feature/auth".to_string(),
424 worktree_path: PathBuf::from("/Users/test/code/my-project-feature-auth"),
425 cli: "claude".to_string(),
426 branch_created: false,
427 },
428 WorktreeEntry {
429 branch: "fix/api".to_string(),
430 worktree_path: PathBuf::from("/Users/test/code/my-project-fix-api"),
431 cli: "gemini".to_string(),
432 branch_created: false,
433 },
434 WorktreeEntry {
435 branch: "feature/logging".to_string(),
436 worktree_path: PathBuf::from("/Users/test/code/my-project-feature-logging"),
437 cli: "claude".to_string(),
438 branch_created: false,
439 },
440 ],
441 broker_port: None,
442 broker_bind: None,
443 broker_log_path: None,
444 mode: SessionMode::Bare,
445 dashboard_pane: None,
446 }
447 }
448
449 #[test]
453 fn saved_session_can_be_loaded_with_all_fields_intact() {
454 let dir = TempDir::new().unwrap();
455 let session = sample_session();
456 save_session_in(&session, dir.path()).unwrap();
457
458 let loaded = load_session_from("paw-my-project", dir.path())
459 .unwrap()
460 .expect("session should exist");
461
462 assert_eq!(loaded.session_name, "paw-my-project");
463 assert_eq!(
464 loaded.repo_path,
465 PathBuf::from("/Users/test/code/my-project")
466 );
467 assert_eq!(loaded.project_name, "my-project");
468 assert_eq!(loaded.created_at, session.created_at);
469 assert_eq!(loaded.status, SessionStatus::Active);
470 assert_eq!(loaded.worktrees.len(), 3);
471 assert_eq!(loaded.worktrees[0].branch, "feature/auth");
472 assert_eq!(loaded.worktrees[0].cli, "claude");
473 assert_eq!(loaded.worktrees[1].branch, "fix/api");
474 assert_eq!(loaded.worktrees[1].cli, "gemini");
475 assert_eq!(loaded.worktrees[2].branch, "feature/logging");
476 }
477
478 #[test]
481 fn saving_again_replaces_previous_state() {
482 let dir = TempDir::new().unwrap();
483 let mut session = sample_session();
484 save_session_in(&session, dir.path()).unwrap();
485
486 session.status = SessionStatus::Stopped;
487 session.worktrees.pop();
488 save_session_in(&session, dir.path()).unwrap();
489
490 let loaded = load_session_from("paw-my-project", dir.path())
491 .unwrap()
492 .expect("session should exist");
493
494 assert_eq!(loaded.status, SessionStatus::Stopped);
495 assert_eq!(loaded.worktrees.len(), 2);
496 }
497
498 #[test]
501 fn loading_nonexistent_session_returns_none() {
502 let dir = TempDir::new().unwrap();
503 let result = load_session_from("nonexistent", dir.path()).unwrap();
504 assert!(result.is_none());
505 }
506
507 #[test]
511 fn finds_correct_session_among_multiple_by_repo_path() {
512 let dir = TempDir::new().unwrap();
513
514 let mut session_a = sample_session();
515 session_a.session_name = "paw-project-a".to_string();
516 session_a.repo_path = PathBuf::from("/Users/test/code/project-a");
517
518 let mut session_b = sample_session();
519 session_b.session_name = "paw-project-b".to_string();
520 session_b.repo_path = PathBuf::from("/Users/test/code/project-b");
521
522 save_session_in(&session_a, dir.path()).unwrap();
523 save_session_in(&session_b, dir.path()).unwrap();
524
525 let found = find_session_for_repo_in(Path::new("/Users/test/code/project-b"), dir.path())
526 .unwrap()
527 .expect("should find session for project-b");
528
529 assert_eq!(found.session_name, "paw-project-b");
530 assert_eq!(found.repo_path, PathBuf::from("/Users/test/code/project-b"));
531 }
532
533 #[test]
534 fn find_returns_none_when_no_repo_matches() {
535 let dir = TempDir::new().unwrap();
536 save_session_in(&sample_session(), dir.path()).unwrap();
537
538 let found =
539 find_session_for_repo_in(Path::new("/Users/test/code/other-project"), dir.path())
540 .unwrap();
541 assert!(found.is_none());
542 }
543
544 #[test]
545 fn find_returns_none_when_no_sessions_exist() {
546 let dir = TempDir::new().unwrap();
547 let missing = dir.path().join("does-not-exist");
548 let found = find_session_for_repo_in(Path::new("/any"), &missing).unwrap();
549 assert!(found.is_none());
550 }
551
552 #[test]
555 fn deleted_session_is_no_longer_loadable() {
556 let dir = TempDir::new().unwrap();
557 save_session_in(&sample_session(), dir.path()).unwrap();
558
559 delete_session_in("paw-my-project", dir.path()).unwrap();
560
561 let loaded = load_session_from("paw-my-project", dir.path()).unwrap();
562 assert!(loaded.is_none());
563 }
564
565 #[test]
566 fn deleting_nonexistent_session_succeeds() {
567 let dir = TempDir::new().unwrap();
568 delete_session_in("nonexistent", dir.path()).unwrap();
569 }
570
571 #[test]
574 fn file_says_active_and_tmux_alive_means_active() {
575 let session = sample_session();
576 assert_eq!(session.effective_status(|_| true), SessionStatus::Active);
577 }
578
579 #[test]
580 fn file_says_active_but_tmux_dead_means_stopped() {
581 let session = sample_session();
582 assert_eq!(session.effective_status(|_| false), SessionStatus::Stopped);
583 }
584
585 #[test]
586 fn file_says_stopped_stays_stopped_regardless_of_tmux() {
587 let mut session = sample_session();
588 session.status = SessionStatus::Stopped;
589 assert_eq!(session.effective_status(|_| true), SessionStatus::Stopped);
591 }
592
593 #[test]
596 fn session_status_displays_as_lowercase_string() {
597 assert_eq!(SessionStatus::Active.to_string(), "active");
598 assert_eq!(SessionStatus::Paused.to_string(), "paused");
599 assert_eq!(SessionStatus::Stopped.to_string(), "stopped");
600 }
601
602 #[test]
605 fn paused_status_serializes_lowercase() {
606 let dir = TempDir::new().unwrap();
607 let mut session = sample_session();
608 session.status = SessionStatus::Paused;
609 save_session_in(&session, dir.path()).unwrap();
610
611 let json = std::fs::read_to_string(dir.path().join("paw-my-project.json")).unwrap();
612 assert!(
613 json.contains("\"status\": \"paused\""),
614 "JSON should contain `\"status\": \"paused\"`, got: {json}"
615 );
616 }
617
618 #[test]
619 fn paused_session_round_trips() {
620 let dir = TempDir::new().unwrap();
621 let mut session = sample_session();
622 session.status = SessionStatus::Paused;
623 save_session_in(&session, dir.path()).unwrap();
624
625 let loaded = load_session_from("paw-my-project", dir.path())
626 .unwrap()
627 .expect("session should exist");
628 assert_eq!(loaded.status, SessionStatus::Paused);
629 }
630
631 #[test]
632 fn effective_status_paused_alive_remains_paused() {
633 let mut session = sample_session();
634 session.status = SessionStatus::Paused;
635 assert_eq!(session.effective_status(|_| true), SessionStatus::Paused);
636 }
637
638 #[test]
639 fn effective_status_paused_dead_downgrades_to_stopped() {
640 let mut session = sample_session();
641 session.status = SessionStatus::Paused;
642 assert_eq!(session.effective_status(|_| false), SessionStatus::Stopped);
643 }
644
645 #[test]
648 fn dashboard_pane_round_trips() {
649 let dir = TempDir::new().unwrap();
650 let mut session = sample_session();
651 session.dashboard_pane = Some(1);
652 save_session_in(&session, dir.path()).unwrap();
653
654 let loaded = load_session_from("paw-my-project", dir.path())
655 .unwrap()
656 .expect("session should exist");
657 assert_eq!(loaded.dashboard_pane, Some(1));
658 }
659
660 #[test]
661 fn v04_session_without_dashboard_pane_loads_as_none() {
662 let dir = TempDir::new().unwrap();
663 let json = r#"{
664 "session_name": "paw-legacy-dashboard",
665 "repo_path": "/tmp/legacy-repo",
666 "project_name": "legacy",
667 "created_at": "2024-03-23T12:00:00Z",
668 "status": "active",
669 "worktrees": []
670 }"#;
671 std::fs::write(dir.path().join("paw-legacy-dashboard.json"), json).unwrap();
672
673 let loaded = load_session_from("paw-legacy-dashboard", dir.path())
674 .unwrap()
675 .expect("session should load");
676 assert!(
677 loaded.dashboard_pane.is_none(),
678 "v0.4 session should load with dashboard_pane = None"
679 );
680 }
681
682 #[test]
683 fn dashboard_pane_none_is_omitted_from_json() {
684 let dir = TempDir::new().unwrap();
685 let session = sample_session(); save_session_in(&session, dir.path()).unwrap();
687
688 let json = std::fs::read_to_string(dir.path().join("paw-my-project.json")).unwrap();
689 assert!(
690 !json.contains("dashboard_pane"),
691 "JSON should not include dashboard_pane when None, got: {json}"
692 );
693 }
694
695 #[test]
700 fn session_with_broker_fields_round_trips() {
701 let dir = TempDir::new().unwrap();
702 let mut session = sample_session();
703 session.broker_port = Some(9119);
704 session.broker_bind = Some("127.0.0.1".to_string());
705 session.broker_log_path = Some(PathBuf::from("/tmp/broker.log"));
706
707 save_session_in(&session, dir.path()).unwrap();
708
709 let loaded = load_session_from("paw-my-project", dir.path())
710 .unwrap()
711 .expect("session should exist");
712
713 assert_eq!(loaded.broker_port, Some(9119));
714 assert_eq!(loaded.broker_bind.as_deref(), Some("127.0.0.1"));
715 assert_eq!(
716 loaded.broker_log_path,
717 Some(PathBuf::from("/tmp/broker.log"))
718 );
719 }
720
721 #[test]
722 fn v020_session_json_loads_with_broker_fields_as_none() {
723 let dir = TempDir::new().unwrap();
724 let json = r#"{
726 "session_name": "paw-legacy",
727 "repo_path": "/tmp/legacy-repo",
728 "project_name": "legacy",
729 "created_at": "2024-03-23T12:00:00Z",
730 "status": "active",
731 "worktrees": []
732 }"#;
733 std::fs::write(dir.path().join("paw-legacy.json"), json).unwrap();
734
735 let loaded = load_session_from("paw-legacy", dir.path())
736 .unwrap()
737 .expect("session should load");
738
739 assert!(loaded.broker_port.is_none());
740 assert!(loaded.broker_bind.is_none());
741 assert!(loaded.broker_log_path.is_none());
742 assert_eq!(loaded.session_name, "paw-legacy");
743 }
744
745 #[test]
746 fn session_with_broker_fields_serializes_them() {
747 let dir = TempDir::new().unwrap();
748 let mut session = sample_session();
749 session.broker_port = Some(9119);
750 session.broker_bind = Some("127.0.0.1".to_string());
751 session.broker_log_path = Some(PathBuf::from("/tmp/broker.log"));
752 save_session_in(&session, dir.path()).unwrap();
753
754 let json = std::fs::read_to_string(dir.path().join("paw-my-project.json")).unwrap();
755 assert!(
756 json.contains("broker_port"),
757 "JSON should contain broker_port"
758 );
759 assert!(
760 json.contains("broker_bind"),
761 "JSON should contain broker_bind"
762 );
763 assert!(
764 json.contains("broker_log_path"),
765 "JSON should contain broker_log_path"
766 );
767 }
768
769 #[test]
770 fn session_without_broker_fields_omits_them_from_json() {
771 let dir = TempDir::new().unwrap();
772 let session = sample_session(); save_session_in(&session, dir.path()).unwrap();
774
775 let json = std::fs::read_to_string(dir.path().join("paw-my-project.json")).unwrap();
776 assert!(
777 !json.contains("broker_port"),
778 "JSON should not contain broker_port when None"
779 );
780 assert!(
781 !json.contains("broker_bind"),
782 "JSON should not contain broker_bind when None"
783 );
784 assert!(
785 !json.contains("broker_log_path"),
786 "JSON should not contain broker_log_path when None"
787 );
788 }
789
790 #[test]
793 fn recovery_after_tmux_crash_has_all_data_to_reconstruct() {
794 let dir = TempDir::new().unwrap();
795 let session = sample_session();
796 save_session_in(&session, dir.path()).unwrap();
797
798 let recovered = load_session_from("paw-my-project", dir.path())
800 .unwrap()
801 .expect("session state should survive tmux crash");
802
803 assert_eq!(recovered.session_name, "paw-my-project");
805 assert_eq!(
807 recovered.repo_path,
808 PathBuf::from("/Users/test/code/my-project")
809 );
810 assert_eq!(recovered.worktrees.len(), 3);
812 for wt in &recovered.worktrees {
813 assert!(!wt.branch.is_empty());
814 assert!(!wt.worktree_path.as_os_str().is_empty());
815 assert!(!wt.cli.is_empty());
816 }
817 assert_eq!(
819 recovered.effective_status(|_| false),
820 SessionStatus::Stopped
821 );
822 }
823
824 #[test]
827 fn session_with_broker_enabled_has_recovery_data() {
828 let dir = TempDir::new().unwrap();
829 let mut session = sample_session();
830 session.broker_port = Some(9119);
831 session.broker_bind = Some("127.0.0.1".to_string());
832 save_session_in(&session, dir.path()).unwrap();
833
834 let recovered = load_session_from("paw-my-project", dir.path())
835 .unwrap()
836 .expect("session should load");
837
838 assert_eq!(recovered.broker_port, Some(9119));
840 assert_eq!(recovered.broker_bind.as_deref(), Some("127.0.0.1"));
841 }
842
843 #[test]
844 fn session_without_broker_has_no_recovery_data() {
845 let dir = TempDir::new().unwrap();
846 let session = sample_session(); save_session_in(&session, dir.path()).unwrap();
848
849 let recovered = load_session_from("paw-my-project", dir.path())
850 .unwrap()
851 .expect("session should load");
852
853 assert!(recovered.broker_port.is_none());
855 assert!(recovered.broker_bind.is_none());
856 }
857}