1use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, RwLock};
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18const DEFAULT_TIMEOUT_SECS: u64 = 60;
19const DEFAULT_MAX_OUTPUT: usize = 102_400; #[derive(Debug, Default)]
24pub struct SessionConfig {
25 pub root: PathBuf,
26 pub timeout_secs: Option<u64>,
27 pub max_output: Option<usize>,
28 pub alias: Option<String>,
34 pub global_recipe_dirs: Vec<PathBuf>,
41 pub worktrees_dir: Option<PathBuf>,
61}
62
63#[derive(Debug, thiserror::Error)]
65pub enum SessionError {
66 #[error(
67 "session root path no longer exists, please call session_start again: {}",
68 _0.display()
69 )]
70 RootGone(PathBuf),
71}
72
73#[derive(Debug, thiserror::Error)]
75pub enum CoreError {
76 #[error("session root does not exist: {}", _0.display())]
77 RootNotFound(PathBuf),
78 #[error("no active session — call session_start first")]
79 NoSession,
80 #[error("session not found: {0}")]
81 SessionNotFound(String),
82 #[error("alias already in use: {0}")]
83 AliasConflict(String),
84}
85
86#[derive(Debug)]
95pub struct Session {
96 root: PathBuf,
97 session_id: String,
98 alias: RwLock<Option<String>>,
99 timeout: Duration,
100 max_output: usize,
101 global_recipe_dirs: Vec<PathBuf>,
102 worktrees_dir: PathBuf,
103 created_at: u64,
104 last_used_at: RwLock<u64>,
105}
106
107fn resolve_worktrees_dir(root: &Path, override_dir: Option<PathBuf>) -> PathBuf {
113 const DEFAULT_WORKTREES_SUBDIR: &str = ".worktrees";
114 let candidate = override_dir
115 .or_else(|| std::env::var_os("LDS_WORKTREES_DIR").map(PathBuf::from))
116 .unwrap_or_else(|| PathBuf::from(DEFAULT_WORKTREES_SUBDIR));
117 if candidate.is_absolute() {
118 candidate
119 } else {
120 root.join(candidate)
121 }
122}
123
124impl Session {
125 pub fn new(config: SessionConfig) -> Result<Self, CoreError> {
126 let root = config.root;
127 if !root.is_dir() {
128 tracing::warn!(root = %root.display(), "session root does not exist");
129 return Err(CoreError::RootNotFound(root));
130 }
131 let session_id = session_id_new();
132 let timeout = Duration::from_secs(config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
133 let max_output = config.max_output.unwrap_or(DEFAULT_MAX_OUTPUT);
134 let now = epoch_secs();
135 tracing::info!(
136 root = %root.display(),
137 session_id = %session_id,
138 alias = ?config.alias,
139 timeout_secs = timeout.as_secs(),
140 max_output,
141 "session started"
142 );
143 let global_recipe_dirs = config.global_recipe_dirs;
144 let worktrees_dir = resolve_worktrees_dir(&root, config.worktrees_dir);
145 Ok(Self {
146 root,
147 session_id,
148 alias: RwLock::new(config.alias),
149 timeout,
150 max_output,
151 global_recipe_dirs,
152 worktrees_dir,
153 created_at: now,
154 last_used_at: RwLock::new(now),
155 })
156 }
157
158 pub fn worktrees_dir(&self) -> &Path {
165 &self.worktrees_dir
166 }
167
168 pub fn root(&self) -> &Path {
169 &self.root
170 }
171
172 pub fn id(&self) -> &str {
173 &self.session_id
174 }
175
176 pub fn alias(&self) -> Option<String> {
177 self.alias.read().expect("alias lock poisoned").clone()
178 }
179
180 pub fn created_at(&self) -> u64 {
181 self.created_at
182 }
183
184 pub fn last_used_at(&self) -> u64 {
185 *self.last_used_at.read().expect("last_used lock poisoned")
186 }
187
188 pub fn touch(&self) {
189 if let Ok(mut g) = self.last_used_at.write() {
190 *g = epoch_secs();
191 }
192 }
193
194 pub(crate) fn set_alias(&self, alias: Option<String>) {
195 if let Ok(mut g) = self.alias.write() {
196 *g = alias;
197 }
198 }
199
200 pub fn timeout(&self) -> Duration {
201 self.timeout
202 }
203
204 pub fn max_output(&self) -> usize {
205 self.max_output
206 }
207
208 pub fn global_recipe_dirs(&self) -> &[PathBuf] {
209 &self.global_recipe_dirs
210 }
211
212 pub fn ensure_alive(&self) -> Result<(), SessionError> {
218 if !self.root.is_dir() {
219 tracing::warn!(root = %self.root.display(), "session root no longer exists");
220 return Err(SessionError::RootGone(self.root.clone()));
221 }
222 Ok(())
223 }
224}
225
226#[derive(Debug, Clone)]
237pub struct LdsState {
238 sessions: HashMap<String, Arc<Session>>,
240 aliases: HashMap<String, String>,
242 default_id: Option<String>,
245}
246
247#[derive(Debug, Clone)]
249pub struct SessionEntry {
250 pub session_id: String,
251 pub alias: Option<String>,
252 pub root: PathBuf,
253 pub created_at: u64,
254 pub last_used_at: u64,
255 pub is_default: bool,
256}
257
258impl SessionEntry {
259 fn from_session(s: &Session, is_default: bool) -> Self {
260 Self {
261 session_id: s.id().to_string(),
262 alias: s.alias(),
263 root: s.root().to_path_buf(),
264 created_at: s.created_at(),
265 last_used_at: s.last_used_at(),
266 is_default,
267 }
268 }
269}
270
271impl LdsState {
272 pub fn new() -> Self {
273 Self {
274 sessions: HashMap::new(),
275 aliases: HashMap::new(),
276 default_id: None,
277 }
278 }
279
280 pub fn create_session(
288 &mut self,
289 config: SessionConfig,
290 make_default: bool,
291 ) -> Result<Arc<Session>, CoreError> {
292 if let Some(alias) = &config.alias
293 && self.aliases.contains_key(alias)
294 {
295 return Err(CoreError::AliasConflict(alias.clone()));
296 }
297 let alias_clone = config.alias.clone();
298 let session = Arc::new(Session::new(config)?);
299 let id = session.id().to_string();
300 self.sessions.insert(id.clone(), Arc::clone(&session));
301 if let Some(alias) = alias_clone {
302 self.aliases.insert(alias, id.clone());
303 }
304 if make_default || self.default_id.is_none() {
305 self.default_id = Some(id);
306 }
307 Ok(session)
308 }
309
310 pub fn start_session(&mut self, config: SessionConfig) -> Result<Arc<Session>, CoreError> {
313 self.create_session(config, true)
314 }
315
316 pub fn resolve(&self, key: &str) -> Result<Arc<Session>, CoreError> {
321 if let Some(id) = self.aliases.get(key)
322 && let Some(s) = self.sessions.get(id)
323 {
324 return Ok(Arc::clone(s));
325 }
326 if let Some(s) = self.sessions.get(key) {
327 return Ok(Arc::clone(s));
328 }
329 Err(CoreError::SessionNotFound(key.to_string()))
330 }
331
332 pub fn session(&self) -> Result<Arc<Session>, CoreError> {
334 let id = self.default_id.as_ref().ok_or(CoreError::NoSession)?;
335 let s = self
336 .sessions
337 .get(id)
338 .ok_or_else(|| CoreError::SessionNotFound(id.clone()))?;
339 Ok(Arc::clone(s))
340 }
341
342 pub fn default_session_id(&self) -> Option<&str> {
343 self.default_id.as_deref()
344 }
345
346 pub fn list_sessions(&self) -> Vec<SessionEntry> {
348 let mut out: Vec<SessionEntry> = self
349 .sessions
350 .values()
351 .map(|s| {
352 let is_default = self.default_id.as_deref() == Some(s.id());
353 SessionEntry::from_session(s, is_default)
354 })
355 .collect();
356 out.sort_by_key(|e| e.created_at);
357 out
358 }
359
360 pub fn describe(&self, key: &str) -> Result<SessionEntry, CoreError> {
362 let s = self.resolve(key)?;
363 let is_default = self.default_id.as_deref() == Some(s.id());
364 Ok(SessionEntry::from_session(&s, is_default))
365 }
366
367 pub fn set_alias(&mut self, key: &str, alias: String) -> Result<(), CoreError> {
372 let target = self.resolve(key)?;
373 if let Some(owner_id) = self.aliases.get(&alias) {
374 if owner_id != target.id() {
375 return Err(CoreError::AliasConflict(alias));
376 }
377 return Ok(());
378 }
379 if let Some(prev) = target.alias() {
381 self.aliases.remove(&prev);
382 }
383 target.set_alias(Some(alias.clone()));
384 self.aliases.insert(alias, target.id().to_string());
385 Ok(())
386 }
387
388 pub fn unset_alias(&mut self, alias: &str) -> Result<(), CoreError> {
390 let id = self
391 .aliases
392 .remove(alias)
393 .ok_or_else(|| CoreError::SessionNotFound(alias.to_string()))?;
394 if let Some(s) = self.sessions.get(&id) {
395 s.set_alias(None);
396 }
397 Ok(())
398 }
399
400 pub fn close(&mut self, key: &str) -> Result<(), CoreError> {
406 let target = self.resolve(key)?;
407 let id = target.id().to_string();
408 if let Some(alias) = target.alias() {
409 self.aliases.remove(&alias);
410 }
411 self.sessions.remove(&id);
412 if self.default_id.as_deref() == Some(&id) {
413 self.default_id = None;
414 }
415 Ok(())
416 }
417}
418
419impl Default for LdsState {
420 fn default() -> Self {
421 Self::new()
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
429pub enum CheckStatus {
430 Ok,
431 Warn,
432 Fail,
433}
434
435impl CheckStatus {
436 pub fn as_str(&self) -> &'static str {
437 match self {
438 CheckStatus::Ok => "ok",
439 CheckStatus::Warn => "warn",
440 CheckStatus::Fail => "fail",
441 }
442 }
443}
444
445#[derive(Debug, Clone)]
446pub struct DoctorCheck {
447 pub name: &'static str,
448 pub status: CheckStatus,
449 pub evidence: String,
450}
451
452#[derive(Debug, Clone)]
453pub struct DoctorReport {
454 pub session_id: String,
455 pub alias: Option<String>,
456 pub verdict: CheckStatus,
457 pub checks: Vec<DoctorCheck>,
458}
459
460const IDLE_WARN_SECS: u64 = 60 * 60 * 6; impl LdsState {
463 pub fn doctor(&self, key: &str) -> Result<DoctorReport, CoreError> {
465 let s = self.resolve(key)?;
466 let mut checks: Vec<DoctorCheck> = Vec::new();
467
468 let root = s.root();
470 if root.is_dir() {
471 checks.push(DoctorCheck {
472 name: "root-exists",
473 status: CheckStatus::Ok,
474 evidence: format!("root={}", root.display()),
475 });
476 } else {
477 checks.push(DoctorCheck {
478 name: "root-exists",
479 status: CheckStatus::Fail,
480 evidence: format!("root missing: {}", root.display()),
481 });
482 }
483
484 let git_path = root.join(".git");
486 if git_path.exists() {
487 checks.push(DoctorCheck {
488 name: "git-bound",
489 status: CheckStatus::Ok,
490 evidence: format!("{}/.git present", root.display()),
491 });
492 } else {
493 checks.push(DoctorCheck {
494 name: "git-bound",
495 status: CheckStatus::Warn,
496 evidence: "no .git in root; git_* tools will fail".into(),
497 });
498 }
499
500 let journal_dir = root.join("workspace");
502 let journal_status = if !journal_dir.exists() {
503 CheckStatus::Warn
504 } else {
505 match tempfile::NamedTempFile::new_in(&journal_dir) {
507 Ok(_) => CheckStatus::Ok,
508 Err(_) => CheckStatus::Fail,
509 }
510 };
511 checks.push(DoctorCheck {
512 name: "journal-db-writable",
513 status: journal_status,
514 evidence: format!("probe dir = {}", journal_dir.display()),
515 });
516
517 let mut stale = Vec::new();
519 for candidate in ["workspace/.journal.db.lock", ".journal.db.lock"] {
520 let p = root.join(candidate);
521 if let Ok(meta) = std::fs::metadata(&p)
522 && let Ok(modified) = meta.modified()
523 && let Ok(age) = SystemTime::now().duration_since(modified)
524 && age.as_secs() > 3600
525 {
526 stale.push(p.display().to_string());
527 }
528 }
529 checks.push(DoctorCheck {
530 name: "stale-lock",
531 status: if stale.is_empty() {
532 CheckStatus::Ok
533 } else {
534 CheckStatus::Warn
535 },
536 evidence: if stale.is_empty() {
537 "no stale lock found".into()
538 } else {
539 format!("stale: {}", stale.join(","))
540 },
541 });
542
543 let conflict_count = self
545 .sessions
546 .values()
547 .filter(|other| other.root() == root && other.id() != s.id())
548 .count();
549 checks.push(DoctorCheck {
550 name: "ownership-drift",
551 status: if conflict_count == 0 {
552 CheckStatus::Ok
553 } else {
554 CheckStatus::Warn
555 },
556 evidence: if conflict_count == 0 {
557 "exclusive owner".into()
558 } else {
559 format!("{conflict_count} other session(s) share root")
560 },
561 });
562
563 checks.push(DoctorCheck {
565 name: "root-conflict",
566 status: match conflict_count {
567 0 => CheckStatus::Ok,
568 1 => CheckStatus::Warn,
569 _ => CheckStatus::Fail,
570 },
571 evidence: format!("conflicts={conflict_count}"),
572 });
573
574 let idle = epoch_secs().saturating_sub(s.last_used_at());
576 checks.push(DoctorCheck {
577 name: "ledger-leak",
578 status: if idle > IDLE_WARN_SECS {
579 CheckStatus::Warn
580 } else {
581 CheckStatus::Ok
582 },
583 evidence: format!("idle_secs={idle}"),
584 });
585
586 let verdict =
587 checks
588 .iter()
589 .map(|c| c.status.clone())
590 .fold(CheckStatus::Ok, |acc, s| match (&acc, &s) {
591 (CheckStatus::Fail, _) | (_, CheckStatus::Fail) => CheckStatus::Fail,
592 (CheckStatus::Warn, _) | (_, CheckStatus::Warn) => CheckStatus::Warn,
593 _ => CheckStatus::Ok,
594 });
595
596 Ok(DoctorReport {
597 session_id: s.id().to_string(),
598 alias: s.alias(),
599 verdict,
600 checks,
601 })
602 }
603}
604
605fn epoch_secs() -> u64 {
606 SystemTime::now()
607 .duration_since(UNIX_EPOCH)
608 .map(|d| d.as_secs())
609 .unwrap_or(0)
610}
611
612fn session_id_new() -> String {
619 use std::time::{SystemTime, UNIX_EPOCH};
620 let ts = SystemTime::now()
621 .duration_since(UNIX_EPOCH)
622 .unwrap_or_default()
623 .as_nanos();
624 let pid = std::process::id();
625 format!("{ts:x}-{pid:x}")
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631
632 #[test]
636 fn resolve_worktrees_dir_falls_back_to_default_subdir_when_no_override() {
637 let root = PathBuf::from("/tmp/some-session-root");
638 let got = resolve_worktrees_dir(&root, None);
639 assert_eq!(got, root.join(".worktrees"));
640 }
641
642 #[test]
643 fn resolve_worktrees_dir_explicit_absolute_config_wins_over_default() {
644 let root = PathBuf::from("/tmp/some-session-root");
645 let explicit = PathBuf::from("/var/pool/lds-wt");
646 let got = resolve_worktrees_dir(&root, Some(explicit.clone()));
647 assert_eq!(got, explicit);
648 }
649
650 #[test]
651 fn resolve_worktrees_dir_relative_config_is_anchored_to_root() {
652 let root = PathBuf::from("/tmp/some-session-root");
653 let got = resolve_worktrees_dir(&root, Some(PathBuf::from("wt-pool")));
654 assert_eq!(got, root.join("wt-pool"));
655 }
656
657 #[test]
658 fn session_worktrees_dir_matches_resolver_output() {
659 let tmp = tempfile::tempdir().unwrap();
660 let root = tmp.path().to_path_buf();
661 let session = Session::new(SessionConfig {
662 root: root.clone(),
663 ..Default::default()
664 })
665 .unwrap();
666 assert_eq!(session.worktrees_dir(), resolve_worktrees_dir(&root, None));
667 }
668
669 #[test]
672 fn core_error_root_not_found_display_contains_prefix_and_path() {
673 let path = PathBuf::from("/some/missing/root");
674 let err = CoreError::RootNotFound(path.clone());
675 let msg = err.to_string();
676 assert!(
677 msg.contains("session root does not exist: "),
678 "I1: message must start with invariant prefix, got: {msg}"
679 );
680 assert!(
681 msg.contains("/some/missing/root"),
682 "I1: message must contain the path, got: {msg}"
683 );
684 }
685
686 #[test]
687 fn core_error_no_session_display_matches_invariant() {
688 let err = CoreError::NoSession;
689 let msg = err.to_string();
690 assert_eq!(
691 msg, "no active session \u{2014} call session_start first",
692 "I2: message must exactly match invariant string"
693 );
694 }
695
696 #[test]
699 fn session_error_root_gone_message_contains_invariant_substring() {
700 use std::path::PathBuf;
701 let path = PathBuf::from("/tmp/gone");
702 let err = SessionError::RootGone(path.clone());
703 let msg = err.to_string();
704 assert!(
705 msg.contains("session root path no longer exists, please call session_start again"),
706 "error message must contain the K-239 recovery substring, got: {msg}"
707 );
708 assert!(
709 msg.contains("/tmp/gone"),
710 "error message must include the path, got: {msg}"
711 );
712 }
713
714 #[test]
715 fn ensure_alive_ok_when_root_exists() {
716 let tmp = tempfile::tempdir().unwrap();
717 let session = Session::new(SessionConfig {
718 root: tmp.path().to_path_buf(),
719 ..Default::default()
720 })
721 .unwrap();
722 assert!(session.ensure_alive().is_ok());
723 }
724
725 fn mk_state_with_root(alias: Option<&str>) -> (LdsState, tempfile::TempDir, String) {
728 let tmp = tempfile::tempdir().unwrap();
729 let mut state = LdsState::new();
730 let session = state
731 .create_session(
732 SessionConfig {
733 root: tmp.path().to_path_buf(),
734 alias: alias.map(|s| s.to_string()),
735 ..Default::default()
736 },
737 true,
738 )
739 .unwrap();
740 let id = session.id().to_string();
741 (state, tmp, id)
742 }
743
744 #[test]
745 fn ledger_create_sets_default_when_first_session() {
746 let (state, _tmp, id) = mk_state_with_root(None);
747 assert_eq!(state.default_session_id(), Some(id.as_str()));
748 assert_eq!(state.list_sessions().len(), 1);
749 }
750
751 #[test]
752 fn ledger_second_session_preserves_default_unless_requested() {
753 let (mut state, _tmp1, id1) = mk_state_with_root(None);
754 let tmp2 = tempfile::tempdir().unwrap();
755 let _ = state
756 .create_session(
757 SessionConfig {
758 root: tmp2.path().to_path_buf(),
759 ..Default::default()
760 },
761 false,
762 )
763 .unwrap();
764 assert_eq!(state.default_session_id(), Some(id1.as_str()));
765 assert_eq!(state.list_sessions().len(), 2);
766 }
767
768 #[test]
769 fn ledger_resolve_by_id_or_alias() {
770 let (state, _tmp, id) = mk_state_with_root(Some("worker-1"));
771 let by_id = state.resolve(&id).unwrap();
772 let by_alias = state.resolve("worker-1").unwrap();
773 assert_eq!(by_id.id(), by_alias.id());
774 }
775
776 #[test]
777 fn ledger_resolve_unknown_returns_not_found() {
778 let (state, _tmp, _id) = mk_state_with_root(None);
779 let err = state.resolve("does-not-exist").unwrap_err();
780 assert!(matches!(err, CoreError::SessionNotFound(_)), "got {err:?}");
781 }
782
783 #[test]
784 fn ledger_alias_conflict_rejected_on_create() {
785 let (mut state, _tmp, _id) = mk_state_with_root(Some("dup"));
786 let tmp2 = tempfile::tempdir().unwrap();
787 let err = state
788 .create_session(
789 SessionConfig {
790 root: tmp2.path().to_path_buf(),
791 alias: Some("dup".to_string()),
792 ..Default::default()
793 },
794 false,
795 )
796 .unwrap_err();
797 assert!(matches!(err, CoreError::AliasConflict(_)), "got {err:?}");
798 }
799
800 #[test]
801 fn ledger_set_alias_assigns_and_replaces() {
802 let (mut state, _tmp, id) = mk_state_with_root(None);
803 state.set_alias(&id, "main".into()).unwrap();
804 assert_eq!(state.resolve("main").unwrap().id(), id);
805 state.set_alias(&id, "renamed".into()).unwrap();
807 assert!(state.resolve("main").is_err());
808 assert_eq!(state.resolve("renamed").unwrap().id(), id);
809 }
810
811 #[test]
812 fn ledger_unset_alias_removes_mapping_but_keeps_session() {
813 let (mut state, _tmp, id) = mk_state_with_root(Some("tmp-alias"));
814 state.unset_alias("tmp-alias").unwrap();
815 assert!(state.resolve("tmp-alias").is_err());
816 assert!(state.resolve(&id).is_ok());
817 }
818
819 #[test]
820 fn ledger_close_drops_session_and_clears_default() {
821 let (mut state, _tmp, id) = mk_state_with_root(Some("main"));
822 state.close(&id).unwrap();
823 assert!(state.resolve(&id).is_err());
824 assert_eq!(state.default_session_id(), None);
825 assert_eq!(state.list_sessions().len(), 0);
826 }
827
828 #[test]
829 fn ledger_list_sorted_by_created_at() {
830 let (mut state, _tmp1, id1) = mk_state_with_root(None);
831 std::thread::sleep(std::time::Duration::from_millis(1100));
833 let tmp2 = tempfile::tempdir().unwrap();
834 let s2 = state
835 .create_session(
836 SessionConfig {
837 root: tmp2.path().to_path_buf(),
838 ..Default::default()
839 },
840 false,
841 )
842 .unwrap();
843 let entries = state.list_sessions();
844 assert_eq!(entries.len(), 2);
845 assert_eq!(entries[0].session_id, id1);
846 assert_eq!(entries[1].session_id, s2.id());
847 }
848
849 #[test]
852 fn doctor_root_exists_passes_on_live_root() {
853 let (state, _tmp, id) = mk_state_with_root(None);
854 let report = state.doctor(&id).unwrap();
855 let root_check = report
856 .checks
857 .iter()
858 .find(|c| c.name == "root-exists")
859 .unwrap();
860 assert_eq!(root_check.status, CheckStatus::Ok);
861 }
862
863 #[test]
864 fn doctor_root_exists_fails_when_root_deleted() {
865 let tmp = tempfile::tempdir().unwrap();
866 let path = tmp.path().to_path_buf();
867 let mut state = LdsState::new();
868 let s = state
869 .create_session(
870 SessionConfig {
871 root: path.clone(),
872 ..Default::default()
873 },
874 true,
875 )
876 .unwrap();
877 std::fs::remove_dir_all(&path).unwrap();
878 let report = state.doctor(s.id()).unwrap();
879 let root_check = report
880 .checks
881 .iter()
882 .find(|c| c.name == "root-exists")
883 .unwrap();
884 assert_eq!(root_check.status, CheckStatus::Fail);
885 assert_eq!(report.verdict, CheckStatus::Fail);
886 }
887
888 #[test]
889 fn doctor_detects_root_conflict_between_sessions() {
890 let tmp = tempfile::tempdir().unwrap();
891 let mut state = LdsState::new();
892 let s1 = state
893 .create_session(
894 SessionConfig {
895 root: tmp.path().to_path_buf(),
896 ..Default::default()
897 },
898 true,
899 )
900 .unwrap();
901 let _s2 = state
902 .create_session(
903 SessionConfig {
904 root: tmp.path().to_path_buf(),
905 ..Default::default()
906 },
907 false,
908 )
909 .unwrap();
910 let report = state.doctor(s1.id()).unwrap();
911 let conflict = report
912 .checks
913 .iter()
914 .find(|c| c.name == "root-conflict")
915 .unwrap();
916 assert_eq!(conflict.status, CheckStatus::Warn);
917 }
918
919 #[test]
920 fn ensure_alive_err_when_root_deleted() {
921 let tmp = tempfile::tempdir().unwrap();
922 let path = tmp.path().to_path_buf();
923 let session = Session::new(SessionConfig {
924 root: path.clone(),
925 ..Default::default()
926 })
927 .unwrap();
928 std::fs::remove_dir_all(&path).unwrap();
929 let err = session.ensure_alive().unwrap_err();
930 let msg = err.to_string();
931 assert!(
932 msg.contains("session root path no longer exists, please call session_start again"),
933 "expected K-239 substring, got: {msg}"
934 );
935 }
936}