Skip to main content

boundgraph_slice/
log.rs

1//! Condensing command output: keep what failed, drop what scrolled past.
2//!
3//! # Why this slicer earns its place first
4//!
5//! Log output is the dominant term in the quadratic cost formula. A ten
6//! thousand line build log costs more tokens than every prompt in a flow put
7//! together, and almost all of it is noise — the same "Compiling" line four
8//! hundred times, with the three lines anyone cares about somewhere in the
9//! middle.
10//!
11//! # What is kept, in what order
12//!
13//! The cap is spent in priority order, so that a small cap still produces
14//! something useful rather than the first `n` lines:
15//!
16//! 1. **The command outcome.** Exit status and timing, always, regardless of
17//!    the cap. They cost a handful of tokens and they are the first thing
18//!    anyone asks about a failed command. A slicer that dropped them to fit two
19//!    more log lines would have optimised the wrong way.
20//! 2. **A floor of head and tail lines.** Failures cluster at both ends: what
21//!    was invoked is at the top, the summary is at the bottom. A few of each are
22//!    kept before anything competes for the budget.
23//! 3. **Error lines**, in the order they appeared.
24//! 4. **Warning lines.**
25//! 5. **More head and tail**, then anything else that fits.
26//!
27//! # Repetition is collapsed, not dropped
28//!
29//! Consecutive identical lines become one line with a count. *Consecutive*
30//! rather than global, because collapsing repeats that were far apart would
31//! reorder the log and quietly imply the two occurrences were adjacent — and
32//! the gap between two identical errors is often the whole story.
33//!
34//! # What the model is told it did not see
35//!
36//! Every count of what was dropped is reported. A model reading a condensate
37//! with no elision marker will reason as though it has the whole log; one that
38//! can see `9821 lines dropped` knows to ask for the original, which is in the
39//! content store under [`Condensate::raw`](boundgraph_core::Condensate::raw).
40
41use 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
48/// The schema a log condensate declares.
49pub const LOG_SCHEMA: &str = "boundgraph.log.v1";
50
51/// How important a line looked.
52///
53/// Judged by convention rather than by parsing: there is no grammar for "a log
54/// line", and every toolchain writes its own. The conventions matched below are
55/// the ones that are near-universal, and a line that matches none of them is
56/// simply informational — which is the safe default, since being wrong here
57/// costs priority rather than correctness.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum Level {
61    /// Something failed.
62    Error,
63    /// Something is suspect.
64    Warning,
65    /// Everything else.
66    Info,
67}
68
69impl Level {
70    /// Classify a line by the conventions toolchains actually use.
71    ///
72    /// Case-insensitive, and anchored to word-ish positions so that a path
73    /// containing `error` — `src/error.rs`, which appears in almost every Rust
74    /// build log — is not mistaken for an error line.
75    pub fn of(line: &str) -> Self {
76        let lower = line.to_ascii_lowercase();
77        let trimmed = lower.trim_start();
78
79        // Leading markers: `error:`, `error[E0308]:`, `E: `, `FAILED`, and the
80        // shapes test harnesses and compilers put at the start of a line.
81        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        // Embedded markers, which is how test runners report: `test x ... FAILED`,
104        // `1 failed`, `assertion failed`. Checked after prefixes so a line that
105        // is both is reported at its stronger level.
106        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    /// Whether this level is worth keeping ahead of ordinary output.
125    pub fn is_notable(self) -> bool {
126        matches!(self, Self::Error | Self::Warning)
127    }
128}
129
130/// Whether `lower` reports a *count* of `noun`, as in `2 errors` or `1 warning`.
131///
132/// Matching the bare word would be far too eager: `src/error.rs` and
133/// `writing errors.log` appear in ordinary build output constantly, and
134/// treating them as failures would spend the cap on compiler progress. The
135/// count form is the one that actually reports a result, so the digit before it
136/// is what makes the match safe.
137fn counts(lower: &str, noun: &str) -> bool {
138    lower.match_indices(noun).any(|(at, _)| {
139        // Reject `errors.log`: a count is followed by a word boundary.
140        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        // And it is preceded by a number: "2 errors", not "of errors".
149        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/// One line of output, after consecutive repeats were collapsed.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct LogLine {
160    /// The line number in the original output, counting from one.
161    ///
162    /// Kept so a reader can tell that lines 12 and 4_310 are not adjacent, and
163    /// so a gap in the numbering *is* the elision marker.
164    pub n: usize,
165    /// The text, with trailing whitespace removed.
166    pub text: String,
167    /// How important it looked. Omitted from the payload when informational,
168    /// since that is the common case and repeating it would cost tokens.
169    #[serde(skip_serializing_if = "is_info")]
170    pub level: Level,
171    /// How many consecutive identical lines this stands for. Omitted when one.
172    #[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/// What did not make it into the summary.
185#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
186pub struct Dropped {
187    /// Lines not shown, after collapsing.
188    pub lines: usize,
189    /// Error lines not shown. Non-zero here is a strong hint to fetch the raw.
190    pub errors: usize,
191    /// Warning lines not shown.
192    pub warnings: usize,
193    /// Lines removed by collapsing consecutive repeats.
194    pub repeated: usize,
195}
196
197impl Dropped {
198    /// Whether anything was lost.
199    pub fn any(&self) -> bool {
200        self.lines > 0 || self.repeated > 0
201    }
202}
203
204/// Condenses command and build output.
205///
206/// # Example
207///
208/// ```
209/// use boundgraph_slice::{LogSlicer, MemoryStore, Raw, Slicer};
210///
211/// let noisy = (0..5_000)
212///     .map(|i| format!("Compiling crate_{i}"))
213///     .collect::<Vec<_>>()
214///     .join("\n")
215///     + "\nerror[E0308]: mismatched types\n";
216///
217/// let condensate = tokio::runtime::Builder::new_current_thread()
218///     .build()?
219///     .block_on(LogSlicer::new().condense(&Raw::new(noisy), 300, &MemoryStore::new()))?;
220///
221/// // The error survived; the five thousand Compiling lines did not.
222/// assert!(condensate.lossy);
223/// # Ok::<(), Box<dyn std::error::Error>>(())
224/// ```
225#[derive(Debug, Clone, Copy)]
226pub struct LogSlicer {
227    /// Lines from the start kept before anything competes for the budget.
228    head: usize,
229    /// Lines from the end kept on the same basis.
230    tail: usize,
231}
232
233impl Default for LogSlicer {
234    fn default() -> Self {
235        Self::new()
236    }
237}
238
239impl LogSlicer {
240    /// A slicer keeping three lines from each end as a floor.
241    pub fn new() -> Self {
242        Self { head: 3, tail: 3 }
243    }
244
245    /// Keep this many lines from each end before prioritising by level.
246    #[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    /// Collapse consecutive identical lines, numbering by the original.
254    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                // Only *consecutive* repeats collapse — see the module docs.
262                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    /// The order the cap is spent in. Returns indices into `lines`.
278    ///
279    /// Head and tail come before errors deliberately: a floor of structural
280    /// context is what stops a tiny cap from producing a bare error with no
281    /// indication of what was even being built.
282    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        // 1. The floor of head and tail.
295        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        // 2. Errors, then warnings, each in the order they appeared.
302        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        // 3. Everything else, in order.
310        for i in 0..count {
311            take(i, &mut order, &mut seen);
312        }
313        order
314    }
315
316    /// Assemble the payload for a chosen set of lines.
317    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        // Always present, whatever the cap.
344        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        // The envelope — command summary, totals, dropped counts — is retained
371        // whatever the cap, so if it alone does not fit there is no slice to
372        // make. Reporting the *measured* floor rather than a constant matters
373        // because the floor depends on the input: a long command line costs
374        // more than a short one, and telling an author "at least 64" when their
375        // invocation needs 90 would send them round again.
376        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        // Grow the kept set in priority order while it still fits. Measuring
386        // the real rendered payload each time rather than estimating per line:
387        // the envelope is not free and the alternative is a guess that has to
388        // be corrected afterwards anyway.
389        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                // This line does not fit. A later one in priority order may be
400                // shorter, so keep going rather than stopping here.
401                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    /// A realistic build log: a lot of noise around a little signal.
446    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    // ── priorities ──────────────────────────────────────────────────────────
462
463    /// The criterion: errors and warnings beat informational lines.
464    #[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    /// The one warning survives a cap that keeps only a handful of the thousand
481    /// informational lines it is buried among. Ordinary lines do get whatever
482    /// budget is left over — that is the point of filling — so the property
483    /// worth asserting is that the warning is never the thing that loses.
484    #[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    /// The criterion: the first and last portions are retained.
497    #[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    /// The line numbers are the elision marker: a gap says lines are missing.
531    #[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    // ── repetition ──────────────────────────────────────────────────────────
548
549    /// The criterion: repeated identical lines are collapsed with a count.
550    #[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    /// Collapsing repeats that were far apart would imply they were adjacent,
567    /// and the gap between two identical errors is often the whole story.
568    #[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    // ── the command outcome ─────────────────────────────────────────────────
589
590    /// The criterion: exit status and timing survive regardless of the cap.
591    #[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        // 60 is the measured floor for this invocation — below it there is no
600        // slice to make, and the slicer says so rather than dropping the exit
601        // status to fit.
602        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    /// The envelope is retained whatever the cap, so a cap that cannot hold it
616    /// is a flow-authoring error rather than an excuse to drop the exit status.
617    /// The floor is measured from the input, because a long command line costs
618    /// more than a short one.
619    #[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        // And that floor is honest: at it, the slice succeeds.
635        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    // ── what was dropped ────────────────────────────────────────────────────
646
647    /// The criterion: dropped counts are reported so the model knows.
648    #[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    /// A model told nothing was dropped will reason as though it has the whole
658    /// log, so the flag has to be honest in both directions.
659    #[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    // ── classification ──────────────────────────────────────────────────────
684
685    #[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    /// `src/error.rs` appears in almost every Rust build log, and treating it
713    /// as an error line would fill the cap with compiler progress output.
714    #[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    // ── the contract ────────────────────────────────────────────────────────
734
735    /// The guarantee the trait enforces, exercised against a real log.
736    #[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}