use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 16 * 1024 * 1024;
pub const DEFAULT_MAX_CONCURRENT: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobStatus {
Running,
Exited(Option<i32>),
Killed,
}
impl JobStatus {
pub fn as_str(self) -> &'static str {
match self {
JobStatus::Running => "running",
JobStatus::Exited(_) => "exited",
JobStatus::Killed => "killed",
}
}
}
#[derive(Debug, Default)]
pub struct CapturedOutput {
inner: Mutex<CaptureState>,
}
#[derive(Debug, Default)]
struct CaptureState {
buf: String,
truncated: bool,
drained: usize,
}
impl CapturedOutput {
pub fn new() -> Self {
CapturedOutput::default()
}
pub fn append(&self, chunk: &str, cap: usize) {
if chunk.is_empty() {
return;
}
let Ok(mut st) = self.inner.lock() else {
return;
};
if st.buf.len() >= cap {
st.truncated = true;
return;
}
let remaining = cap - st.buf.len();
if chunk.len() <= remaining {
st.buf.push_str(chunk);
} else {
let mut end = remaining;
while end > 0 && !chunk.is_char_boundary(end) {
end -= 1;
}
st.buf.push_str(&chunk[..end]);
st.truncated = true;
}
}
pub fn snapshot(&self) -> (String, bool) {
self.inner
.lock()
.map(|st| (st.buf.clone(), st.truncated))
.unwrap_or_default()
}
pub fn drain_new(&self) -> String {
let Ok(mut st) = self.inner.lock() else {
return String::new();
};
let new = st.buf[st.drained..].to_string();
st.drained = st.buf.len();
new
}
}
static JOB_ID_SEQ: AtomicU64 = AtomicU64::new(0);
pub fn next_job_id(now_ms: i64) -> String {
let seq = JOB_ID_SEQ.fetch_add(1, Ordering::Relaxed);
format!("bg-{now_ms:x}-{seq:x}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn job_status_as_str_matches_the_documented_schema_values() {
assert_eq!(JobStatus::Running.as_str(), "running");
assert_eq!(JobStatus::Exited(Some(0)).as_str(), "exited");
assert_eq!(JobStatus::Exited(None).as_str(), "exited");
assert_eq!(JobStatus::Killed.as_str(), "killed");
}
#[test]
fn next_job_id_is_unique_across_calls_even_at_the_same_timestamp() {
let a = next_job_id(1000);
let b = next_job_id(1000);
assert_ne!(a, b);
assert!(a.starts_with("bg-"));
}
#[test]
fn captured_output_appends_under_the_cap_without_truncation() {
let out = CapturedOutput::new();
out.append("hello ", 100);
out.append("world", 100);
let (buf, truncated) = out.snapshot();
assert_eq!(buf, "hello world");
assert!(!truncated);
}
#[test]
fn captured_output_never_grows_past_the_cap_and_latches_truncated() {
let out = CapturedOutput::new();
out.append("0123456789", 5); let (buf, truncated) = out.snapshot();
assert_eq!(buf, "01234");
assert!(truncated);
assert_eq!(buf.len(), 5);
out.append("more data that must be dropped entirely", 5);
let (buf2, truncated2) = out.snapshot();
assert_eq!(buf2, "01234");
assert!(truncated2);
}
#[test]
fn captured_output_respects_char_boundaries_when_truncating() {
let out = CapturedOutput::new();
out.append("héllo", 2);
let (buf, truncated) = out.snapshot();
assert!(truncated);
assert!(buf.is_char_boundary(buf.len()));
assert!(std::str::from_utf8(buf.as_bytes()).is_ok());
}
#[test]
fn drain_new_returns_only_the_delta_since_the_last_call() {
let out = CapturedOutput::new();
out.append("first ", 1000);
assert_eq!(out.drain_new(), "first ");
assert_eq!(out.drain_new(), "", "no new output since the last drain");
out.append("second", 1000);
assert_eq!(out.drain_new(), "second");
}
#[test]
fn ten_mb_of_appends_never_retains_past_the_configured_cap() {
let out = CapturedOutput::new();
let cap = 1024;
let chunk = "x".repeat(4096);
for _ in 0..2560 {
out.append(&chunk, cap);
let (buf, _) = out.snapshot();
assert!(buf.len() <= cap, "buffer must never exceed the cap");
}
let (buf, truncated) = out.snapshot();
assert_eq!(buf.len(), cap);
assert!(truncated);
}
}