1use async_trait::async_trait;
42use boundgraph_core::{SchemaId, SlicerId};
43use serde::{Deserialize, Serialize};
44use serde_json::json;
45
46use crate::slicer::{Raw, Slice, SliceError, Slicer};
47
48pub const LOG_SCHEMA: &str = "boundgraph.log.v1";
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum Level {
61 Error,
63 Warning,
65 Info,
67}
68
69impl Level {
70 pub fn of(line: &str) -> Self {
76 let lower = line.to_ascii_lowercase();
77 let trimmed = lower.trim_start();
78
79 const ERROR_PREFIX: &[&str] = &[
82 "error",
83 "fatal",
84 "panic",
85 "failed",
86 "failure",
87 "exception",
88 "traceback",
89 "e:",
90 "err:",
91 "[error]",
92 "[fatal]",
93 ];
94 const WARNING_PREFIX: &[&str] = &["warning", "warn", "w:", "[warn", "deprecated"];
95
96 if ERROR_PREFIX.iter().any(|m| trimmed.starts_with(m)) {
97 return Self::Error;
98 }
99 if WARNING_PREFIX.iter().any(|m| trimmed.starts_with(m)) {
100 return Self::Warning;
101 }
102
103 const ERROR_INFIX: &[&str] = &[
107 " failed",
108 "failed:",
109 " error:",
110 "assertion",
111 "segmentation fault",
112 ];
113 const WARNING_INFIX: &[&str] = &[" warning:", " deprecated"];
114
115 if ERROR_INFIX.iter().any(|m| lower.contains(m)) || counts(&lower, "error") {
116 return Self::Error;
117 }
118 if WARNING_INFIX.iter().any(|m| lower.contains(m)) || counts(&lower, "warning") {
119 return Self::Warning;
120 }
121 Self::Info
122 }
123
124 pub fn is_notable(self) -> bool {
126 matches!(self, Self::Error | Self::Warning)
127 }
128}
129
130fn counts(lower: &str, noun: &str) -> bool {
138 lower.match_indices(noun).any(|(at, _)| {
139 let after = lower[at + noun.len()..].chars().next();
141 if after.is_some_and(|c| c.is_alphanumeric() && c != 's') {
142 return false;
143 }
144 let tail = lower[at + noun.len()..].trim_start_matches('s');
145 if tail.starts_with('.') || tail.starts_with('/') || tail.starts_with('_') {
146 return false;
147 }
148 lower[..at]
150 .trim_end()
151 .rsplit(|c: char| c.is_whitespace())
152 .next()
153 .is_some_and(|word| !word.is_empty() && word.chars().all(|c| c.is_ascii_digit()))
154 })
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct LogLine {
160 pub n: usize,
165 pub text: String,
167 #[serde(skip_serializing_if = "is_info")]
170 pub level: Level,
171 #[serde(skip_serializing_if = "is_one")]
173 pub repeats: usize,
174}
175
176fn is_info(level: &Level) -> bool {
177 matches!(level, Level::Info)
178}
179
180fn is_one(n: &usize) -> bool {
181 *n == 1
182}
183
184#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
186pub struct Dropped {
187 pub lines: usize,
189 pub errors: usize,
191 pub warnings: usize,
193 pub repeated: usize,
195}
196
197impl Dropped {
198 pub fn any(&self) -> bool {
200 self.lines > 0 || self.repeated > 0
201 }
202}
203
204#[derive(Debug, Clone, Copy)]
226pub struct LogSlicer {
227 head: usize,
229 tail: usize,
231}
232
233impl Default for LogSlicer {
234 fn default() -> Self {
235 Self::new()
236 }
237}
238
239impl LogSlicer {
240 pub fn new() -> Self {
242 Self { head: 3, tail: 3 }
243 }
244
245 #[must_use]
247 pub fn anchored(mut self, head: usize, tail: usize) -> Self {
248 self.head = head;
249 self.tail = tail;
250 self
251 }
252
253 fn collapse(text: &str) -> (Vec<LogLine>, usize) {
255 let mut out: Vec<LogLine> = Vec::new();
256 let mut collapsed = 0;
257
258 for (index, raw_line) in text.lines().enumerate() {
259 let line = raw_line.trim_end();
260 match out.last_mut() {
261 Some(last) if last.text == line => {
263 last.repeats += 1;
264 collapsed += 1;
265 }
266 _ => out.push(LogLine {
267 n: index + 1,
268 text: line.to_string(),
269 level: Level::of(line),
270 repeats: 1,
271 }),
272 }
273 }
274 (out, collapsed)
275 }
276
277 fn priority(&self, lines: &[LogLine]) -> Vec<usize> {
283 let count = lines.len();
284 let mut order: Vec<usize> = Vec::with_capacity(count);
285 let mut seen = vec![false; count];
286
287 let take = |index: usize, order: &mut Vec<usize>, seen: &mut Vec<bool>| {
288 if index < count && !seen[index] {
289 seen[index] = true;
290 order.push(index);
291 }
292 };
293
294 for i in 0..self.head.min(count) {
296 take(i, &mut order, &mut seen);
297 }
298 for i in count.saturating_sub(self.tail)..count {
299 take(i, &mut order, &mut seen);
300 }
301 for level in [Level::Error, Level::Warning] {
303 for (i, line) in lines.iter().enumerate() {
304 if line.level == level {
305 take(i, &mut order, &mut seen);
306 }
307 }
308 }
309 for i in 0..count {
311 take(i, &mut order, &mut seen);
312 }
313 order
314 }
315
316 fn payload(
318 kept: &[LogLine],
319 total: usize,
320 collapsed: usize,
321 all: &[LogLine],
322 command: Option<&crate::slicer::CommandOutcome>,
323 ) -> serde_json::Value {
324 let shown: std::collections::BTreeSet<usize> = kept.iter().map(|l| l.n).collect();
325 let dropped = Dropped {
326 lines: all.len() - kept.len(),
327 errors: all
328 .iter()
329 .filter(|l| l.level == Level::Error && !shown.contains(&l.n))
330 .count(),
331 warnings: all
332 .iter()
333 .filter(|l| l.level == Level::Warning && !shown.contains(&l.n))
334 .count(),
335 repeated: collapsed,
336 };
337
338 let mut payload = json!({
339 "lines": kept,
340 "total_lines": total,
341 });
342
343 if let Some(outcome) = command {
345 payload["command"] = json!(outcome.summary());
346 payload["exit"] = json!(outcome.exit);
347 }
348 if dropped.any() {
349 payload["dropped"] = json!(dropped);
350 }
351 payload
352 }
353}
354
355#[async_trait]
356impl Slicer for LogSlicer {
357 fn id(&self) -> SlicerId {
358 SlicerId::new("log", 1)
359 }
360
361 async fn slice(&self, raw: &Raw, cap: usize) -> Result<Slice, SliceError> {
362 let text = raw.text();
363 let (lines, collapsed) = Self::collapse(&text);
364 let total = text.lines().count();
365 let schema = SchemaId::new(LOG_SCHEMA, 1);
366
367 let order = self.priority(&lines);
368 let counter = self.counter();
369
370 let envelope = Self::payload(&[], total, collapsed, &lines, raw.command.as_ref());
377 let floor = counter.count(&serde_json::to_string(&envelope).unwrap_or_default());
378 if floor > cap {
379 return Err(SliceError::CapTooSmall {
380 cap,
381 minimum: floor,
382 });
383 }
384
385 let mut chosen: Vec<usize> = Vec::new();
390 for &index in &order {
391 let mut candidate = chosen.clone();
392 candidate.push(index);
393 candidate.sort_unstable();
394
395 let kept: Vec<LogLine> = candidate.iter().map(|&i| lines[i].clone()).collect();
396 let payload = Self::payload(&kept, total, collapsed, &lines, raw.command.as_ref());
397
398 if counter.count(&serde_json::to_string(&payload).unwrap_or_default()) > cap {
399 continue;
402 }
403 chosen = candidate;
404 }
405
406 chosen.sort_unstable();
407 let kept: Vec<LogLine> = chosen.iter().map(|&i| lines[i].clone()).collect();
408 let lossy = kept.len() < lines.len() || collapsed > 0;
409 let payload = Self::payload(&kept, total, collapsed, &lines, raw.command.as_ref());
410
411 Ok(Slice {
412 schema,
413 payload,
414 lossy,
415 })
416 }
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 use crate::slicer::{CommandOutcome, MemoryStore};
423 use std::time::Duration;
424
425 fn raw_of(text: &str) -> Raw {
426 Raw::new(text.as_bytes().to_vec())
427 }
428
429 async fn slice(text: &str, cap: usize) -> Slice {
430 LogSlicer::new()
431 .slice(&raw_of(text), cap)
432 .await
433 .expect("the log slicer handles anything")
434 }
435
436 fn texts(slice: &Slice) -> Vec<String> {
437 slice.payload["lines"]
438 .as_array()
439 .unwrap()
440 .iter()
441 .map(|l| l["text"].as_str().unwrap().to_string())
442 .collect()
443 }
444
445 fn build_log() -> String {
447 let mut out = String::from("$ cargo build --release\n");
448 for i in 0..500 {
449 out.push_str(&format!(" Compiling crate_{i} v0.1.0\n"));
450 }
451 out.push_str("warning: unused variable `x`\n");
452 for i in 500..1_000 {
453 out.push_str(&format!(" Compiling crate_{i} v0.1.0\n"));
454 }
455 out.push_str("error[E0308]: mismatched types\n");
456 out.push_str(" --> src/main.rs:42:9\n");
457 out.push_str("error: could not compile `app` due to 1 previous error\n");
458 out
459 }
460
461 #[tokio::test]
465 async fn errors_survive_a_cap_that_drops_almost_everything() {
466 let slice = slice(&build_log(), 300).await;
467 let kept = texts(&slice);
468
469 assert!(
470 kept.iter().any(|l| l.contains("E0308")),
471 "the error must survive: {kept:?}"
472 );
473 assert!(
474 kept.iter().any(|l| l.contains("could not compile")),
475 "and so must the second one"
476 );
477 assert!(slice.lossy);
478 }
479
480 #[tokio::test]
485 async fn warnings_outrank_informational_lines() {
486 for cap in [200, 300, 400, 800] {
487 let slice = slice(&build_log(), cap).await;
488 let kept = texts(&slice);
489 assert!(
490 kept.iter().any(|l| l.contains("warning: unused variable")),
491 "the warning should survive cap {cap}: {kept:?}"
492 );
493 }
494 }
495
496 #[tokio::test]
498 async fn the_head_and_tail_are_anchored() {
499 let slice = slice(&build_log(), 500).await;
500 let kept = texts(&slice);
501
502 assert!(
503 kept[0].contains("cargo build --release"),
504 "the invocation is the head: {:?}",
505 kept[0]
506 );
507 assert!(
508 kept.last().unwrap().contains("could not compile"),
509 "the summary is the tail: {:?}",
510 kept.last()
511 );
512 }
513
514 #[tokio::test]
515 async fn kept_lines_stay_in_the_order_they_appeared() {
516 let slice = slice(&build_log(), 600).await;
517 let numbers: Vec<u64> = slice.payload["lines"]
518 .as_array()
519 .unwrap()
520 .iter()
521 .map(|l| l["n"].as_u64().unwrap())
522 .collect();
523
524 assert!(
525 numbers.windows(2).all(|w| w[0] < w[1]),
526 "a reordered log is a misleading log: {numbers:?}"
527 );
528 }
529
530 #[tokio::test]
532 async fn line_numbers_reference_the_original_so_gaps_are_visible() {
533 let slice = slice(&build_log(), 300).await;
534 let numbers: Vec<u64> = slice.payload["lines"]
535 .as_array()
536 .unwrap()
537 .iter()
538 .map(|l| l["n"].as_u64().unwrap())
539 .collect();
540
541 assert!(
542 numbers.windows(2).any(|w| w[1] - w[0] > 1),
543 "a heavily sliced log should show gaps"
544 );
545 }
546
547 #[tokio::test]
551 async fn consecutive_repeats_collapse_to_one_line_with_a_count() {
552 let text = "start\nsame\nsame\nsame\nsame\nend";
553 let slice = slice(text, 200).await;
554
555 let same = slice.payload["lines"]
556 .as_array()
557 .unwrap()
558 .iter()
559 .find(|l| l["text"] == "same")
560 .expect("the repeated line");
561 assert_eq!(same["repeats"], 4);
562 assert_eq!(slice.payload["dropped"]["repeated"], 3);
563 assert!(slice.lossy, "collapsing is lossy even when nothing is cut");
564 }
565
566 #[tokio::test]
569 async fn repeats_that_are_not_adjacent_are_not_collapsed() {
570 let text = "same\nother\nsame";
571 let slice = slice(text, 200).await;
572
573 let same: Vec<_> = slice.payload["lines"]
574 .as_array()
575 .unwrap()
576 .iter()
577 .filter(|l| l["text"] == "same")
578 .collect();
579 assert_eq!(same.len(), 2, "two separate occurrences, kept separate");
580 }
581
582 #[tokio::test]
583 async fn a_line_that_appears_once_carries_no_count() {
584 let slice = slice("only once\n", 200).await;
585 assert!(slice.payload["lines"][0].get("repeats").is_none());
586 }
587
588 #[tokio::test]
592 async fn the_exit_status_and_timing_survive_the_smallest_cap() {
593 let raw = raw_of(&build_log()).from_command(CommandOutcome::new(
594 "cargo build --release",
595 101,
596 Duration::from_millis(4_200),
597 ));
598
599 for cap in [60, 64, 128, 1_000] {
603 let slice = LogSlicer::new().slice(&raw, cap).await.unwrap();
604 assert_eq!(slice.payload["exit"], 101, "at cap {cap}");
605 assert!(
606 slice.payload["command"]
607 .as_str()
608 .unwrap()
609 .contains("4200ms"),
610 "timing must survive cap {cap}"
611 );
612 }
613 }
614
615 #[tokio::test]
620 async fn a_cap_below_the_envelope_reports_the_measured_floor() {
621 let raw = raw_of("some output\n").from_command(CommandOutcome::new(
622 "cargo build --release --workspace --all-features",
623 101,
624 Duration::from_millis(4_200),
625 ));
626
627 let err = LogSlicer::new().slice(&raw, 20).await.unwrap_err();
628 let SliceError::CapTooSmall { cap, minimum } = err else {
629 panic!("expected CapTooSmall, got {err:?}");
630 };
631 assert_eq!(cap, 20);
632 assert!(minimum > 20, "the floor should exceed the cap it refused");
633
634 assert!(LogSlicer::new().slice(&raw, minimum).await.is_ok());
636 }
637
638 #[tokio::test]
639 async fn output_with_no_command_reports_no_exit_status() {
640 let slice = slice("just some text\n", 200).await;
641 assert!(slice.payload.get("exit").is_none());
642 assert!(slice.payload.get("command").is_none());
643 }
644
645 #[tokio::test]
649 async fn dropped_counts_are_reported() {
650 let slice = slice(&build_log(), 300).await;
651 let dropped = &slice.payload["dropped"];
652
653 assert!(dropped["lines"].as_u64().unwrap() > 900);
654 assert_eq!(slice.payload["total_lines"], 1_005);
655 }
656
657 #[tokio::test]
660 async fn a_log_that_fits_entirely_is_not_marked_lossy() {
661 let slice = slice("line one\nline two\nline three\n", 500).await;
662
663 assert!(!slice.lossy);
664 assert!(slice.payload.get("dropped").is_none());
665 assert_eq!(texts(&slice).len(), 3);
666 }
667
668 #[tokio::test]
669 async fn dropped_errors_are_counted_separately_as_a_hint_to_fetch_the_raw() {
670 let mut text = String::new();
671 for i in 0..200 {
672 text.push_str(&format!("error: failure number {i}\n"));
673 }
674 let slice = slice(&text, 200).await;
675
676 let dropped = slice.payload["dropped"]["errors"].as_u64().unwrap();
677 assert!(
678 dropped > 0,
679 "not every error fits, and that is worth saying"
680 );
681 }
682
683 #[test]
686 fn common_error_conventions_are_recognised() {
687 for line in [
688 "error[E0308]: mismatched types",
689 "ERROR: connection refused",
690 " fatal: not a git repository",
691 "Traceback (most recent call last):",
692 "panic: runtime error",
693 "test tests::it_works ... FAILED",
694 "assertion failed: left == right",
695 "E: Unable to locate package",
696 ] {
697 assert_eq!(Level::of(line), Level::Error, "{line}");
698 }
699 }
700
701 #[test]
702 fn common_warning_conventions_are_recognised() {
703 for line in [
704 "warning: unused variable `x`",
705 "WARN stale lockfile",
706 " deprecated: use bar instead",
707 ] {
708 assert_eq!(Level::of(line), Level::Warning, "{line}");
709 }
710 }
711
712 #[test]
715 fn a_path_containing_error_is_not_an_error_line() {
716 for line in [
717 " Compiling boundgraph-core v0.1.0 (crates/boundgraph-core/src/error.rs)",
718 " Doc-tests error handling",
719 "info: writing errors.log",
720 ] {
721 assert_eq!(Level::of(line), Level::Info, "{line}");
722 }
723 }
724
725 #[test]
726 fn anything_unrecognised_is_informational() {
727 assert_eq!(Level::of(""), Level::Info);
728 assert_eq!(Level::of(" Compiling serde v1.0"), Level::Info);
729 assert!(Level::Error.is_notable());
730 assert!(!Level::Info.is_notable());
731 }
732
733 #[tokio::test]
737 async fn the_output_never_exceeds_the_cap() {
738 let store = MemoryStore::new();
739 let raw = raw_of(&build_log());
740
741 for cap in [64, 128, 256, 512, 1_024, 4_096] {
742 let condensate = LogSlicer::new()
743 .condense(&raw, cap, &store)
744 .await
745 .unwrap_or_else(|e| panic!("cap {cap} should be satisfiable: {e}"));
746 assert_eq!(condensate.cap, cap);
747 }
748 }
749
750 #[tokio::test]
751 async fn slicing_is_deterministic() {
752 let raw = raw_of(&build_log());
753 let first = LogSlicer::new().slice(&raw, 400).await.unwrap();
754 for _ in 0..8 {
755 assert_eq!(LogSlicer::new().slice(&raw, 400).await.unwrap(), first);
756 }
757 }
758
759 #[tokio::test]
760 async fn empty_output_is_handled_without_ceremony() {
761 let slice = slice("", 200).await;
762 assert!(!slice.lossy);
763 assert_eq!(slice.payload["total_lines"], 0);
764 assert!(texts(&slice).is_empty());
765 }
766
767 #[tokio::test]
768 async fn output_that_is_not_utf8_still_slices() {
769 let raw = Raw::new(vec![b'o', b'k', 0xff, b'\n', b'e', b'r', b'r', b'\n']);
770 let slice = LogSlicer::new().slice(&raw, 200).await.unwrap();
771 assert_eq!(slice.payload["total_lines"], 2);
772 }
773
774 #[tokio::test]
775 async fn the_anchor_is_configurable() {
776 let text: String = (1..=100).map(|i| format!("line {i}\n")).collect();
777 let slice = LogSlicer::new()
778 .anchored(10, 0)
779 .slice(&raw_of(&text), 120)
780 .await
781 .unwrap();
782
783 let kept = texts(&slice);
784 assert!(kept[0].contains("line 1"));
785 assert!(
786 kept.iter().take(5).any(|l| l.contains("line 5")),
787 "a wider head anchor keeps more of the start: {kept:?}"
788 );
789 }
790}