use crate::ninja_gen::ninja_gen_command_list::COMMAND_LIST_FAILURE_PREFIX;
use std::io::{self, Write};
use super::streaming::{ForwardStats, forward_child_output};
pub(super) fn forward_stderr_with_attribution<R, W>(
reader: R,
output: W,
) -> (ForwardStats, Option<CommandListFailure>)
where
R: io::Read,
W: Write,
{
let mut attribution_writer = FailureAttributionWriter::new(output);
let stats = forward_child_output(reader, &mut attribution_writer, "stderr");
(stats, attribution_writer.into_failure())
}
pub(super) struct NinjaFailureOutputTail<W> {
inner: W,
tail: Vec<u8>,
}
impl<W> NinjaFailureOutputTail<W> {
const MAX_TAIL_BYTES: usize = 512;
pub(super) fn new(inner: W) -> Self {
Self {
inner,
tail: Vec::with_capacity(Self::MAX_TAIL_BYTES),
}
}
pub(super) fn into_failure(self) -> Option<CommandListFailure> {
self.tail
.split(|byte| *byte == b'\n')
.filter_map(parse_marker)
.next_back()
}
#[cfg(test)]
const fn tail_len(&self) -> usize {
self.tail.len()
}
fn retain_tail(&mut self, bytes: &[u8]) {
if bytes.len() >= Self::MAX_TAIL_BYTES {
self.tail.clear();
let suffix = bytes
.get(bytes.len().saturating_sub(Self::MAX_TAIL_BYTES)..)
.unwrap_or_default();
self.tail.extend_from_slice(suffix);
return;
}
let retained = self.tail.len().saturating_add(bytes.len());
if retained > Self::MAX_TAIL_BYTES {
self.tail.drain(..retained - Self::MAX_TAIL_BYTES);
}
self.tail.extend_from_slice(bytes);
}
}
impl<W: Write> Write for NinjaFailureOutputTail<W> {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
let count = self.inner.write(bytes)?;
let Some(written) = bytes.get(..count) else {
return Err(io::Error::other("writer reported an invalid byte count"));
};
self.retain_tail(written);
Ok(count)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
pub(super) struct FailureAttributionWriter<W> {
inner: W,
pending: Vec<u8>,
failure: Option<CommandListFailure>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct CommandListFailure {
action_identity: String,
entry_index: usize,
}
impl CommandListFailure {
pub(super) fn action_identity(&self) -> &str {
&self.action_identity
}
pub(super) const fn entry_index(&self) -> usize {
self.entry_index
}
}
impl std::fmt::Display for CommandListFailure {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"{COMMAND_LIST_FAILURE_PREFIX}{}, entry {}",
self.action_identity, self.entry_index
)
}
}
impl<W> FailureAttributionWriter<W> {
const MAX_LINE_BYTES: usize = 128;
pub(super) const fn new(inner: W) -> Self {
Self {
inner,
pending: Vec::new(),
failure: None,
}
}
pub(super) fn into_failure(self) -> Option<CommandListFailure> {
self.failure
}
fn observe(&mut self, bytes: &[u8]) {
for byte in bytes {
if *byte == b'\n' {
self.record_line();
self.pending.clear();
} else if self.pending.len() < Self::MAX_LINE_BYTES {
self.pending.push(*byte);
}
}
}
fn record_line(&mut self) {
if let Some(failure) = parse_marker(&self.pending) {
self.failure = Some(failure);
}
}
}
fn parse_marker(bytes: &[u8]) -> Option<CommandListFailure> {
let line = std::str::from_utf8(bytes).ok()?;
let (action, entry_text) = line
.strip_prefix(COMMAND_LIST_FAILURE_PREFIX)?
.split_once(", entry ")?;
let entry = entry_text.parse::<usize>().ok()?;
if is_action_identity(action) && entry > 0 {
Some(CommandListFailure {
action_identity: action.to_owned(),
entry_index: entry,
})
} else {
None
}
}
fn is_action_identity(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
impl<W: Write> Write for FailureAttributionWriter<W> {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
let count = self.inner.write(bytes)?;
let Some(written) = bytes.get(..count) else {
return Err(io::Error::other("writer reported an invalid byte count"));
};
self.observe(written);
Ok(count)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
#[cfg(test)]
mod tests {
use super::*;
const ACTION_IDENTITY: &str =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[test]
fn extracts_a_valid_marker_split_across_writes() {
let mut writer = FailureAttributionWriter::new(Vec::new());
writer
.write_all(b"ninja output\nnetsuke command-list fail")
.expect("first chunk should write");
writer
.write_all(format!("ure: action {ACTION_IDENTITY}, entry 3\n").as_bytes())
.expect("second chunk should write");
let failure = writer.into_failure().map(|failure| failure.to_string());
assert_eq!(
failure.as_deref(),
Some(
"netsuke command-list failure: action 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef, entry 3"
)
);
}
#[test]
fn retains_a_marker_when_later_output_has_no_marker() {
let mut writer = FailureAttributionWriter::new(Vec::new());
writer
.write_all(
format!("netsuke command-list failure: action {ACTION_IDENTITY}, entry 3\n")
.as_bytes(),
)
.expect("failure marker should write");
writer
.write_all(b"ordinary command output\n")
.expect("ordinary output should write");
assert_eq!(
writer.into_failure().map(|failure| failure.to_string()),
Some(format!(
"netsuke command-list failure: action {ACTION_IDENTITY}, entry 3"
))
);
}
#[test]
fn ignores_malformed_or_unbounded_markers() {
let mut writer = FailureAttributionWriter::new(Vec::new());
writer
.write_all(b"netsuke command-list failure: action zero, entry 2\n")
.expect("malformed marker should write");
writer
.write_all(&[b'x'; FailureAttributionWriter::<Vec<u8>>::MAX_LINE_BYTES + 1])
.expect("unbounded marker should write");
writer
.write_all(
format!("netsuke command-list failure: action {ACTION_IDENTITY}, entry 3\n")
.as_bytes(),
)
.expect("valid marker after unbounded content should write");
assert!(writer.into_failure().is_none());
}
#[test]
fn retains_a_bounded_ninja_output_tail_for_failure_attribution() {
let mut writer = NinjaFailureOutputTail::new(Vec::new());
writer
.write_all(&vec![b'x'; 256 * 1024])
.expect("large command output should forward");
writer
.write_all(
format!("\nnetsuke command-list failure: action {ACTION_IDENTITY}, entry 3\n")
.as_bytes(),
)
.expect("Ninja failure marker should forward");
assert!(
writer.tail_len() <= NinjaFailureOutputTail::<Vec<u8>>::MAX_TAIL_BYTES,
"Ninja failure attribution must retain a fixed-size output tail"
);
assert_eq!(
writer.into_failure().map(|failure| failure.to_string()),
Some(
"netsuke command-list failure: action 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef, entry 3"
.into()
)
);
}
}