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