use std::collections::VecDeque;
use std::process::{Command, Output};
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
pub const MAX_ENTRIES: usize = 256;
const MAX_OUTPUT_BYTES: usize = 8 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandStatus {
Exited(Option<i32>),
Spawn,
}
impl CommandStatus {
pub fn is_success(&self) -> bool {
matches!(self, CommandStatus::Exited(Some(0)))
}
}
#[derive(Debug, Clone)]
pub struct CommandLogEntry {
pub command: String,
pub duration: Duration,
pub status: CommandStatus,
pub output: String,
}
impl CommandLogEntry {
pub fn is_success(&self) -> bool {
self.status.is_success()
}
}
#[derive(Debug, Default)]
pub struct CommandLog {
entries: VecDeque<CommandLogEntry>,
}
impl CommandLog {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, entry: CommandLogEntry) {
if self.entries.len() == MAX_ENTRIES {
self.entries.pop_front();
}
self.entries.push_back(entry);
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &CommandLogEntry> {
self.entries.iter()
}
pub fn snapshot(&self) -> Vec<CommandLogEntry> {
self.entries.iter().cloned().collect()
}
pub fn clear(&mut self) {
self.entries.clear();
}
}
static GLOBAL: LazyLock<Mutex<CommandLog>> = LazyLock::new(|| Mutex::new(CommandLog::new()));
pub fn record(entry: CommandLogEntry) {
if let Ok(mut log) = GLOBAL.lock() {
log.push(entry);
}
}
pub fn snapshot() -> Vec<CommandLogEntry> {
GLOBAL.lock().map(|log| log.snapshot()).unwrap_or_default()
}
pub fn reset() {
if let Ok(mut log) = GLOBAL.lock() {
log.clear();
}
}
fn bound_output(stdout: &[u8], stderr: &[u8]) -> String {
let stdout = String::from_utf8_lossy(stdout);
let stdout = stdout.trim();
let stderr = String::from_utf8_lossy(stderr);
let stderr = stderr.trim();
let combined = match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => String::new(),
(false, true) => stdout.to_string(),
(true, false) => stderr.to_string(),
(false, false) => format!("{stdout}\n{stderr}"),
};
if combined.len() <= MAX_OUTPUT_BYTES {
return combined;
}
let cut = combined.len() - MAX_OUTPUT_BYTES;
let start = (cut..combined.len())
.find(|&i| combined.is_char_boundary(i))
.unwrap_or(combined.len());
combined[start..].to_string()
}
pub fn run_logged_with_stdin(
cmd: &mut Command,
command: String,
stdin: &[u8],
redact_output: bool,
) -> std::io::Result<Output> {
run_logged_inner(cmd, command, Some(stdin), redact_output)
}
pub fn run_logged_redacted(cmd: &mut Command, command: String) -> std::io::Result<Output> {
run_logged_inner(cmd, command, None, true)
}
fn run_logged_inner(
cmd: &mut Command,
command: String,
stdin: Option<&[u8]>,
redact_output: bool,
) -> std::io::Result<Output> {
use std::io::Write;
let start = Instant::now();
let result = match stdin {
None => cmd.output(),
Some(payload) => {
cmd
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
(|| {
let mut child = cmd.spawn()?;
child.stdin.take().expect("stdin was piped above").write_all(payload)?;
child.wait_with_output()
})()
}
};
let duration = start.elapsed();
match &result {
Ok(out) => record(CommandLogEntry {
command,
duration,
status: CommandStatus::Exited(out.status.code()),
output: if redact_output {
bound_output(WITHHELD_RESPONSE.as_bytes(), &out.stderr)
} else {
bound_output(&out.stdout, &out.stderr)
},
}),
Err(_) => record(CommandLogEntry {
command,
duration,
status: CommandStatus::Spawn,
output: String::new(),
}),
}
result
}
const WITHHELD_RESPONSE: &str = "<response withheld: it echoes the submitted body>";
pub fn run_logged(cmd: &mut Command, command: String) -> std::io::Result<Output> {
let start = Instant::now();
let result = cmd.output();
let duration = start.elapsed();
match &result {
Ok(out) => record(CommandLogEntry {
command,
duration,
status: CommandStatus::Exited(out.status.code()),
output: bound_output(&out.stdout, &out.stderr),
}),
Err(_) => record(CommandLogEntry {
command,
duration,
status: CommandStatus::Spawn,
output: String::new(),
}),
}
result
}