Skip to main content

lds_core/
lib.rs

1//! Core utilities shared across all lds modules.
2//!
3//! Session lifecycle (`Session`, `LdsState`, `SessionConfig`, `SessionError`,
4//! `CoreError`) lives in the sibling `lds-session` crate and is re-exported
5//! here for backward compatibility — existing `use lds_core::Session;` etc.
6//! continues to work unchanged.
7//!
8//! This crate retains cross-cutting helpers that do not belong to the
9//! session contract: binary probing (`find_in_path`, `check_binaries`,
10//! `BinaryStatus`), output truncation (`truncate_output`), config file
11//! handling (`config` module), and the in-memory log ring (`log_store`).
12
13pub mod config;
14pub mod log_store;
15
16pub use lds_session::{CoreError, LdsState, Session, SessionConfig, SessionError};
17
18use std::path::PathBuf;
19
20/// Check whether an executable is reachable via `PATH`.
21///
22/// Returns the resolved path on success. Used by `session_info` to
23/// report degraded-mode availability of external tools that lds
24/// (and plugin recipes) depend on (`git`, `just`, `python3`,
25/// `codedash`, `rg`, etc.). Agents read the result to decide between
26/// a typed tool path and an in-band fallback.
27pub fn find_in_path(binary: &str) -> Option<PathBuf> {
28    let path = std::env::var_os("PATH")?;
29    for dir in std::env::split_paths(&path) {
30        let candidate = dir.join(binary);
31        if candidate.is_file() {
32            return Some(candidate);
33        }
34    }
35    None
36}
37
38/// Availability status for an external binary.
39#[derive(Debug, Clone, serde::Serialize)]
40pub struct BinaryStatus {
41    pub name: String,
42    pub available: bool,
43    pub path: Option<String>,
44}
45
46/// Check a set of external binaries and return their availability.
47pub fn check_binaries(names: &[&str]) -> Vec<BinaryStatus> {
48    names
49        .iter()
50        .map(|name| {
51            let resolved = find_in_path(name);
52            BinaryStatus {
53                name: (*name).to_string(),
54                available: resolved.is_some(),
55                path: resolved.map(|p| p.display().to_string()),
56            }
57        })
58        .collect()
59}
60
61/// Truncate byte output to `max` bytes, splitting into head + tail halves.
62///
63/// Returns `(output_string, was_truncated)`. When truncated, inserts a
64/// marker line between the halves showing the original size. Splits are
65/// aligned to UTF-8 character boundaries so the result is always valid.
66pub fn truncate_output(raw: &[u8], max: usize) -> (String, bool) {
67    if raw.len() <= max {
68        return (String::from_utf8_lossy(raw).into_owned(), false);
69    }
70    let half = max / 2;
71    let head_end = find_utf8_boundary(raw, half);
72    let tail_start = find_utf8_boundary_rev(raw, raw.len() - half);
73    let head = String::from_utf8_lossy(&raw[..head_end]);
74    let tail = String::from_utf8_lossy(&raw[tail_start..]);
75    let mut out = head.into_owned();
76    out.push_str(&format!(
77        "\n\n--- [truncated: {} bytes total, showing first/last ~{} bytes] ---\n\n",
78        raw.len(),
79        half,
80    ));
81    out.push_str(&tail);
82    (out, true)
83}
84
85fn find_utf8_boundary(buf: &[u8], pos: usize) -> usize {
86    let pos = pos.min(buf.len());
87    let mut i = pos;
88    while i > 0 && !is_utf8_char_start(buf[i]) {
89        i -= 1;
90    }
91    i
92}
93
94fn find_utf8_boundary_rev(buf: &[u8], pos: usize) -> usize {
95    let pos = pos.min(buf.len());
96    let mut i = pos;
97    while i < buf.len() && !is_utf8_char_start(buf[i]) {
98        i += 1;
99    }
100    i
101}
102
103fn is_utf8_char_start(b: u8) -> bool {
104    // UTF-8 continuation bytes are 0b10xxxxxx (0x80..0xBF)
105    (b & 0xC0) != 0x80
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn truncate_short_input() {
114        let data = b"hello world";
115        let (out, truncated) = truncate_output(data, 200);
116        assert_eq!(out, "hello world");
117        assert!(!truncated);
118    }
119
120    #[test]
121    fn truncate_empty() {
122        let (out, truncated) = truncate_output(b"", 100);
123        assert_eq!(out, "");
124        assert!(!truncated);
125    }
126
127    #[test]
128    fn truncate_over_limit() {
129        let data: Vec<u8> = (0..1000).map(|i| b'A' + (i % 26) as u8).collect();
130        let (out, truncated) = truncate_output(&data, 100);
131        assert!(truncated);
132        assert!(out.contains("[truncated:"));
133        assert!(out.len() < data.len());
134    }
135
136    #[test]
137    fn truncate_multibyte_boundary() {
138        // "あいう" = 9 bytes (3 chars × 3 bytes each)
139        let data = "あいうえお".as_bytes(); // 15 bytes
140        let (out, truncated) = truncate_output(data, 10);
141        assert!(truncated);
142        // Should not produce invalid UTF-8
143        assert!(out.is_ascii() || out.chars().all(|c| c.len_utf8() > 0));
144    }
145
146    #[test]
147    fn truncate_exact_limit() {
148        let data = b"exactly ten";
149        let (out, truncated) = truncate_output(data, data.len());
150        assert_eq!(out, "exactly ten");
151        assert!(!truncated);
152    }
153
154    #[test]
155    fn find_in_path_resolves_common_binary() {
156        // `sh` is essentially guaranteed on every Unix.
157        let resolved = find_in_path("sh");
158        assert!(resolved.is_some(), "sh should be on PATH");
159        assert!(resolved.unwrap().is_file());
160    }
161
162    #[test]
163    fn find_in_path_returns_none_for_unknown() {
164        assert!(find_in_path("definitely-not-a-real-binary-xyz-12345").is_none());
165    }
166
167    #[test]
168    fn check_binaries_marks_missing() {
169        let report = check_binaries(&["sh", "definitely-not-a-real-binary-xyz-12345"]);
170        assert_eq!(report.len(), 2);
171        assert_eq!(report[0].name, "sh");
172        assert!(report[0].available);
173        assert!(report[0].path.is_some());
174        assert_eq!(report[1].name, "definitely-not-a-real-binary-xyz-12345");
175        assert!(!report[1].available);
176        assert!(report[1].path.is_none());
177    }
178}