use async_trait::async_trait;
use boundgraph_core::{SchemaId, SlicerId};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::slicer::{Raw, Slice, SliceError, Slicer};
pub const LOG_SCHEMA: &str = "boundgraph.log.v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Level {
Error,
Warning,
Info,
}
impl Level {
pub fn of(line: &str) -> Self {
let lower = line.to_ascii_lowercase();
let trimmed = lower.trim_start();
const ERROR_PREFIX: &[&str] = &[
"error",
"fatal",
"panic",
"failed",
"failure",
"exception",
"traceback",
"e:",
"err:",
"[error]",
"[fatal]",
];
const WARNING_PREFIX: &[&str] = &["warning", "warn", "w:", "[warn", "deprecated"];
if ERROR_PREFIX.iter().any(|m| trimmed.starts_with(m)) {
return Self::Error;
}
if WARNING_PREFIX.iter().any(|m| trimmed.starts_with(m)) {
return Self::Warning;
}
const ERROR_INFIX: &[&str] = &[
" failed",
"failed:",
" error:",
"assertion",
"segmentation fault",
];
const WARNING_INFIX: &[&str] = &[" warning:", " deprecated"];
if ERROR_INFIX.iter().any(|m| lower.contains(m)) || counts(&lower, "error") {
return Self::Error;
}
if WARNING_INFIX.iter().any(|m| lower.contains(m)) || counts(&lower, "warning") {
return Self::Warning;
}
Self::Info
}
pub fn is_notable(self) -> bool {
matches!(self, Self::Error | Self::Warning)
}
}
fn counts(lower: &str, noun: &str) -> bool {
lower.match_indices(noun).any(|(at, _)| {
let after = lower[at + noun.len()..].chars().next();
if after.is_some_and(|c| c.is_alphanumeric() && c != 's') {
return false;
}
let tail = lower[at + noun.len()..].trim_start_matches('s');
if tail.starts_with('.') || tail.starts_with('/') || tail.starts_with('_') {
return false;
}
lower[..at]
.trim_end()
.rsplit(|c: char| c.is_whitespace())
.next()
.is_some_and(|word| !word.is_empty() && word.chars().all(|c| c.is_ascii_digit()))
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogLine {
pub n: usize,
pub text: String,
#[serde(skip_serializing_if = "is_info")]
pub level: Level,
#[serde(skip_serializing_if = "is_one")]
pub repeats: usize,
}
fn is_info(level: &Level) -> bool {
matches!(level, Level::Info)
}
fn is_one(n: &usize) -> bool {
*n == 1
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Dropped {
pub lines: usize,
pub errors: usize,
pub warnings: usize,
pub repeated: usize,
}
impl Dropped {
pub fn any(&self) -> bool {
self.lines > 0 || self.repeated > 0
}
}
#[derive(Debug, Clone, Copy)]
pub struct LogSlicer {
head: usize,
tail: usize,
}
impl Default for LogSlicer {
fn default() -> Self {
Self::new()
}
}
impl LogSlicer {
pub fn new() -> Self {
Self { head: 3, tail: 3 }
}
#[must_use]
pub fn anchored(mut self, head: usize, tail: usize) -> Self {
self.head = head;
self.tail = tail;
self
}
fn collapse(text: &str) -> (Vec<LogLine>, usize) {
let mut out: Vec<LogLine> = Vec::new();
let mut collapsed = 0;
for (index, raw_line) in text.lines().enumerate() {
let line = raw_line.trim_end();
match out.last_mut() {
Some(last) if last.text == line => {
last.repeats += 1;
collapsed += 1;
}
_ => out.push(LogLine {
n: index + 1,
text: line.to_string(),
level: Level::of(line),
repeats: 1,
}),
}
}
(out, collapsed)
}
fn priority(&self, lines: &[LogLine]) -> Vec<usize> {
let count = lines.len();
let mut order: Vec<usize> = Vec::with_capacity(count);
let mut seen = vec![false; count];
let take = |index: usize, order: &mut Vec<usize>, seen: &mut Vec<bool>| {
if index < count && !seen[index] {
seen[index] = true;
order.push(index);
}
};
for i in 0..self.head.min(count) {
take(i, &mut order, &mut seen);
}
for i in count.saturating_sub(self.tail)..count {
take(i, &mut order, &mut seen);
}
for level in [Level::Error, Level::Warning] {
for (i, line) in lines.iter().enumerate() {
if line.level == level {
take(i, &mut order, &mut seen);
}
}
}
for i in 0..count {
take(i, &mut order, &mut seen);
}
order
}
fn payload(
kept: &[LogLine],
total: usize,
collapsed: usize,
all: &[LogLine],
command: Option<&crate::slicer::CommandOutcome>,
) -> serde_json::Value {
let shown: std::collections::BTreeSet<usize> = kept.iter().map(|l| l.n).collect();
let dropped = Dropped {
lines: all.len() - kept.len(),
errors: all
.iter()
.filter(|l| l.level == Level::Error && !shown.contains(&l.n))
.count(),
warnings: all
.iter()
.filter(|l| l.level == Level::Warning && !shown.contains(&l.n))
.count(),
repeated: collapsed,
};
let mut payload = json!({
"lines": kept,
"total_lines": total,
});
if let Some(outcome) = command {
payload["command"] = json!(outcome.summary());
payload["exit"] = json!(outcome.exit);
}
if dropped.any() {
payload["dropped"] = json!(dropped);
}
payload
}
}
#[async_trait]
impl Slicer for LogSlicer {
fn id(&self) -> SlicerId {
SlicerId::new("log", 1)
}
async fn slice(&self, raw: &Raw, cap: usize) -> Result<Slice, SliceError> {
let text = raw.text();
let (lines, collapsed) = Self::collapse(&text);
let total = text.lines().count();
let schema = SchemaId::new(LOG_SCHEMA, 1);
let order = self.priority(&lines);
let counter = self.counter();
let envelope = Self::payload(&[], total, collapsed, &lines, raw.command.as_ref());
let floor = counter.count(&serde_json::to_string(&envelope).unwrap_or_default());
if floor > cap {
return Err(SliceError::CapTooSmall {
cap,
minimum: floor,
});
}
let mut chosen: Vec<usize> = Vec::new();
for &index in &order {
let mut candidate = chosen.clone();
candidate.push(index);
candidate.sort_unstable();
let kept: Vec<LogLine> = candidate.iter().map(|&i| lines[i].clone()).collect();
let payload = Self::payload(&kept, total, collapsed, &lines, raw.command.as_ref());
if counter.count(&serde_json::to_string(&payload).unwrap_or_default()) > cap {
continue;
}
chosen = candidate;
}
chosen.sort_unstable();
let kept: Vec<LogLine> = chosen.iter().map(|&i| lines[i].clone()).collect();
let lossy = kept.len() < lines.len() || collapsed > 0;
let payload = Self::payload(&kept, total, collapsed, &lines, raw.command.as_ref());
Ok(Slice {
schema,
payload,
lossy,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::slicer::{CommandOutcome, MemoryStore};
use std::time::Duration;
fn raw_of(text: &str) -> Raw {
Raw::new(text.as_bytes().to_vec())
}
async fn slice(text: &str, cap: usize) -> Slice {
LogSlicer::new()
.slice(&raw_of(text), cap)
.await
.expect("the log slicer handles anything")
}
fn texts(slice: &Slice) -> Vec<String> {
slice.payload["lines"]
.as_array()
.unwrap()
.iter()
.map(|l| l["text"].as_str().unwrap().to_string())
.collect()
}
fn build_log() -> String {
let mut out = String::from("$ cargo build --release\n");
for i in 0..500 {
out.push_str(&format!(" Compiling crate_{i} v0.1.0\n"));
}
out.push_str("warning: unused variable `x`\n");
for i in 500..1_000 {
out.push_str(&format!(" Compiling crate_{i} v0.1.0\n"));
}
out.push_str("error[E0308]: mismatched types\n");
out.push_str(" --> src/main.rs:42:9\n");
out.push_str("error: could not compile `app` due to 1 previous error\n");
out
}
#[tokio::test]
async fn errors_survive_a_cap_that_drops_almost_everything() {
let slice = slice(&build_log(), 300).await;
let kept = texts(&slice);
assert!(
kept.iter().any(|l| l.contains("E0308")),
"the error must survive: {kept:?}"
);
assert!(
kept.iter().any(|l| l.contains("could not compile")),
"and so must the second one"
);
assert!(slice.lossy);
}
#[tokio::test]
async fn warnings_outrank_informational_lines() {
for cap in [200, 300, 400, 800] {
let slice = slice(&build_log(), cap).await;
let kept = texts(&slice);
assert!(
kept.iter().any(|l| l.contains("warning: unused variable")),
"the warning should survive cap {cap}: {kept:?}"
);
}
}
#[tokio::test]
async fn the_head_and_tail_are_anchored() {
let slice = slice(&build_log(), 500).await;
let kept = texts(&slice);
assert!(
kept[0].contains("cargo build --release"),
"the invocation is the head: {:?}",
kept[0]
);
assert!(
kept.last().unwrap().contains("could not compile"),
"the summary is the tail: {:?}",
kept.last()
);
}
#[tokio::test]
async fn kept_lines_stay_in_the_order_they_appeared() {
let slice = slice(&build_log(), 600).await;
let numbers: Vec<u64> = slice.payload["lines"]
.as_array()
.unwrap()
.iter()
.map(|l| l["n"].as_u64().unwrap())
.collect();
assert!(
numbers.windows(2).all(|w| w[0] < w[1]),
"a reordered log is a misleading log: {numbers:?}"
);
}
#[tokio::test]
async fn line_numbers_reference_the_original_so_gaps_are_visible() {
let slice = slice(&build_log(), 300).await;
let numbers: Vec<u64> = slice.payload["lines"]
.as_array()
.unwrap()
.iter()
.map(|l| l["n"].as_u64().unwrap())
.collect();
assert!(
numbers.windows(2).any(|w| w[1] - w[0] > 1),
"a heavily sliced log should show gaps"
);
}
#[tokio::test]
async fn consecutive_repeats_collapse_to_one_line_with_a_count() {
let text = "start\nsame\nsame\nsame\nsame\nend";
let slice = slice(text, 200).await;
let same = slice.payload["lines"]
.as_array()
.unwrap()
.iter()
.find(|l| l["text"] == "same")
.expect("the repeated line");
assert_eq!(same["repeats"], 4);
assert_eq!(slice.payload["dropped"]["repeated"], 3);
assert!(slice.lossy, "collapsing is lossy even when nothing is cut");
}
#[tokio::test]
async fn repeats_that_are_not_adjacent_are_not_collapsed() {
let text = "same\nother\nsame";
let slice = slice(text, 200).await;
let same: Vec<_> = slice.payload["lines"]
.as_array()
.unwrap()
.iter()
.filter(|l| l["text"] == "same")
.collect();
assert_eq!(same.len(), 2, "two separate occurrences, kept separate");
}
#[tokio::test]
async fn a_line_that_appears_once_carries_no_count() {
let slice = slice("only once\n", 200).await;
assert!(slice.payload["lines"][0].get("repeats").is_none());
}
#[tokio::test]
async fn the_exit_status_and_timing_survive_the_smallest_cap() {
let raw = raw_of(&build_log()).from_command(CommandOutcome::new(
"cargo build --release",
101,
Duration::from_millis(4_200),
));
for cap in [60, 64, 128, 1_000] {
let slice = LogSlicer::new().slice(&raw, cap).await.unwrap();
assert_eq!(slice.payload["exit"], 101, "at cap {cap}");
assert!(
slice.payload["command"]
.as_str()
.unwrap()
.contains("4200ms"),
"timing must survive cap {cap}"
);
}
}
#[tokio::test]
async fn a_cap_below_the_envelope_reports_the_measured_floor() {
let raw = raw_of("some output\n").from_command(CommandOutcome::new(
"cargo build --release --workspace --all-features",
101,
Duration::from_millis(4_200),
));
let err = LogSlicer::new().slice(&raw, 20).await.unwrap_err();
let SliceError::CapTooSmall { cap, minimum } = err else {
panic!("expected CapTooSmall, got {err:?}");
};
assert_eq!(cap, 20);
assert!(minimum > 20, "the floor should exceed the cap it refused");
assert!(LogSlicer::new().slice(&raw, minimum).await.is_ok());
}
#[tokio::test]
async fn output_with_no_command_reports_no_exit_status() {
let slice = slice("just some text\n", 200).await;
assert!(slice.payload.get("exit").is_none());
assert!(slice.payload.get("command").is_none());
}
#[tokio::test]
async fn dropped_counts_are_reported() {
let slice = slice(&build_log(), 300).await;
let dropped = &slice.payload["dropped"];
assert!(dropped["lines"].as_u64().unwrap() > 900);
assert_eq!(slice.payload["total_lines"], 1_005);
}
#[tokio::test]
async fn a_log_that_fits_entirely_is_not_marked_lossy() {
let slice = slice("line one\nline two\nline three\n", 500).await;
assert!(!slice.lossy);
assert!(slice.payload.get("dropped").is_none());
assert_eq!(texts(&slice).len(), 3);
}
#[tokio::test]
async fn dropped_errors_are_counted_separately_as_a_hint_to_fetch_the_raw() {
let mut text = String::new();
for i in 0..200 {
text.push_str(&format!("error: failure number {i}\n"));
}
let slice = slice(&text, 200).await;
let dropped = slice.payload["dropped"]["errors"].as_u64().unwrap();
assert!(
dropped > 0,
"not every error fits, and that is worth saying"
);
}
#[test]
fn common_error_conventions_are_recognised() {
for line in [
"error[E0308]: mismatched types",
"ERROR: connection refused",
" fatal: not a git repository",
"Traceback (most recent call last):",
"panic: runtime error",
"test tests::it_works ... FAILED",
"assertion failed: left == right",
"E: Unable to locate package",
] {
assert_eq!(Level::of(line), Level::Error, "{line}");
}
}
#[test]
fn common_warning_conventions_are_recognised() {
for line in [
"warning: unused variable `x`",
"WARN stale lockfile",
" deprecated: use bar instead",
] {
assert_eq!(Level::of(line), Level::Warning, "{line}");
}
}
#[test]
fn a_path_containing_error_is_not_an_error_line() {
for line in [
" Compiling boundgraph-core v0.1.0 (crates/boundgraph-core/src/error.rs)",
" Doc-tests error handling",
"info: writing errors.log",
] {
assert_eq!(Level::of(line), Level::Info, "{line}");
}
}
#[test]
fn anything_unrecognised_is_informational() {
assert_eq!(Level::of(""), Level::Info);
assert_eq!(Level::of(" Compiling serde v1.0"), Level::Info);
assert!(Level::Error.is_notable());
assert!(!Level::Info.is_notable());
}
#[tokio::test]
async fn the_output_never_exceeds_the_cap() {
let store = MemoryStore::new();
let raw = raw_of(&build_log());
for cap in [64, 128, 256, 512, 1_024, 4_096] {
let condensate = LogSlicer::new()
.condense(&raw, cap, &store)
.await
.unwrap_or_else(|e| panic!("cap {cap} should be satisfiable: {e}"));
assert_eq!(condensate.cap, cap);
}
}
#[tokio::test]
async fn slicing_is_deterministic() {
let raw = raw_of(&build_log());
let first = LogSlicer::new().slice(&raw, 400).await.unwrap();
for _ in 0..8 {
assert_eq!(LogSlicer::new().slice(&raw, 400).await.unwrap(), first);
}
}
#[tokio::test]
async fn empty_output_is_handled_without_ceremony() {
let slice = slice("", 200).await;
assert!(!slice.lossy);
assert_eq!(slice.payload["total_lines"], 0);
assert!(texts(&slice).is_empty());
}
#[tokio::test]
async fn output_that_is_not_utf8_still_slices() {
let raw = Raw::new(vec![b'o', b'k', 0xff, b'\n', b'e', b'r', b'r', b'\n']);
let slice = LogSlicer::new().slice(&raw, 200).await.unwrap();
assert_eq!(slice.payload["total_lines"], 2);
}
#[tokio::test]
async fn the_anchor_is_configurable() {
let text: String = (1..=100).map(|i| format!("line {i}\n")).collect();
let slice = LogSlicer::new()
.anchored(10, 0)
.slice(&raw_of(&text), 120)
.await
.unwrap();
let kept = texts(&slice);
assert!(kept[0].contains("line 1"));
assert!(
kept.iter().take(5).any(|l| l.contains("line 5")),
"a wider head anchor keeps more of the start: {kept:?}"
);
}
}