pub mod config;
pub mod log_store;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
const DEFAULT_TIMEOUT_SECS: u64 = 60;
const DEFAULT_MAX_OUTPUT: usize = 102_400;
#[derive(Debug, Default)]
pub struct SessionConfig {
pub root: PathBuf,
pub timeout_secs: Option<u64>,
pub max_output: Option<usize>,
pub global_recipe_dirs: Vec<PathBuf>,
}
#[derive(Debug, thiserror::Error)]
pub enum SessionError {
#[error(
"session root path no longer exists, please call session_start again: {}",
_0.display()
)]
RootGone(PathBuf),
}
#[derive(Debug, thiserror::Error)]
pub enum CoreError {
#[error("session root does not exist: {}", _0.display())]
RootNotFound(PathBuf),
#[error("no active session — call session_start first")]
NoSession,
}
#[derive(Debug, Clone)]
pub struct Session {
root: PathBuf,
session_id: String,
timeout: Duration,
max_output: usize,
global_recipe_dirs: Vec<PathBuf>,
}
impl Session {
pub fn new(config: SessionConfig) -> Result<Self, CoreError> {
let root = config.root;
if !root.is_dir() {
tracing::warn!(root = %root.display(), "session root does not exist");
return Err(CoreError::RootNotFound(root));
}
let session_id = session_id_new();
let timeout = Duration::from_secs(config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
let max_output = config.max_output.unwrap_or(DEFAULT_MAX_OUTPUT);
tracing::info!(
root = %root.display(),
session_id = %session_id,
timeout_secs = timeout.as_secs(),
max_output,
"session started"
);
let global_recipe_dirs = config.global_recipe_dirs;
Ok(Self {
root,
session_id,
timeout,
max_output,
global_recipe_dirs,
})
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn id(&self) -> &str {
&self.session_id
}
pub fn timeout(&self) -> Duration {
self.timeout
}
pub fn max_output(&self) -> usize {
self.max_output
}
pub fn global_recipe_dirs(&self) -> &[PathBuf] {
&self.global_recipe_dirs
}
pub fn ensure_alive(&self) -> Result<(), SessionError> {
if !self.root.is_dir() {
tracing::warn!(root = %self.root.display(), "session root no longer exists");
return Err(SessionError::RootGone(self.root.clone()));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct LdsState {
session: Option<Arc<Session>>,
}
impl LdsState {
pub fn new() -> Self {
Self { session: None }
}
pub fn start_session(&mut self, config: SessionConfig) -> Result<Arc<Session>, CoreError> {
let session = Arc::new(Session::new(config)?);
self.session = Some(Arc::clone(&session));
Ok(session)
}
pub fn session(&self) -> Result<&Arc<Session>, CoreError> {
self.session.as_ref().ok_or(CoreError::NoSession)
}
}
impl Default for LdsState {
fn default() -> Self {
Self::new()
}
}
pub fn find_in_path(binary: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(binary);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct BinaryStatus {
pub name: String,
pub available: bool,
pub path: Option<String>,
}
pub fn check_binaries(names: &[&str]) -> Vec<BinaryStatus> {
names
.iter()
.map(|name| {
let resolved = find_in_path(name);
BinaryStatus {
name: (*name).to_string(),
available: resolved.is_some(),
path: resolved.map(|p| p.display().to_string()),
}
})
.collect()
}
pub fn truncate_output(raw: &[u8], max: usize) -> (String, bool) {
if raw.len() <= max {
return (String::from_utf8_lossy(raw).into_owned(), false);
}
let half = max / 2;
let head_end = find_utf8_boundary(raw, half);
let tail_start = find_utf8_boundary_rev(raw, raw.len() - half);
let head = String::from_utf8_lossy(&raw[..head_end]);
let tail = String::from_utf8_lossy(&raw[tail_start..]);
let mut out = head.into_owned();
out.push_str(&format!(
"\n\n--- [truncated: {} bytes total, showing first/last ~{} bytes] ---\n\n",
raw.len(),
half,
));
out.push_str(&tail);
(out, true)
}
fn find_utf8_boundary(buf: &[u8], pos: usize) -> usize {
let pos = pos.min(buf.len());
let mut i = pos;
while i > 0 && !is_utf8_char_start(buf[i]) {
i -= 1;
}
i
}
fn find_utf8_boundary_rev(buf: &[u8], pos: usize) -> usize {
let pos = pos.min(buf.len());
let mut i = pos;
while i < buf.len() && !is_utf8_char_start(buf[i]) {
i += 1;
}
i
}
fn is_utf8_char_start(b: u8) -> bool {
(b & 0xC0) != 0x80
}
fn session_id_new() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let pid = std::process::id();
format!("{ts:x}-{pid:x}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn core_error_root_not_found_display_contains_prefix_and_path() {
let path = PathBuf::from("/some/missing/root");
let err = CoreError::RootNotFound(path.clone());
let msg = err.to_string();
assert!(
msg.contains("session root does not exist: "),
"I1: message must start with invariant prefix, got: {msg}"
);
assert!(
msg.contains("/some/missing/root"),
"I1: message must contain the path, got: {msg}"
);
}
#[test]
fn core_error_no_session_display_matches_invariant() {
let err = CoreError::NoSession;
let msg = err.to_string();
assert_eq!(
msg, "no active session \u{2014} call session_start first",
"I2: message must exactly match invariant string"
);
}
#[test]
fn session_error_root_gone_message_contains_invariant_substring() {
use std::path::PathBuf;
let path = PathBuf::from("/tmp/gone");
let err = SessionError::RootGone(path.clone());
let msg = err.to_string();
assert!(
msg.contains("session root path no longer exists, please call session_start again"),
"error message must contain the K-239 recovery substring, got: {msg}"
);
assert!(
msg.contains("/tmp/gone"),
"error message must include the path, got: {msg}"
);
}
#[test]
fn ensure_alive_ok_when_root_exists() {
let tmp = tempfile::tempdir().unwrap();
let session = Session::new(SessionConfig {
root: tmp.path().to_path_buf(),
..Default::default()
})
.unwrap();
assert!(session.ensure_alive().is_ok());
}
#[test]
fn ensure_alive_err_when_root_deleted() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().to_path_buf();
let session = Session::new(SessionConfig {
root: path.clone(),
..Default::default()
})
.unwrap();
std::fs::remove_dir_all(&path).unwrap();
let err = session.ensure_alive().unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("session root path no longer exists, please call session_start again"),
"expected K-239 substring, got: {msg}"
);
}
#[test]
fn truncate_short_input() {
let data = b"hello world";
let (out, truncated) = truncate_output(data, 200);
assert_eq!(out, "hello world");
assert!(!truncated);
}
#[test]
fn truncate_empty() {
let (out, truncated) = truncate_output(b"", 100);
assert_eq!(out, "");
assert!(!truncated);
}
#[test]
fn truncate_over_limit() {
let data: Vec<u8> = (0..1000).map(|i| b'A' + (i % 26) as u8).collect();
let (out, truncated) = truncate_output(&data, 100);
assert!(truncated);
assert!(out.contains("[truncated:"));
assert!(out.len() < data.len());
}
#[test]
fn truncate_multibyte_boundary() {
let data = "あいうえお".as_bytes(); let (out, truncated) = truncate_output(data, 10);
assert!(truncated);
assert!(out.is_ascii() || out.chars().all(|c| c.len_utf8() > 0));
}
#[test]
fn truncate_exact_limit() {
let data = b"exactly ten";
let (out, truncated) = truncate_output(data, data.len());
assert_eq!(out, "exactly ten");
assert!(!truncated);
}
#[test]
fn find_in_path_resolves_common_binary() {
let resolved = find_in_path("sh");
assert!(resolved.is_some(), "sh should be on PATH");
assert!(resolved.unwrap().is_file());
}
#[test]
fn find_in_path_returns_none_for_unknown() {
assert!(find_in_path("definitely-not-a-real-binary-xyz-12345").is_none());
}
#[test]
fn check_binaries_marks_missing() {
let report = check_binaries(&["sh", "definitely-not-a-real-binary-xyz-12345"]);
assert_eq!(report.len(), 2);
assert_eq!(report[0].name, "sh");
assert!(report[0].available);
assert!(report[0].path.is_some());
assert_eq!(report[1].name, "definitely-not-a-real-binary-xyz-12345");
assert!(!report[1].available);
assert!(report[1].path.is_none());
}
}