Skip to main content

lds_session/
lib.rs

1//! Session lifecycle primitives shared across all lds modules.
2//!
3//! Every module (git, recipe, sandbox) receives an `Arc<Session>` that
4//! anchors operations to a single project root. Shared concerns — timeout,
5//! output truncation, global recipe dirs — live here so modules don't
6//! duplicate configuration.
7//!
8//! This crate was split out of `lds-core` to give downstream consumers
9//! (current: lds workspace modules; future: session-mcp / KV primitives)
10//! a self-contained session contract independent of the broader core
11//! utilities (binary probing, output truncation, config files).
12
13use 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; // 100KB
20
21/// Configuration passed to [`Session::new`]. Optional fields fall back
22/// to sensible defaults (60s timeout, 100KB output limit).
23#[derive(Debug, Default)]
24pub struct SessionConfig {
25    pub root: PathBuf,
26    pub timeout_secs: Option<u64>,
27    pub max_output: Option<usize>,
28    /// Optional human-readable alias for this session.
29    ///
30    /// Used by callers (MainAI / SubAgent) to dispatch by a stable label
31    /// instead of the opaque `session_id`. Aliases are case-sensitive and
32    /// must be unique within an [`LdsState`] ledger.
33    pub alias: Option<String>,
34    /// Additional global recipe directories, in precedence order (lowest first).
35    ///
36    /// The default `~/.config/lds` is always consulted by `build_resolve_chain`
37    /// regardless of this list. Entries here are pushed after the default and before
38    /// the project justfile. Populate via `LDS_RECIPE_GLOBAL_DIRS` (colon-separated)
39    /// and/or the `global_recipe_dir` MCP wire argument.
40    pub global_recipe_dirs: Vec<PathBuf>,
41}
42
43/// Errors that can occur during a [`Session`]'s post-construction lifecycle.
44#[derive(Debug, thiserror::Error)]
45pub enum SessionError {
46    #[error(
47        "session root path no longer exists, please call session_start again: {}",
48        _0.display()
49    )]
50    RootGone(PathBuf),
51}
52
53/// Errors that can occur during session construction or access.
54#[derive(Debug, thiserror::Error)]
55pub enum CoreError {
56    #[error("session root does not exist: {}", _0.display())]
57    RootNotFound(PathBuf),
58    #[error("no active session — call session_start first")]
59    NoSession,
60    #[error("session not found: {0}")]
61    SessionNotFound(String),
62    #[error("alias already in use: {0}")]
63    AliasConflict(String),
64}
65
66/// Immutable session state created by `session_start` / `session_create`.
67///
68/// Cloned (via `Arc`) into each module. Holds the project root and
69/// cross-cutting concerns that every module may need.
70///
71/// `alias` and `last_used_at` are interior-mutable (RwLock) so the ledger
72/// can rename or touch sessions without invalidating outstanding `Arc<Session>`
73/// handles held by tool handlers.
74#[derive(Debug)]
75pub struct Session {
76    root: PathBuf,
77    session_id: String,
78    alias: RwLock<Option<String>>,
79    timeout: Duration,
80    max_output: usize,
81    global_recipe_dirs: Vec<PathBuf>,
82    created_at: u64,
83    last_used_at: RwLock<u64>,
84}
85
86impl Session {
87    pub fn new(config: SessionConfig) -> Result<Self, CoreError> {
88        let root = config.root;
89        if !root.is_dir() {
90            tracing::warn!(root = %root.display(), "session root does not exist");
91            return Err(CoreError::RootNotFound(root));
92        }
93        let session_id = session_id_new();
94        let timeout = Duration::from_secs(config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
95        let max_output = config.max_output.unwrap_or(DEFAULT_MAX_OUTPUT);
96        let now = epoch_secs();
97        tracing::info!(
98            root = %root.display(),
99            session_id = %session_id,
100            alias = ?config.alias,
101            timeout_secs = timeout.as_secs(),
102            max_output,
103            "session started"
104        );
105        let global_recipe_dirs = config.global_recipe_dirs;
106        Ok(Self {
107            root,
108            session_id,
109            alias: RwLock::new(config.alias),
110            timeout,
111            max_output,
112            global_recipe_dirs,
113            created_at: now,
114            last_used_at: RwLock::new(now),
115        })
116    }
117
118    pub fn root(&self) -> &Path {
119        &self.root
120    }
121
122    pub fn id(&self) -> &str {
123        &self.session_id
124    }
125
126    pub fn alias(&self) -> Option<String> {
127        self.alias.read().expect("alias lock poisoned").clone()
128    }
129
130    pub fn created_at(&self) -> u64 {
131        self.created_at
132    }
133
134    pub fn last_used_at(&self) -> u64 {
135        *self.last_used_at.read().expect("last_used lock poisoned")
136    }
137
138    pub fn touch(&self) {
139        if let Ok(mut g) = self.last_used_at.write() {
140            *g = epoch_secs();
141        }
142    }
143
144    pub(crate) fn set_alias(&self, alias: Option<String>) {
145        if let Ok(mut g) = self.alias.write() {
146            *g = alias;
147        }
148    }
149
150    pub fn timeout(&self) -> Duration {
151        self.timeout
152    }
153
154    pub fn max_output(&self) -> usize {
155        self.max_output
156    }
157
158    pub fn global_recipe_dirs(&self) -> &[PathBuf] {
159        &self.global_recipe_dirs
160    }
161
162    /// Check that the session root directory still exists.
163    ///
164    /// Call this at each entry point (e.g. `list`, `run`) to detect a deleted
165    /// root before attempting I/O. Returns [`SessionError::RootGone`] if the
166    /// directory no longer exists.
167    pub fn ensure_alive(&self) -> Result<(), SessionError> {
168        if !self.root.is_dir() {
169            tracing::warn!(root = %self.root.display(), "session root no longer exists");
170            return Err(SessionError::RootGone(self.root.clone()));
171        }
172        Ok(())
173    }
174}
175
176/// Top-level mutable state for the MCP server.
177///
178/// Holds a ledger of all live [`Session`]s, indexed by `session_id` (opaque
179/// hash) and `alias` (human-readable label). One session is designated the
180/// **default session**, returned by [`Self::session`] for backward-compatible
181/// tool calls that pre-date per-call `session_id` addressing.
182///
183/// The MCP handler wraps this in `Arc<RwLock<LdsState>>` for concurrent tool
184/// access. Mutations (create / close / alias) take the write lock; reads
185/// (resolve / list / describe / doctor) take the read lock.
186#[derive(Debug, Clone)]
187pub struct LdsState {
188    /// id -> Arc<Session>
189    sessions: HashMap<String, Arc<Session>>,
190    /// alias -> id (alias resolution map)
191    aliases: HashMap<String, String>,
192    /// The implicit default session for backward-compat callers.
193    /// `None` until the first session_start / session_create.
194    default_id: Option<String>,
195}
196
197/// Snapshot of a single session entry, returned by ledger introspection APIs.
198#[derive(Debug, Clone)]
199pub struct SessionEntry {
200    pub session_id: String,
201    pub alias: Option<String>,
202    pub root: PathBuf,
203    pub created_at: u64,
204    pub last_used_at: u64,
205    pub is_default: bool,
206}
207
208impl SessionEntry {
209    fn from_session(s: &Session, is_default: bool) -> Self {
210        Self {
211            session_id: s.id().to_string(),
212            alias: s.alias(),
213            root: s.root().to_path_buf(),
214            created_at: s.created_at(),
215            last_used_at: s.last_used_at(),
216            is_default,
217        }
218    }
219}
220
221impl LdsState {
222    pub fn new() -> Self {
223        Self {
224            sessions: HashMap::new(),
225            aliases: HashMap::new(),
226            default_id: None,
227        }
228    }
229
230    /// Create a new session and register it in the ledger.
231    ///
232    /// If `make_default` is true (or no default exists yet), the new session
233    /// becomes the implicit default.
234    ///
235    /// Returns [`CoreError::AliasConflict`] if `config.alias` is already
236    /// taken by another session.
237    pub fn create_session(
238        &mut self,
239        config: SessionConfig,
240        make_default: bool,
241    ) -> Result<Arc<Session>, CoreError> {
242        if let Some(alias) = &config.alias
243            && self.aliases.contains_key(alias)
244        {
245            return Err(CoreError::AliasConflict(alias.clone()));
246        }
247        let alias_clone = config.alias.clone();
248        let session = Arc::new(Session::new(config)?);
249        let id = session.id().to_string();
250        self.sessions.insert(id.clone(), Arc::clone(&session));
251        if let Some(alias) = alias_clone {
252            self.aliases.insert(alias, id.clone());
253        }
254        if make_default || self.default_id.is_none() {
255            self.default_id = Some(id);
256        }
257        Ok(session)
258    }
259
260    /// Backward-compatible entry point used by the legacy `session_start`
261    /// MCP tool. Always replaces the default session.
262    pub fn start_session(&mut self, config: SessionConfig) -> Result<Arc<Session>, CoreError> {
263        self.create_session(config, true)
264    }
265
266    /// Look up a session by id or alias.
267    ///
268    /// `key` is tried as an alias first, then as a session_id. Returns
269    /// [`CoreError::SessionNotFound`] if neither matches.
270    pub fn resolve(&self, key: &str) -> Result<Arc<Session>, CoreError> {
271        if let Some(id) = self.aliases.get(key)
272            && let Some(s) = self.sessions.get(id)
273        {
274            return Ok(Arc::clone(s));
275        }
276        if let Some(s) = self.sessions.get(key) {
277            return Ok(Arc::clone(s));
278        }
279        Err(CoreError::SessionNotFound(key.to_string()))
280    }
281
282    /// Return the default session for backward-compatible tool calls.
283    pub fn session(&self) -> Result<Arc<Session>, CoreError> {
284        let id = self.default_id.as_ref().ok_or(CoreError::NoSession)?;
285        let s = self
286            .sessions
287            .get(id)
288            .ok_or_else(|| CoreError::SessionNotFound(id.clone()))?;
289        Ok(Arc::clone(s))
290    }
291
292    pub fn default_session_id(&self) -> Option<&str> {
293        self.default_id.as_deref()
294    }
295
296    /// Snapshot every session in the ledger.
297    pub fn list_sessions(&self) -> Vec<SessionEntry> {
298        let mut out: Vec<SessionEntry> = self
299            .sessions
300            .values()
301            .map(|s| {
302                let is_default = self.default_id.as_deref() == Some(s.id());
303                SessionEntry::from_session(s, is_default)
304            })
305            .collect();
306        out.sort_by_key(|e| e.created_at);
307        out
308    }
309
310    /// Describe a single session by id or alias.
311    pub fn describe(&self, key: &str) -> Result<SessionEntry, CoreError> {
312        let s = self.resolve(key)?;
313        let is_default = self.default_id.as_deref() == Some(s.id());
314        Ok(SessionEntry::from_session(&s, is_default))
315    }
316
317    /// Assign or change an alias on an existing session.
318    ///
319    /// Returns [`CoreError::AliasConflict`] if the new alias is already held
320    /// by another session.
321    pub fn set_alias(&mut self, key: &str, alias: String) -> Result<(), CoreError> {
322        let target = self.resolve(key)?;
323        if let Some(owner_id) = self.aliases.get(&alias) {
324            if owner_id != target.id() {
325                return Err(CoreError::AliasConflict(alias));
326            }
327            return Ok(());
328        }
329        // Drop the session's previous alias, if any.
330        if let Some(prev) = target.alias() {
331            self.aliases.remove(&prev);
332        }
333        target.set_alias(Some(alias.clone()));
334        self.aliases.insert(alias, target.id().to_string());
335        Ok(())
336    }
337
338    /// Remove an alias from the ledger. The session itself remains.
339    pub fn unset_alias(&mut self, alias: &str) -> Result<(), CoreError> {
340        let id = self
341            .aliases
342            .remove(alias)
343            .ok_or_else(|| CoreError::SessionNotFound(alias.to_string()))?;
344        if let Some(s) = self.sessions.get(&id) {
345            s.set_alias(None);
346        }
347        Ok(())
348    }
349
350    /// Close (drop) a session by id or alias.
351    ///
352    /// If the closed session was the default, the default is cleared and
353    /// must be re-set by a subsequent `session_start` / `session_create`
354    /// with `make_default=true`.
355    pub fn close(&mut self, key: &str) -> Result<(), CoreError> {
356        let target = self.resolve(key)?;
357        let id = target.id().to_string();
358        if let Some(alias) = target.alias() {
359            self.aliases.remove(&alias);
360        }
361        self.sessions.remove(&id);
362        if self.default_id.as_deref() == Some(&id) {
363            self.default_id = None;
364        }
365        Ok(())
366    }
367}
368
369impl Default for LdsState {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375// ── Doctor ────────────────────────────────────────────────────────────────
376
377/// Per-check verdict returned by [`LdsState::doctor`].
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub enum CheckStatus {
380    Ok,
381    Warn,
382    Fail,
383}
384
385impl CheckStatus {
386    pub fn as_str(&self) -> &'static str {
387        match self {
388            CheckStatus::Ok => "ok",
389            CheckStatus::Warn => "warn",
390            CheckStatus::Fail => "fail",
391        }
392    }
393}
394
395#[derive(Debug, Clone)]
396pub struct DoctorCheck {
397    pub name: &'static str,
398    pub status: CheckStatus,
399    pub evidence: String,
400}
401
402#[derive(Debug, Clone)]
403pub struct DoctorReport {
404    pub session_id: String,
405    pub alias: Option<String>,
406    pub verdict: CheckStatus,
407    pub checks: Vec<DoctorCheck>,
408}
409
410const IDLE_WARN_SECS: u64 = 60 * 60 * 6; // 6h idle → ledger-leak WARN
411
412impl LdsState {
413    /// Run health checks on a single session (by id / alias).
414    pub fn doctor(&self, key: &str) -> Result<DoctorReport, CoreError> {
415        let s = self.resolve(key)?;
416        let mut checks: Vec<DoctorCheck> = Vec::new();
417
418        // C1 root-exists
419        let root = s.root();
420        if root.is_dir() {
421            checks.push(DoctorCheck {
422                name: "root-exists",
423                status: CheckStatus::Ok,
424                evidence: format!("root={}", root.display()),
425            });
426        } else {
427            checks.push(DoctorCheck {
428                name: "root-exists",
429                status: CheckStatus::Fail,
430                evidence: format!("root missing: {}", root.display()),
431            });
432        }
433
434        // C2 git-bound (presence of .git, file or dir = git worktree)
435        let git_path = root.join(".git");
436        if git_path.exists() {
437            checks.push(DoctorCheck {
438                name: "git-bound",
439                status: CheckStatus::Ok,
440                evidence: format!("{}/.git present", root.display()),
441            });
442        } else {
443            checks.push(DoctorCheck {
444                name: "git-bound",
445                status: CheckStatus::Warn,
446                evidence: "no .git in root; git_* tools will fail".into(),
447            });
448        }
449
450        // C3 journal-db-writable
451        let journal_dir = root.join("workspace");
452        let journal_status = if !journal_dir.exists() {
453            CheckStatus::Warn
454        } else {
455            // Probe writability by attempting to create a temp marker.
456            match tempfile::NamedTempFile::new_in(&journal_dir) {
457                Ok(_) => CheckStatus::Ok,
458                Err(_) => CheckStatus::Fail,
459            }
460        };
461        checks.push(DoctorCheck {
462            name: "journal-db-writable",
463            status: journal_status,
464            evidence: format!("probe dir = {}", journal_dir.display()),
465        });
466
467        // C4 stale-lock (heuristic: look for .lock files older than 1h)
468        let mut stale = Vec::new();
469        for candidate in ["workspace/.journal.db.lock", ".journal.db.lock"] {
470            let p = root.join(candidate);
471            if let Ok(meta) = std::fs::metadata(&p)
472                && let Ok(modified) = meta.modified()
473                && let Ok(age) = SystemTime::now().duration_since(modified)
474                && age.as_secs() > 3600
475            {
476                stale.push(p.display().to_string());
477            }
478        }
479        checks.push(DoctorCheck {
480            name: "stale-lock",
481            status: if stale.is_empty() {
482                CheckStatus::Ok
483            } else {
484                CheckStatus::Warn
485            },
486            evidence: if stale.is_empty() {
487                "no stale lock found".into()
488            } else {
489                format!("stale: {}", stale.join(","))
490            },
491        });
492
493        // C5 ownership-drift: detect multiple sessions claiming the same root.
494        let conflict_count = self
495            .sessions
496            .values()
497            .filter(|other| other.root() == root && other.id() != s.id())
498            .count();
499        checks.push(DoctorCheck {
500            name: "ownership-drift",
501            status: if conflict_count == 0 {
502                CheckStatus::Ok
503            } else {
504                CheckStatus::Warn
505            },
506            evidence: if conflict_count == 0 {
507                "exclusive owner".into()
508            } else {
509                format!("{conflict_count} other session(s) share root")
510            },
511        });
512
513        // C6 root-conflict: same as C5 but raises to FAIL when ≥2 conflicts.
514        checks.push(DoctorCheck {
515            name: "root-conflict",
516            status: match conflict_count {
517                0 => CheckStatus::Ok,
518                1 => CheckStatus::Warn,
519                _ => CheckStatus::Fail,
520            },
521            evidence: format!("conflicts={conflict_count}"),
522        });
523
524        // C7 ledger-leak: idle for > IDLE_WARN_SECS.
525        let idle = epoch_secs().saturating_sub(s.last_used_at());
526        checks.push(DoctorCheck {
527            name: "ledger-leak",
528            status: if idle > IDLE_WARN_SECS {
529                CheckStatus::Warn
530            } else {
531                CheckStatus::Ok
532            },
533            evidence: format!("idle_secs={idle}"),
534        });
535
536        let verdict =
537            checks
538                .iter()
539                .map(|c| c.status.clone())
540                .fold(CheckStatus::Ok, |acc, s| match (&acc, &s) {
541                    (CheckStatus::Fail, _) | (_, CheckStatus::Fail) => CheckStatus::Fail,
542                    (CheckStatus::Warn, _) | (_, CheckStatus::Warn) => CheckStatus::Warn,
543                    _ => CheckStatus::Ok,
544                });
545
546        Ok(DoctorReport {
547            session_id: s.id().to_string(),
548            alias: s.alias(),
549            verdict,
550            checks,
551        })
552    }
553}
554
555fn epoch_secs() -> u64 {
556    SystemTime::now()
557        .duration_since(UNIX_EPOCH)
558        .map(|d| d.as_secs())
559        .unwrap_or(0)
560}
561
562/// Generate a session uniqueness identifier as `{nanos_hex}-{pid_hex}`.
563///
564/// This is NOT an RFC 4122 UUID — it is a lightweight identifier used
565/// for session ownership tracking and log correlation. It carries no
566/// cryptographic randomness guarantees. Use the `uuid` crate if a
567/// RFC 4122 v4 UUID is required.
568fn session_id_new() -> String {
569    use std::time::{SystemTime, UNIX_EPOCH};
570    let ts = SystemTime::now()
571        .duration_since(UNIX_EPOCH)
572        .unwrap_or_default()
573        .as_nanos();
574    let pid = std::process::id();
575    format!("{ts:x}-{pid:x}")
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    // ── CoreError Display invariants (I1 / I2) ───────────────────────────────
583
584    #[test]
585    fn core_error_root_not_found_display_contains_prefix_and_path() {
586        let path = PathBuf::from("/some/missing/root");
587        let err = CoreError::RootNotFound(path.clone());
588        let msg = err.to_string();
589        assert!(
590            msg.contains("session root does not exist: "),
591            "I1: message must start with invariant prefix, got: {msg}"
592        );
593        assert!(
594            msg.contains("/some/missing/root"),
595            "I1: message must contain the path, got: {msg}"
596        );
597    }
598
599    #[test]
600    fn core_error_no_session_display_matches_invariant() {
601        let err = CoreError::NoSession;
602        let msg = err.to_string();
603        assert_eq!(
604            msg, "no active session \u{2014} call session_start first",
605            "I2: message must exactly match invariant string"
606        );
607    }
608
609    // ── SessionError / Session::ensure_alive ─────────────────────────────────
610
611    #[test]
612    fn session_error_root_gone_message_contains_invariant_substring() {
613        use std::path::PathBuf;
614        let path = PathBuf::from("/tmp/gone");
615        let err = SessionError::RootGone(path.clone());
616        let msg = err.to_string();
617        assert!(
618            msg.contains("session root path no longer exists, please call session_start again"),
619            "error message must contain the K-239 recovery substring, got: {msg}"
620        );
621        assert!(
622            msg.contains("/tmp/gone"),
623            "error message must include the path, got: {msg}"
624        );
625    }
626
627    #[test]
628    fn ensure_alive_ok_when_root_exists() {
629        let tmp = tempfile::tempdir().unwrap();
630        let session = Session::new(SessionConfig {
631            root: tmp.path().to_path_buf(),
632            ..Default::default()
633        })
634        .unwrap();
635        assert!(session.ensure_alive().is_ok());
636    }
637
638    // ── Ledger (multi-session) ───────────────────────────────────────────────
639
640    fn mk_state_with_root(alias: Option<&str>) -> (LdsState, tempfile::TempDir, String) {
641        let tmp = tempfile::tempdir().unwrap();
642        let mut state = LdsState::new();
643        let session = state
644            .create_session(
645                SessionConfig {
646                    root: tmp.path().to_path_buf(),
647                    alias: alias.map(|s| s.to_string()),
648                    ..Default::default()
649                },
650                true,
651            )
652            .unwrap();
653        let id = session.id().to_string();
654        (state, tmp, id)
655    }
656
657    #[test]
658    fn ledger_create_sets_default_when_first_session() {
659        let (state, _tmp, id) = mk_state_with_root(None);
660        assert_eq!(state.default_session_id(), Some(id.as_str()));
661        assert_eq!(state.list_sessions().len(), 1);
662    }
663
664    #[test]
665    fn ledger_second_session_preserves_default_unless_requested() {
666        let (mut state, _tmp1, id1) = mk_state_with_root(None);
667        let tmp2 = tempfile::tempdir().unwrap();
668        let _ = state
669            .create_session(
670                SessionConfig {
671                    root: tmp2.path().to_path_buf(),
672                    ..Default::default()
673                },
674                false,
675            )
676            .unwrap();
677        assert_eq!(state.default_session_id(), Some(id1.as_str()));
678        assert_eq!(state.list_sessions().len(), 2);
679    }
680
681    #[test]
682    fn ledger_resolve_by_id_or_alias() {
683        let (state, _tmp, id) = mk_state_with_root(Some("worker-1"));
684        let by_id = state.resolve(&id).unwrap();
685        let by_alias = state.resolve("worker-1").unwrap();
686        assert_eq!(by_id.id(), by_alias.id());
687    }
688
689    #[test]
690    fn ledger_resolve_unknown_returns_not_found() {
691        let (state, _tmp, _id) = mk_state_with_root(None);
692        let err = state.resolve("does-not-exist").unwrap_err();
693        assert!(matches!(err, CoreError::SessionNotFound(_)), "got {err:?}");
694    }
695
696    #[test]
697    fn ledger_alias_conflict_rejected_on_create() {
698        let (mut state, _tmp, _id) = mk_state_with_root(Some("dup"));
699        let tmp2 = tempfile::tempdir().unwrap();
700        let err = state
701            .create_session(
702                SessionConfig {
703                    root: tmp2.path().to_path_buf(),
704                    alias: Some("dup".to_string()),
705                    ..Default::default()
706                },
707                false,
708            )
709            .unwrap_err();
710        assert!(matches!(err, CoreError::AliasConflict(_)), "got {err:?}");
711    }
712
713    #[test]
714    fn ledger_set_alias_assigns_and_replaces() {
715        let (mut state, _tmp, id) = mk_state_with_root(None);
716        state.set_alias(&id, "main".into()).unwrap();
717        assert_eq!(state.resolve("main").unwrap().id(), id);
718        // Reassign to a new alias — previous one is freed.
719        state.set_alias(&id, "renamed".into()).unwrap();
720        assert!(state.resolve("main").is_err());
721        assert_eq!(state.resolve("renamed").unwrap().id(), id);
722    }
723
724    #[test]
725    fn ledger_unset_alias_removes_mapping_but_keeps_session() {
726        let (mut state, _tmp, id) = mk_state_with_root(Some("tmp-alias"));
727        state.unset_alias("tmp-alias").unwrap();
728        assert!(state.resolve("tmp-alias").is_err());
729        assert!(state.resolve(&id).is_ok());
730    }
731
732    #[test]
733    fn ledger_close_drops_session_and_clears_default() {
734        let (mut state, _tmp, id) = mk_state_with_root(Some("main"));
735        state.close(&id).unwrap();
736        assert!(state.resolve(&id).is_err());
737        assert_eq!(state.default_session_id(), None);
738        assert_eq!(state.list_sessions().len(), 0);
739    }
740
741    #[test]
742    fn ledger_list_sorted_by_created_at() {
743        let (mut state, _tmp1, id1) = mk_state_with_root(None);
744        // Force a measurable timestamp gap (epoch_secs is second-resolution).
745        std::thread::sleep(std::time::Duration::from_millis(1100));
746        let tmp2 = tempfile::tempdir().unwrap();
747        let s2 = state
748            .create_session(
749                SessionConfig {
750                    root: tmp2.path().to_path_buf(),
751                    ..Default::default()
752                },
753                false,
754            )
755            .unwrap();
756        let entries = state.list_sessions();
757        assert_eq!(entries.len(), 2);
758        assert_eq!(entries[0].session_id, id1);
759        assert_eq!(entries[1].session_id, s2.id());
760    }
761
762    // ── Doctor checks ────────────────────────────────────────────────────────
763
764    #[test]
765    fn doctor_root_exists_passes_on_live_root() {
766        let (state, _tmp, id) = mk_state_with_root(None);
767        let report = state.doctor(&id).unwrap();
768        let root_check = report
769            .checks
770            .iter()
771            .find(|c| c.name == "root-exists")
772            .unwrap();
773        assert_eq!(root_check.status, CheckStatus::Ok);
774    }
775
776    #[test]
777    fn doctor_root_exists_fails_when_root_deleted() {
778        let tmp = tempfile::tempdir().unwrap();
779        let path = tmp.path().to_path_buf();
780        let mut state = LdsState::new();
781        let s = state
782            .create_session(
783                SessionConfig {
784                    root: path.clone(),
785                    ..Default::default()
786                },
787                true,
788            )
789            .unwrap();
790        std::fs::remove_dir_all(&path).unwrap();
791        let report = state.doctor(s.id()).unwrap();
792        let root_check = report
793            .checks
794            .iter()
795            .find(|c| c.name == "root-exists")
796            .unwrap();
797        assert_eq!(root_check.status, CheckStatus::Fail);
798        assert_eq!(report.verdict, CheckStatus::Fail);
799    }
800
801    #[test]
802    fn doctor_detects_root_conflict_between_sessions() {
803        let tmp = tempfile::tempdir().unwrap();
804        let mut state = LdsState::new();
805        let s1 = state
806            .create_session(
807                SessionConfig {
808                    root: tmp.path().to_path_buf(),
809                    ..Default::default()
810                },
811                true,
812            )
813            .unwrap();
814        let _s2 = state
815            .create_session(
816                SessionConfig {
817                    root: tmp.path().to_path_buf(),
818                    ..Default::default()
819                },
820                false,
821            )
822            .unwrap();
823        let report = state.doctor(s1.id()).unwrap();
824        let conflict = report
825            .checks
826            .iter()
827            .find(|c| c.name == "root-conflict")
828            .unwrap();
829        assert_eq!(conflict.status, CheckStatus::Warn);
830    }
831
832    #[test]
833    fn ensure_alive_err_when_root_deleted() {
834        let tmp = tempfile::tempdir().unwrap();
835        let path = tmp.path().to_path_buf();
836        let session = Session::new(SessionConfig {
837            root: path.clone(),
838            ..Default::default()
839        })
840        .unwrap();
841        std::fs::remove_dir_all(&path).unwrap();
842        let err = session.ensure_alive().unwrap_err();
843        let msg = err.to_string();
844        assert!(
845            msg.contains("session root path no longer exists, please call session_start again"),
846            "expected K-239 substring, got: {msg}"
847        );
848    }
849}