1pub 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; #[derive(Debug, Default)]
21pub struct SessionConfig {
22 pub root: PathBuf,
23 pub timeout_secs: Option<u64>,
24 pub max_output: Option<usize>,
25 pub global_recipe_dirs: Vec<PathBuf>,
32}
33
34#[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#[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#[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 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#[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
158pub 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#[derive(Debug, Clone, serde::Serialize)]
178pub struct BinaryStatus {
179 pub name: String,
180 pub available: bool,
181 pub path: Option<String>,
182}
183
184pub 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
199pub 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 (b & 0xC0) != 0x80
244}
245
246fn 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 #[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 #[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 #[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 let data = "あいうえお".as_bytes(); let (out, truncated) = truncate_output(data, 10);
371 assert!(truncated);
372 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 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}