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