Skip to main content

lds_core/
lib.rs

1//! Core session state 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
8pub mod config;
9pub mod log_store;
10
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use std::time::Duration;
14
15const DEFAULT_TIMEOUT_SECS: u64 = 60;
16const DEFAULT_MAX_OUTPUT: usize = 102_400; // 100KB
17
18/// Configuration passed to [`Session::new`]. Optional fields fall back
19/// to sensible defaults (60s timeout, 100KB output limit).
20#[derive(Debug, Default)]
21pub struct SessionConfig {
22    pub root: PathBuf,
23    pub timeout_secs: Option<u64>,
24    pub max_output: Option<usize>,
25    /// Additional global recipe directories, in precedence order (lowest first).
26    ///
27    /// The default `~/.config/lds` is always consulted by `build_resolve_chain`
28    /// regardless of this list. Entries here are pushed after the default and before
29    /// the project justfile. Populate via `LDS_RECIPE_GLOBAL_DIRS` (colon-separated)
30    /// and/or the `global_recipe_dir` MCP wire argument.
31    pub global_recipe_dirs: Vec<PathBuf>,
32}
33
34/// Errors that can occur during a [`Session`]'s post-construction lifecycle.
35#[derive(Debug, thiserror::Error)]
36pub enum SessionError {
37    #[error(
38        "session root path no longer exists, please call session_start again: {}",
39        _0.display()
40    )]
41    RootGone(PathBuf),
42}
43
44/// Errors that can occur during session construction or access.
45#[derive(Debug, thiserror::Error)]
46pub enum CoreError {
47    #[error("session root does not exist: {}", _0.display())]
48    RootNotFound(PathBuf),
49    #[error("no active session — call session_start first")]
50    NoSession,
51}
52
53/// Immutable session state created by `session_start`.
54///
55/// Cloned (via `Arc`) into each module. Holds the project root and
56/// cross-cutting concerns that every module may need.
57#[derive(Debug, Clone)]
58pub struct Session {
59    root: PathBuf,
60    session_id: String,
61    timeout: Duration,
62    max_output: usize,
63    global_recipe_dirs: Vec<PathBuf>,
64}
65
66impl Session {
67    pub fn new(config: SessionConfig) -> Result<Self, CoreError> {
68        let root = config.root;
69        if !root.is_dir() {
70            tracing::warn!(root = %root.display(), "session root does not exist");
71            return Err(CoreError::RootNotFound(root));
72        }
73        let session_id = session_id_new();
74        let timeout = Duration::from_secs(config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
75        let max_output = config.max_output.unwrap_or(DEFAULT_MAX_OUTPUT);
76        tracing::info!(
77            root = %root.display(),
78            session_id = %session_id,
79            timeout_secs = timeout.as_secs(),
80            max_output,
81            "session started"
82        );
83        let global_recipe_dirs = config.global_recipe_dirs;
84        Ok(Self {
85            root,
86            session_id,
87            timeout,
88            max_output,
89            global_recipe_dirs,
90        })
91    }
92
93    pub fn root(&self) -> &Path {
94        &self.root
95    }
96
97    pub fn id(&self) -> &str {
98        &self.session_id
99    }
100
101    pub fn timeout(&self) -> Duration {
102        self.timeout
103    }
104
105    pub fn max_output(&self) -> usize {
106        self.max_output
107    }
108
109    pub fn global_recipe_dirs(&self) -> &[PathBuf] {
110        &self.global_recipe_dirs
111    }
112
113    /// Check that the session root directory still exists.
114    ///
115    /// Call this at each entry point (e.g. `list`, `run`) to detect a deleted
116    /// root before attempting I/O. Returns [`SessionError::RootGone`] if the
117    /// directory no longer exists.
118    pub fn ensure_alive(&self) -> Result<(), SessionError> {
119        if !self.root.is_dir() {
120            tracing::warn!(root = %self.root.display(), "session root no longer exists");
121            return Err(SessionError::RootGone(self.root.clone()));
122        }
123        Ok(())
124    }
125}
126
127/// Top-level mutable state for the MCP server.
128///
129/// Holds the current [`Session`] (if started). The MCP handler wraps
130/// this in `Arc<RwLock<LdsState>>` for concurrent tool access.
131#[derive(Debug, Clone)]
132pub struct LdsState {
133    session: Option<Arc<Session>>,
134}
135
136impl LdsState {
137    pub fn new() -> Self {
138        Self { session: None }
139    }
140
141    pub fn start_session(&mut self, config: SessionConfig) -> Result<Arc<Session>, CoreError> {
142        let session = Arc::new(Session::new(config)?);
143        self.session = Some(Arc::clone(&session));
144        Ok(session)
145    }
146
147    pub fn session(&self) -> Result<&Arc<Session>, CoreError> {
148        self.session.as_ref().ok_or(CoreError::NoSession)
149    }
150}
151
152impl Default for LdsState {
153    fn default() -> Self {
154        Self::new()
155    }
156}
157
158/// Check whether an executable is reachable via `PATH`.
159///
160/// Returns the resolved path on success. Used by `session_info` to
161/// report degraded-mode availability of external tools that lds
162/// (and plugin recipes) depend on (`git`, `just`, `python3`,
163/// `codedash`, `rg`, etc.). Agents read the result to decide between
164/// a typed tool path and an in-band fallback.
165pub fn find_in_path(binary: &str) -> Option<PathBuf> {
166    let path = std::env::var_os("PATH")?;
167    for dir in std::env::split_paths(&path) {
168        let candidate = dir.join(binary);
169        if candidate.is_file() {
170            return Some(candidate);
171        }
172    }
173    None
174}
175
176/// Availability status for an external binary.
177#[derive(Debug, Clone, serde::Serialize)]
178pub struct BinaryStatus {
179    pub name: String,
180    pub available: bool,
181    pub path: Option<String>,
182}
183
184/// Check a set of external binaries and return their availability.
185pub fn check_binaries(names: &[&str]) -> Vec<BinaryStatus> {
186    names
187        .iter()
188        .map(|name| {
189            let resolved = find_in_path(name);
190            BinaryStatus {
191                name: (*name).to_string(),
192                available: resolved.is_some(),
193                path: resolved.map(|p| p.display().to_string()),
194            }
195        })
196        .collect()
197}
198
199/// Truncate byte output to `max` bytes, splitting into head + tail halves.
200///
201/// Returns `(output_string, was_truncated)`. When truncated, inserts a
202/// marker line between the halves showing the original size. Splits are
203/// aligned to UTF-8 character boundaries so the result is always valid.
204pub fn truncate_output(raw: &[u8], max: usize) -> (String, bool) {
205    if raw.len() <= max {
206        return (String::from_utf8_lossy(raw).into_owned(), false);
207    }
208    let half = max / 2;
209    let head_end = find_utf8_boundary(raw, half);
210    let tail_start = find_utf8_boundary_rev(raw, raw.len() - half);
211    let head = String::from_utf8_lossy(&raw[..head_end]);
212    let tail = String::from_utf8_lossy(&raw[tail_start..]);
213    let mut out = head.into_owned();
214    out.push_str(&format!(
215        "\n\n--- [truncated: {} bytes total, showing first/last ~{} bytes] ---\n\n",
216        raw.len(),
217        half,
218    ));
219    out.push_str(&tail);
220    (out, true)
221}
222
223fn find_utf8_boundary(buf: &[u8], pos: usize) -> usize {
224    let pos = pos.min(buf.len());
225    let mut i = pos;
226    while i > 0 && !is_utf8_char_start(buf[i]) {
227        i -= 1;
228    }
229    i
230}
231
232fn find_utf8_boundary_rev(buf: &[u8], pos: usize) -> usize {
233    let pos = pos.min(buf.len());
234    let mut i = pos;
235    while i < buf.len() && !is_utf8_char_start(buf[i]) {
236        i += 1;
237    }
238    i
239}
240
241fn is_utf8_char_start(b: u8) -> bool {
242    // UTF-8 continuation bytes are 0b10xxxxxx (0x80..0xBF)
243    (b & 0xC0) != 0x80
244}
245
246/// Generate a session uniqueness identifier as `{nanos_hex}-{pid_hex}`.
247///
248/// This is NOT an RFC 4122 UUID — it is a lightweight identifier used
249/// for session ownership tracking and log correlation. It carries no
250/// cryptographic randomness guarantees. Use the `uuid` crate if a
251/// RFC 4122 v4 UUID is required.
252fn session_id_new() -> String {
253    use std::time::{SystemTime, UNIX_EPOCH};
254    let ts = SystemTime::now()
255        .duration_since(UNIX_EPOCH)
256        .unwrap_or_default()
257        .as_nanos();
258    let pid = std::process::id();
259    format!("{ts:x}-{pid:x}")
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    // ── CoreError Display invariants (I1 / I2) ───────────────────────────────
267
268    #[test]
269    fn core_error_root_not_found_display_contains_prefix_and_path() {
270        let path = PathBuf::from("/some/missing/root");
271        let err = CoreError::RootNotFound(path.clone());
272        let msg = err.to_string();
273        assert!(
274            msg.contains("session root does not exist: "),
275            "I1: message must start with invariant prefix, got: {msg}"
276        );
277        assert!(
278            msg.contains("/some/missing/root"),
279            "I1: message must contain the path, got: {msg}"
280        );
281    }
282
283    #[test]
284    fn core_error_no_session_display_matches_invariant() {
285        let err = CoreError::NoSession;
286        let msg = err.to_string();
287        assert_eq!(
288            msg, "no active session \u{2014} call session_start first",
289            "I2: message must exactly match invariant string"
290        );
291    }
292
293    // ── SessionError / Session::ensure_alive ─────────────────────────────────
294
295    #[test]
296    fn session_error_root_gone_message_contains_invariant_substring() {
297        use std::path::PathBuf;
298        let path = PathBuf::from("/tmp/gone");
299        let err = SessionError::RootGone(path.clone());
300        let msg = err.to_string();
301        assert!(
302            msg.contains("session root path no longer exists, please call session_start again"),
303            "error message must contain the K-239 recovery substring, got: {msg}"
304        );
305        assert!(
306            msg.contains("/tmp/gone"),
307            "error message must include the path, got: {msg}"
308        );
309    }
310
311    #[test]
312    fn ensure_alive_ok_when_root_exists() {
313        let tmp = tempfile::tempdir().unwrap();
314        let session = Session::new(SessionConfig {
315            root: tmp.path().to_path_buf(),
316            ..Default::default()
317        })
318        .unwrap();
319        assert!(session.ensure_alive().is_ok());
320    }
321
322    #[test]
323    fn ensure_alive_err_when_root_deleted() {
324        let tmp = tempfile::tempdir().unwrap();
325        let path = tmp.path().to_path_buf();
326        let session = Session::new(SessionConfig {
327            root: path.clone(),
328            ..Default::default()
329        })
330        .unwrap();
331        std::fs::remove_dir_all(&path).unwrap();
332        let err = session.ensure_alive().unwrap_err();
333        let msg = err.to_string();
334        assert!(
335            msg.contains("session root path no longer exists, please call session_start again"),
336            "expected K-239 substring, got: {msg}"
337        );
338    }
339
340    // ── truncate_output ──────────────────────────────────────────────────────
341
342    #[test]
343    fn truncate_short_input() {
344        let data = b"hello world";
345        let (out, truncated) = truncate_output(data, 200);
346        assert_eq!(out, "hello world");
347        assert!(!truncated);
348    }
349
350    #[test]
351    fn truncate_empty() {
352        let (out, truncated) = truncate_output(b"", 100);
353        assert_eq!(out, "");
354        assert!(!truncated);
355    }
356
357    #[test]
358    fn truncate_over_limit() {
359        let data: Vec<u8> = (0..1000).map(|i| b'A' + (i % 26) as u8).collect();
360        let (out, truncated) = truncate_output(&data, 100);
361        assert!(truncated);
362        assert!(out.contains("[truncated:"));
363        assert!(out.len() < data.len());
364    }
365
366    #[test]
367    fn truncate_multibyte_boundary() {
368        // "あいう" = 9 bytes (3 chars × 3 bytes each)
369        let data = "あいうえお".as_bytes(); // 15 bytes
370        let (out, truncated) = truncate_output(data, 10);
371        assert!(truncated);
372        // Should not produce invalid UTF-8
373        assert!(out.is_ascii() || out.chars().all(|c| c.len_utf8() > 0));
374    }
375
376    #[test]
377    fn truncate_exact_limit() {
378        let data = b"exactly ten";
379        let (out, truncated) = truncate_output(data, data.len());
380        assert_eq!(out, "exactly ten");
381        assert!(!truncated);
382    }
383
384    #[test]
385    fn find_in_path_resolves_common_binary() {
386        // `sh` is essentially guaranteed on every Unix.
387        let resolved = find_in_path("sh");
388        assert!(resolved.is_some(), "sh should be on PATH");
389        assert!(resolved.unwrap().is_file());
390    }
391
392    #[test]
393    fn find_in_path_returns_none_for_unknown() {
394        assert!(find_in_path("definitely-not-a-real-binary-xyz-12345").is_none());
395    }
396
397    #[test]
398    fn check_binaries_marks_missing() {
399        let report = check_binaries(&["sh", "definitely-not-a-real-binary-xyz-12345"]);
400        assert_eq!(report.len(), 2);
401        assert_eq!(report[0].name, "sh");
402        assert!(report[0].available);
403        assert!(report[0].path.is_some());
404        assert_eq!(report[1].name, "definitely-not-a-real-binary-xyz-12345");
405        assert!(!report[1].available);
406        assert!(report[1].path.is_none());
407    }
408}