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    /// Directory where session-owned git worktrees are placed.
42    ///
43    /// Resolution precedence (highest first): this field → env
44    /// `LDS_WORKTREES_DIR` → `<root>/.worktrees` (default).
45    ///
46    /// **Setup expectation — this dir MUST be gitignored.**
47    /// `GitModule::worktree_add` creates worktrees at
48    /// `<worktrees_dir>/<name>` on session-owned branches. If the directory
49    /// is not covered by `.gitignore`, `git status` will surface the
50    /// worktree contents as untracked entries in the parent repo and a
51    /// `git add -A` / commit can silently absorb them (large accidental
52    /// commit). Callers overriding this path (env or explicit field) must
53    /// ensure the target is either outside the parent repo's tracking or
54    /// listed in the parent's `.gitignore`. `repo-readiness-check` C1
55    /// treats `.worktrees` as a Universal must-have gitignore entry for
56    /// the default path.
57    ///
58    /// Absolute paths are taken verbatim. Relative paths are resolved
59    /// against `root`.
60    pub worktrees_dir: Option<PathBuf>,
61}
62
63/// Errors that can occur during a [`Session`]'s post-construction lifecycle.
64#[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/// Errors that can occur during session construction or access.
74#[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/// Immutable session state created by `session_start` / `session_create`.
87///
88/// Cloned (via `Arc`) into each module. Holds the project root and
89/// cross-cutting concerns that every module may need.
90///
91/// `alias` and `last_used_at` are interior-mutable (RwLock) so the ledger
92/// can rename or touch sessions without invalidating outstanding `Arc<Session>`
93/// handles held by tool handlers.
94#[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
107/// Resolve the effective worktrees directory from
108/// (config → env `LDS_WORKTREES_DIR` → default `<root>/.worktrees`).
109/// Relative paths are anchored to `root`. Only invoked from
110/// [`Session::new`]; exposed as a free function for test coverage of
111/// the precedence rules.
112fn 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    /// Directory where this session's git worktrees live.
159    ///
160    /// Absolute path. Resolved once at [`Session::new`] from
161    /// [`SessionConfig::worktrees_dir`] → env `LDS_WORKTREES_DIR` →
162    /// `<root>/.worktrees`. See [`SessionConfig::worktrees_dir`] for the
163    /// setup expectation (gitignore requirement).
164    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    /// Check that the session root directory still exists.
213    ///
214    /// Call this at each entry point (e.g. `list`, `run`) to detect a deleted
215    /// root before attempting I/O. Returns [`SessionError::RootGone`] if the
216    /// directory no longer exists.
217    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/// Top-level mutable state for the MCP server.
227///
228/// Holds a ledger of all live [`Session`]s, indexed by `session_id` (opaque
229/// hash) and `alias` (human-readable label). One session is designated the
230/// **default session**, returned by [`Self::session`] for backward-compatible
231/// tool calls that pre-date per-call `session_id` addressing.
232///
233/// The MCP handler wraps this in `Arc<RwLock<LdsState>>` for concurrent tool
234/// access. Mutations (create / close / alias) take the write lock; reads
235/// (resolve / list / describe / doctor) take the read lock.
236#[derive(Debug, Clone)]
237pub struct LdsState {
238    /// id -> Arc<Session>
239    sessions: HashMap<String, Arc<Session>>,
240    /// alias -> id (alias resolution map)
241    aliases: HashMap<String, String>,
242    /// The implicit default session for backward-compat callers.
243    /// `None` until the first session_start / session_create.
244    default_id: Option<String>,
245}
246
247/// Snapshot of a single session entry, returned by ledger introspection APIs.
248#[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    /// Create a new session and register it in the ledger.
281    ///
282    /// If `make_default` is true (or no default exists yet), the new session
283    /// becomes the implicit default.
284    ///
285    /// Returns [`CoreError::AliasConflict`] if `config.alias` is already
286    /// taken by another session.
287    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    /// Backward-compatible entry point used by the legacy `session_start`
311    /// MCP tool. Always replaces the default session.
312    pub fn start_session(&mut self, config: SessionConfig) -> Result<Arc<Session>, CoreError> {
313        self.create_session(config, true)
314    }
315
316    /// Look up a session by id or alias.
317    ///
318    /// `key` is tried as an alias first, then as a session_id. Returns
319    /// [`CoreError::SessionNotFound`] if neither matches.
320    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    /// Return the default session for backward-compatible tool calls.
333    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    /// Snapshot every session in the ledger.
347    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    /// Describe a single session by id or alias.
361    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    /// Assign or change an alias on an existing session.
368    ///
369    /// Returns [`CoreError::AliasConflict`] if the new alias is already held
370    /// by another session.
371    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        // Drop the session's previous alias, if any.
380        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    /// Remove an alias from the ledger. The session itself remains.
389    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    /// Close (drop) a session by id or alias.
401    ///
402    /// If the closed session was the default, the default is cleared and
403    /// must be re-set by a subsequent `session_start` / `session_create`
404    /// with `make_default=true`.
405    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// ── Doctor ────────────────────────────────────────────────────────────────
426
427/// Per-check verdict returned by [`LdsState::doctor`].
428#[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; // 6h idle → ledger-leak WARN
461
462impl LdsState {
463    /// Run health checks on a single session (by id / alias).
464    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        // C1 root-exists
469        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        // C2 git-bound (presence of .git, file or dir = git worktree)
485        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        // C3 journal-db-writable
501        let journal_dir = root.join("workspace");
502        let journal_status = if !journal_dir.exists() {
503            CheckStatus::Warn
504        } else {
505            // Probe writability by attempting to create a temp marker.
506            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        // C4 stale-lock (heuristic: look for .lock files older than 1h)
518        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        // C5 ownership-drift: detect multiple sessions claiming the same root.
544        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        // C6 root-conflict: same as C5 but raises to FAIL when ≥2 conflicts.
564        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        // C7 ledger-leak: idle for > IDLE_WARN_SECS.
575        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
612/// Generate a session uniqueness identifier as `{nanos_hex}-{pid_hex}`.
613///
614/// This is NOT an RFC 4122 UUID — it is a lightweight identifier used
615/// for session ownership tracking and log correlation. It carries no
616/// cryptographic randomness guarantees. Use the `uuid` crate if a
617/// RFC 4122 v4 UUID is required.
618fn 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    // ── resolve_worktrees_dir precedence (env parsing intentionally
633    //    excluded — std::env is process-global and races other tests) ────────
634
635    #[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    // ── CoreError Display invariants (I1 / I2) ───────────────────────────────
670
671    #[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    // ── SessionError / Session::ensure_alive ─────────────────────────────────
697
698    #[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    // ── Ledger (multi-session) ───────────────────────────────────────────────
726
727    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        // Reassign to a new alias — previous one is freed.
806        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        // Force a measurable timestamp gap (epoch_secs is second-resolution).
832        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    // ── Doctor checks ────────────────────────────────────────────────────────
850
851    #[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}