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::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::time::Duration;
16
17const DEFAULT_TIMEOUT_SECS: u64 = 60;
18const DEFAULT_MAX_OUTPUT: usize = 102_400; // 100KB
19
20/// Configuration passed to [`Session::new`]. Optional fields fall back
21/// to sensible defaults (60s timeout, 100KB output limit).
22#[derive(Debug, Default)]
23pub struct SessionConfig {
24    pub root: PathBuf,
25    pub timeout_secs: Option<u64>,
26    pub max_output: Option<usize>,
27    /// Additional global recipe directories, in precedence order (lowest first).
28    ///
29    /// The default `~/.config/lds` is always consulted by `build_resolve_chain`
30    /// regardless of this list. Entries here are pushed after the default and before
31    /// the project justfile. Populate via `LDS_RECIPE_GLOBAL_DIRS` (colon-separated)
32    /// and/or the `global_recipe_dir` MCP wire argument.
33    pub global_recipe_dirs: Vec<PathBuf>,
34}
35
36/// Errors that can occur during a [`Session`]'s post-construction lifecycle.
37#[derive(Debug, thiserror::Error)]
38pub enum SessionError {
39    #[error(
40        "session root path no longer exists, please call session_start again: {}",
41        _0.display()
42    )]
43    RootGone(PathBuf),
44}
45
46/// Errors that can occur during session construction or access.
47#[derive(Debug, thiserror::Error)]
48pub enum CoreError {
49    #[error("session root does not exist: {}", _0.display())]
50    RootNotFound(PathBuf),
51    #[error("no active session — call session_start first")]
52    NoSession,
53}
54
55/// Immutable session state created by `session_start`.
56///
57/// Cloned (via `Arc`) into each module. Holds the project root and
58/// cross-cutting concerns that every module may need.
59#[derive(Debug, Clone)]
60pub struct Session {
61    root: PathBuf,
62    session_id: String,
63    timeout: Duration,
64    max_output: usize,
65    global_recipe_dirs: Vec<PathBuf>,
66}
67
68impl Session {
69    pub fn new(config: SessionConfig) -> Result<Self, CoreError> {
70        let root = config.root;
71        if !root.is_dir() {
72            tracing::warn!(root = %root.display(), "session root does not exist");
73            return Err(CoreError::RootNotFound(root));
74        }
75        let session_id = session_id_new();
76        let timeout = Duration::from_secs(config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
77        let max_output = config.max_output.unwrap_or(DEFAULT_MAX_OUTPUT);
78        tracing::info!(
79            root = %root.display(),
80            session_id = %session_id,
81            timeout_secs = timeout.as_secs(),
82            max_output,
83            "session started"
84        );
85        let global_recipe_dirs = config.global_recipe_dirs;
86        Ok(Self {
87            root,
88            session_id,
89            timeout,
90            max_output,
91            global_recipe_dirs,
92        })
93    }
94
95    pub fn root(&self) -> &Path {
96        &self.root
97    }
98
99    pub fn id(&self) -> &str {
100        &self.session_id
101    }
102
103    pub fn timeout(&self) -> Duration {
104        self.timeout
105    }
106
107    pub fn max_output(&self) -> usize {
108        self.max_output
109    }
110
111    pub fn global_recipe_dirs(&self) -> &[PathBuf] {
112        &self.global_recipe_dirs
113    }
114
115    /// Check that the session root directory still exists.
116    ///
117    /// Call this at each entry point (e.g. `list`, `run`) to detect a deleted
118    /// root before attempting I/O. Returns [`SessionError::RootGone`] if the
119    /// directory no longer exists.
120    pub fn ensure_alive(&self) -> Result<(), SessionError> {
121        if !self.root.is_dir() {
122            tracing::warn!(root = %self.root.display(), "session root no longer exists");
123            return Err(SessionError::RootGone(self.root.clone()));
124        }
125        Ok(())
126    }
127}
128
129/// Top-level mutable state for the MCP server.
130///
131/// Holds the current [`Session`] (if started). The MCP handler wraps
132/// this in `Arc<RwLock<LdsState>>` for concurrent tool access.
133#[derive(Debug, Clone)]
134pub struct LdsState {
135    session: Option<Arc<Session>>,
136}
137
138impl LdsState {
139    pub fn new() -> Self {
140        Self { session: None }
141    }
142
143    pub fn start_session(&mut self, config: SessionConfig) -> Result<Arc<Session>, CoreError> {
144        let session = Arc::new(Session::new(config)?);
145        self.session = Some(Arc::clone(&session));
146        Ok(session)
147    }
148
149    pub fn session(&self) -> Result<&Arc<Session>, CoreError> {
150        self.session.as_ref().ok_or(CoreError::NoSession)
151    }
152}
153
154impl Default for LdsState {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160/// Generate a session uniqueness identifier as `{nanos_hex}-{pid_hex}`.
161///
162/// This is NOT an RFC 4122 UUID — it is a lightweight identifier used
163/// for session ownership tracking and log correlation. It carries no
164/// cryptographic randomness guarantees. Use the `uuid` crate if a
165/// RFC 4122 v4 UUID is required.
166fn session_id_new() -> String {
167    use std::time::{SystemTime, UNIX_EPOCH};
168    let ts = SystemTime::now()
169        .duration_since(UNIX_EPOCH)
170        .unwrap_or_default()
171        .as_nanos();
172    let pid = std::process::id();
173    format!("{ts:x}-{pid:x}")
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    // ── CoreError Display invariants (I1 / I2) ───────────────────────────────
181
182    #[test]
183    fn core_error_root_not_found_display_contains_prefix_and_path() {
184        let path = PathBuf::from("/some/missing/root");
185        let err = CoreError::RootNotFound(path.clone());
186        let msg = err.to_string();
187        assert!(
188            msg.contains("session root does not exist: "),
189            "I1: message must start with invariant prefix, got: {msg}"
190        );
191        assert!(
192            msg.contains("/some/missing/root"),
193            "I1: message must contain the path, got: {msg}"
194        );
195    }
196
197    #[test]
198    fn core_error_no_session_display_matches_invariant() {
199        let err = CoreError::NoSession;
200        let msg = err.to_string();
201        assert_eq!(
202            msg, "no active session \u{2014} call session_start first",
203            "I2: message must exactly match invariant string"
204        );
205    }
206
207    // ── SessionError / Session::ensure_alive ─────────────────────────────────
208
209    #[test]
210    fn session_error_root_gone_message_contains_invariant_substring() {
211        use std::path::PathBuf;
212        let path = PathBuf::from("/tmp/gone");
213        let err = SessionError::RootGone(path.clone());
214        let msg = err.to_string();
215        assert!(
216            msg.contains("session root path no longer exists, please call session_start again"),
217            "error message must contain the K-239 recovery substring, got: {msg}"
218        );
219        assert!(
220            msg.contains("/tmp/gone"),
221            "error message must include the path, got: {msg}"
222        );
223    }
224
225    #[test]
226    fn ensure_alive_ok_when_root_exists() {
227        let tmp = tempfile::tempdir().unwrap();
228        let session = Session::new(SessionConfig {
229            root: tmp.path().to_path_buf(),
230            ..Default::default()
231        })
232        .unwrap();
233        assert!(session.ensure_alive().is_ok());
234    }
235
236    #[test]
237    fn ensure_alive_err_when_root_deleted() {
238        let tmp = tempfile::tempdir().unwrap();
239        let path = tmp.path().to_path_buf();
240        let session = Session::new(SessionConfig {
241            root: path.clone(),
242            ..Default::default()
243        })
244        .unwrap();
245        std::fs::remove_dir_all(&path).unwrap();
246        let err = session.ensure_alive().unwrap_err();
247        let msg = err.to_string();
248        assert!(
249            msg.contains("session root path no longer exists, please call session_start again"),
250            "expected K-239 substring, got: {msg}"
251        );
252    }
253}