Skip to main content

spar/
followups.rs

1//! The local follow-up queue, and working it.
2//!
3//! `.spar/followups.md` is what `followups = "local"` writes instead of filing
4//! on the tracker. Until now nothing read it back, so a review's out of scope
5//! findings accumulated in a file with no way to act on them. `spar followup`
6//! reads it, asks one agent whether each entry is still true of the code as it
7//! is now, files the survivors, and hands them to the same pipeline `spar run`
8//! uses, which means both agents still triage each one before anything is
9//! implemented.
10//!
11//! Most of the care here is in the parser, for one reason. An entry is written
12//! as `## <title>`, and `review::issue_report` writes that entry's own sections
13//! as `## Problem`, `## Reproduction`, `## Impact` and `## Expected behavior`,
14//! at the same heading level. A real file had 25 `##` lines and 5 entries, so a
15//! naive split would file twenty issues, four of them titled "Impact".
16
17use std::collections::BTreeSet;
18use std::ops::Range;
19use std::path::Path;
20use std::sync::LazyLock;
21
22use regex::Regex;
23
24use crate::agent::Agent;
25use crate::config::{Config, Followups};
26use crate::error::Result;
27use crate::model::{Finding, ScreenResponse, ScreenVerdict, Screened};
28use crate::repo::{Repo, FOLLOWUP_MARKER};
29use crate::{log, logwarn, schema, spar_err};
30
31static BLANK_RUN: LazyLock<Regex> =
32    LazyLock::new(|| Regex::new(r"\n{3,}").expect("blank run pattern"));
33
34// ---------------------------------------------------------------------------
35// The file format
36// ---------------------------------------------------------------------------
37
38/// One entry as it sits in the note file.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Entry {
41    /// The `## ` heading text, marker excluded.
42    pub title: String,
43    /// Everything under the heading up to the next entry, trimmed. Keeps the
44    /// report sections and the `Found while working on #N.` line.
45    pub body: String,
46    /// Byte range in the source covering the whole entry, marker included.
47    ///
48    /// The rewrite removes spans out of the original text rather than
49    /// re-rendering what stays, which is what makes a hand edited file safe.
50    pub span: Range<usize>,
51}
52
53/// The headings `review::issue_report` writes inside an entry.
54///
55/// Taken from `Finding::report_sections` rather than written out again. A fifth
56/// section added there and forgotten here would split every entry that used it
57/// in two, and the second half would be filed as its own issue.
58fn report_headings() -> Vec<&'static str> {
59    Finding {
60        problem: Some("x".into()),
61        reproduction: Some("x".into()),
62        impact: Some("x".into()),
63        expected: Some("x".into()),
64        ..Finding::default()
65    }
66    .report_sections()
67    .into_iter()
68    .map(|(heading, _)| heading)
69    .collect()
70}
71
72/// Whether a `## ` heading names a section of a bug report rather than an
73/// entry's title.
74///
75/// Compares the whole heading, so `## Reproduction steps are missing` is a
76/// title and not a section. The extra names are what a model writing
77/// `new_issue_body` free hand produces, which the schema does not constrain.
78fn is_section_heading(text: &str) -> bool {
79    let got = text.trim().trim_end_matches(':').trim().to_lowercase();
80    report_headings().iter().any(|h| h.to_lowercase() == got)
81        || matches!(
82            got.as_str(),
83            "expected behaviour"
84                | "expected"
85                | "actual result"
86                | "actual results"
87                | "actual behavior"
88                | "actual behaviour"
89                | "steps to reproduce"
90                | "summary"
91        )
92}
93
94/// Every line with the byte offset it starts at.
95fn lines_with_offsets(text: &str) -> impl Iterator<Item = (usize, &str)> {
96    let mut at = 0usize;
97    text.split_inclusive('\n').map(move |line| {
98        let start = at;
99        at += line.len();
100        (start, line.trim_end_matches(['\n', '\r']))
101    })
102}
103
104/// Split a note file into entries.
105///
106/// Two rules, in order:
107///
108/// 1. A `<!-- spar:followup -->` line always starts an entry. spar writes one
109///    above every entry it appends, so a file written from this release on is
110///    read exactly rather than guessed at, and the first `## ` line after the
111///    marker is that entry's title rather than a boundary.
112/// 2. Otherwise a `## ` line starts an entry unless it names one of the
113///    sections a bug report is written in, or there is nothing open for it to
114///    be a section of.
115///
116/// Rule 2 is generous about what counts as a section, on purpose, because the
117/// two ways to be wrong are not the same size. Mistaking a section for a title
118/// splits one follow-up into four and files an issue called "Impact" carrying a
119/// fragment: visible, embarrassing, and on somebody else's tracker. Mistaking a
120/// title for a section merges two follow-ups into one issue that carries both:
121/// fat, and recoverable by reading it. Nothing is lost.
122///
123/// A heading inside a fenced code block is never a boundary. A body is free to
124/// carry a snippet, `style::issue_body` deliberately protects fenced blocks
125/// from the length budget, and a snippet containing a `## ` line would
126/// otherwise split the entry that quotes it.
127///
128/// Rejected as a third signal: "an entry follows a `Found while working on #N.`
129/// line". Provenance is not guaranteed, a hand written entry has none, and a
130/// parser that leans on prose is one an edit breaks.
131pub fn parse(text: &str) -> Vec<Entry> {
132    // Entry start, and where its `## ` title line starts if it has one.
133    let mut opens: Vec<(usize, Option<usize>)> = Vec::new();
134    let mut open = false;
135    let mut awaiting_title = false;
136    let mut fenced = false;
137
138    for (offset, line) in lines_with_offsets(text) {
139        let trimmed = line.trim_start();
140        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
141            fenced = !fenced;
142            continue;
143        }
144        if fenced {
145            continue;
146        }
147        if trimmed.starts_with(FOLLOWUP_MARKER) {
148            opens.push((offset, None));
149            open = true;
150            awaiting_title = true;
151            continue;
152        }
153        let Some(heading) = trimmed.strip_prefix("## ") else {
154            continue;
155        };
156        if awaiting_title {
157            if let Some(last) = opens.last_mut() {
158                last.1 = Some(offset);
159            }
160            awaiting_title = false;
161            continue;
162        }
163        // Nothing is open, so this cannot be a section of anything: a file
164        // whose first heading is `## Problem` holds one entry called Problem.
165        if open && is_section_heading(heading) {
166            continue;
167        }
168        opens.push((offset, Some(offset)));
169        open = true;
170    }
171
172    let mut out = Vec::with_capacity(opens.len());
173    for (i, (start, title_at)) in opens.iter().enumerate() {
174        let end = opens.get(i + 1).map(|(s, _)| *s).unwrap_or(text.len());
175        let (title, body_from) = match title_at {
176            Some(at) => {
177                let line_end = text[*at..end].find('\n').map(|n| at + n + 1).unwrap_or(end);
178                let heading = text[*at..line_end]
179                    .trim()
180                    .trim_start_matches("## ")
181                    .trim()
182                    .to_string();
183                (heading, line_end)
184            }
185            // A marker with no heading under it. Keep the entry rather than
186            // dropping it: the body is still somebody's finding.
187            None => (String::new(), *start),
188        };
189        out.push(Entry {
190            title,
191            body: text[body_from..end].trim().to_string(),
192            span: *start..end,
193        });
194    }
195    out
196}
197
198/// The text with these entries removed, blank lines closed up at the seams.
199///
200/// Removal is by byte span out of the text as it was parsed, never by
201/// re-rendering what stays. Anything in the file spar did not write, a preamble,
202/// an entry in a shape spar does not recognise, an edit somebody made to a body,
203/// comes back out exactly as it went in.
204///
205/// Spans are sorted and merged first, so a caller may pass them in any order and
206/// may pass the same one twice. That is what makes it correct to rewrite the
207/// file once per entry against the *original* text: splicing a splice would
208/// shift every later offset.
209pub fn without(text: &str, removed: &[Entry]) -> String {
210    let mut spans: Vec<Range<usize>> = removed.iter().map(|e| e.span.clone()).collect();
211    spans.sort_by_key(|s| s.start);
212
213    let mut out = String::with_capacity(text.len());
214    let mut cursor = 0usize;
215    for span in spans {
216        // Already covered by an earlier span. Skipping rather than slicing
217        // backwards, because a panic here would take the queue with it.
218        if span.start < cursor {
219            cursor = cursor.max(span.end);
220            continue;
221        }
222        out.push_str(&text[cursor..span.start]);
223        cursor = span.end;
224    }
225    out.push_str(&text[cursor..]);
226
227    let joined = BLANK_RUN.replace_all(out.trim_end(), "\n\n").to_string();
228    if joined.trim().is_empty() {
229        String::new()
230    } else {
231        format!("{joined}\n")
232    }
233}
234
235// ---------------------------------------------------------------------------
236// Screening
237// ---------------------------------------------------------------------------
238
239const SCREEN_PROMPT: &str = "\
240Below are follow-ups recorded against this repository while other work was going
241on. Each was a real finding when it was written. Time has passed and the code has
242moved: some are already fixed, some describe behaviour that no longer exists, and
243some were never worth the interruption.
244
245Read the code in your working directory before judging each one. Do not modify
246anything. The current checkout is what \"now\" means. Judge against it, not
247against what the entry says the code used to do.
248
249For each entry decide:
250- verdict: still_relevant, already_fixed, not_worth_it, or duplicate.
251  - still_relevant: the defect is still there. It becomes a GitHub issue.
252  - already_fixed: go and look. Name the function or the change that fixed it,
253    so somebody reading this can check you.
254  - not_worth_it: real, still there, and not worth a maintainer's queue.
255  - duplicate: an open issue, or an earlier entry in this list, already covers
256    it. Put that number in duplicate_of.
257- reason: one sentence. For anything but still_relevant this is the only record
258  of why the entry was dropped, so give the reason rather than the verdict
259  restated.
260- title: the entry's title, which becomes the issue title. Copy it across unless
261  it is wrong or says nothing.
262
263Say still_relevant when you are unsure. What survives is triaged by both agents
264afterwards and can still be declined there. What you drop here is dropped.
265
266Entries:
267";
268
269/// The queue as the prompt carries it, and what would not fit.
270struct Rendered {
271    text: String,
272    /// Left in the file for a later run, because the queue did not fit in one
273    /// prompt.
274    deferred: usize,
275}
276
277/// Render the queue under one budget.
278///
279/// The same shape as `triage::render` and for the same reason, which is worth
280/// saying rather than sharing: whole entries wait rather than every entry losing
281/// its tail, because a verdict here *deletes* the entry, so judging one on part
282/// of what it says is worse than not having reached it yet.
283///
284/// Unlike an issue body there is no per entry cut at all. Every entry already
285/// went through `style::issue_body` on the way in, and unlike an issue the entry
286/// is all there is.
287fn render(entries: &[Entry], cfg: &Config) -> Rendered {
288    let mut parts: Vec<String> = Vec::new();
289    let mut total = 0usize;
290    let mut deferred = 0usize;
291
292    for (i, entry) in entries.iter().enumerate() {
293        if deferred > 0 {
294            deferred += 1;
295            continue;
296        }
297        let block = format!("{}. {}\n{}", i + 1, entry.title, entry.body);
298        let len = block.chars().count();
299        // The first entry goes in whatever its size. A queue of one that does
300        // not fit is a command that does nothing, forever.
301        if !parts.is_empty() && total + len > cfg.loop_cfg.max_triage_chars {
302            deferred += 1;
303            continue;
304        }
305        total += len;
306        parts.push(block);
307    }
308
309    Rendered {
310        text: parts.join("\n\n"),
311        deferred,
312    }
313}
314
315/// One agent's verdict on every entry that fits in one prompt.
316///
317/// One call for the whole queue rather than one per entry. A repo aware call per
318/// entry is the dominant cost, and `duplicate` is a judgement across entries as
319/// well as against the tracker: an agent shown one entry at a time cannot say
320/// that this is the same defect as the one above it.
321pub fn screen(
322    agent: &Agent,
323    cfg: &Config,
324    repo: &Repo,
325    entries: &[Entry],
326) -> Result<Vec<ScreenVerdict>> {
327    let rendered = render(entries, cfg);
328    if rendered.deferred > 0 {
329        logwarn!(
330            "the queue did not fit in one screening prompt, so {} entry(s) were left in the file \
331             for a later run",
332            rendered.deferred
333        );
334    }
335    let prompt = format!("{SCREEN_PROMPT}{}", rendered.text);
336    let effort = cfg.effort_for_round(&agent.spec, 1);
337    let answer: ScreenResponse =
338        agent.ask_json(&prompt, &schema::screen(), repo.root(), effort.as_deref())?;
339    Ok(answer.entries)
340}
341
342// ---------------------------------------------------------------------------
343// Working the queue
344// ---------------------------------------------------------------------------
345
346/// Where a run of `spar followup` stops.
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum Mode {
349    /// Print the verdicts and touch nothing.
350    ScreenOnly,
351    /// File the issues and stop, leaving them for a later `spar run`.
352    FileOnly,
353    /// File them and work them.
354    Work,
355}
356
357/// What was filed, and what was left behind.
358#[derive(Debug, Default)]
359pub struct Outcome {
360    /// Issues to work, in file order.
361    pub issues: Vec<i64>,
362    /// Left in the file: not reached, not screened, or could not be filed.
363    pub held: usize,
364}
365
366/// Read the queue, screen it, file what still holds, and take the filed entries
367/// out of the file.
368pub fn run(
369    agents: &[Agent],
370    cfg: &Config,
371    repo: &Repo,
372    path: &Path,
373    limit: usize,
374    mode: Mode,
375) -> Result<Outcome> {
376    let mut outcome = Outcome::default();
377
378    // Before any network call, so an empty queue is a purely local no-op.
379    let Ok(original) = std::fs::read_to_string(path) else {
380        log!("no follow-ups recorded in {}", path.display());
381        if repo.followups != Followups::Local {
382            log!(
383                "followups = \"{}\" is configured, so nothing is written to that file.",
384                repo.followups
385            );
386        }
387        return Ok(outcome);
388    };
389    if original.trim().is_empty() {
390        log!("{} is there and empty", path.display());
391        return Ok(outcome);
392    }
393
394    let entries = parse(&original);
395    if entries.is_empty() {
396        // A parser problem must not be reported as an empty queue.
397        log!(
398            "{} has no `## ` headings, so there is nothing to work. An entry is a `## Title` line \
399             and the text under it.",
400            path.display()
401        );
402        return Ok(outcome);
403    }
404
405    let taken: Vec<Entry> = entries.iter().take(limit).cloned().collect();
406    outcome.held += entries.len() - taken.len();
407    if outcome.held > 0 {
408        log!(
409            "{} follow-up(s) recorded, taking the first {limit}. Raise --limit for the rest.",
410            entries.len()
411        );
412    }
413
414    let agent = crate::agent::find(agents, &cfg.first_implementor)?;
415    // An `already_fixed` verdict is uninterpretable without knowing what was
416    // being judged.
417    log!(
418        "screening {} follow-up(s) with {} against {} at {}",
419        taken.len(),
420        agent.name(),
421        repo.git_try(&["rev-parse", "--abbrev-ref", "HEAD"]).trim(),
422        repo.git_try(&["rev-parse", "--short", "HEAD"]).trim(),
423    );
424
425    // A screen that did not happen is not a screen that found nothing: nothing
426    // is filed and the file is not touched.
427    let verdicts = screen(agent, cfg, repo, &taken)?;
428
429    if mode == Mode::ScreenOnly {
430        print_verdicts(&taken, &verdicts);
431        return Ok(outcome);
432    }
433
434    let mut disposed: Vec<Entry> = Vec::new();
435    for (i, entry) in taken.iter().enumerate() {
436        let number = i as i64 + 1;
437        let Some(verdict) = verdicts.iter().find(|v| v.entry == number) else {
438            // Never read as "drop it". Filing something nobody looked at
439            // defeats the point of the screen; leaving it costs one line.
440            logwarn!(
441                "no verdict for '{}', leaving it in the file",
442                first_line(&entry.title)
443            );
444            outcome.held += 1;
445            continue;
446        };
447
448        // "Duplicate of nothing in particular" is unfalsifiable, and
449        // `find_similar_issue` on the filing path is exactly the check for it.
450        let files = verdict.verdict == Screened::StillRelevant
451            || (verdict.verdict == Screened::Duplicate && verdict.duplicate_of.is_none());
452
453        if files {
454            let title = if verdict.title.trim().is_empty() {
455                entry.title.as_str()
456            } else {
457                verdict.title.as_str()
458            };
459            match crate::review::file_as_issue(repo, title, &entry.body) {
460                Ok(filed) => {
461                    log!("  {}", filed.describe(title));
462                    if let Some(n) = filed.number() {
463                        outcome.issues.push(n);
464                    }
465                    repo.archive_followup(title, &entry.body, &format!("Filed: {}", filed.note()));
466                }
467                Err(e) => {
468                    logwarn!("could not file '{}': {e}", first_line(title));
469                    outcome.held += 1;
470                    continue;
471                }
472            }
473        } else {
474            let why = dropped_note(verdict);
475            log!("  dropped '{}': {why}", first_line(&entry.title));
476            repo.archive_followup(&entry.title, &entry.body, &format!("Dropped: {why}"));
477        }
478
479        disposed.push(entry.clone());
480        // Written after the entry is dealt with, never before. The window is
481        // one entry wide and it always falls on the side of filing twice rather
482        // than losing one: an entry filed and not yet removed is found again
483        // next run and matched to the issue that was just created, while an
484        // entry removed before it was filed is gone.
485        crate::repo::write_text_atomic(path, &without(&original, &disposed)).map_err(|e| {
486            spar_err!(
487                "{e}\n{} follow-up(s) were already dealt with. Remove them from {} by hand before \
488                 running this again, or they will be filed twice.",
489                disposed.len(),
490                path.display()
491            )
492        })?;
493    }
494
495    let filed = outcome.issues.len();
496    println!(
497        "\nfollowups: {} screened, {filed} filed{}",
498        taken.len(),
499        summarise(&taken, &verdicts)
500    );
501    if outcome.held > 0 {
502        println!("{} entry(s) left in {}", outcome.held, path.display());
503    }
504    if !disposed.is_empty() {
505        println!(
506            "what was dealt with is in {}",
507            repo.worked_followups_path().display()
508        );
509    }
510    Ok(outcome)
511}
512
513fn first_line(text: &str) -> String {
514    crate::style::clip(text.trim().lines().next().unwrap_or("").trim(), 80)
515}
516
517fn dropped_note(v: &ScreenVerdict) -> String {
518    let reason = v.reason.trim();
519    match (v.verdict, v.duplicate_of) {
520        (Screened::Duplicate, Some(n)) if reason.is_empty() => format!("#{n} already covers it"),
521        (Screened::Duplicate, Some(n)) => format!("#{n} already covers it. {reason}"),
522        (_, _) if reason.is_empty() => v.verdict.to_string(),
523        _ => format!("{}. {reason}", v.verdict),
524    }
525}
526
527/// The tail of the summary line, naming each verdict that dropped something.
528fn summarise(taken: &[Entry], verdicts: &[ScreenVerdict]) -> String {
529    let mut counts: Vec<(Screened, usize)> = Vec::new();
530    for v in verdicts {
531        if v.entry < 1 || v.entry as usize > taken.len() {
532            continue;
533        }
534        match counts.iter_mut().find(|(k, _)| *k == v.verdict) {
535            Some((_, n)) => *n += 1,
536            None => counts.push((v.verdict, 1)),
537        }
538    }
539    counts.retain(|(k, _)| *k != Screened::StillRelevant);
540    if counts.is_empty() {
541        return String::new();
542    }
543    let listed: Vec<String> = counts
544        .iter()
545        .map(|(k, n)| format!("{n} {}", k.as_str().replace('_', " ")))
546        .collect();
547    format!(", {}", listed.join(", "))
548}
549
550/// What `--screen-only` prints. `println!`, not `log!`: it is the whole output
551/// of the command, and an entry dropped with no visible record of why is
552/// exactly the failure the archive exists for.
553fn print_verdicts(taken: &[Entry], verdicts: &[ScreenVerdict]) {
554    println!();
555    for (i, entry) in taken.iter().enumerate() {
556        let number = i as i64 + 1;
557        match verdicts.iter().find(|v| v.entry == number) {
558            Some(v) => println!(
559                "  {:<14} {}\n                 {}",
560                v.verdict.as_str(),
561                first_line(&entry.title),
562                v.reason.trim()
563            ),
564            None => println!("  {:<14} {}", "no verdict", first_line(&entry.title)),
565        }
566    }
567    let filed = verdicts
568        .iter()
569        .filter(|v| v.verdict == Screened::StillRelevant)
570        .count();
571    println!(
572        "\n{filed} of {} would be filed. Nothing was written.",
573        taken.len()
574    );
575}
576
577/// The issue numbers a set of outcomes produced, deduplicated and in order.
578pub fn wave(outcome: &Outcome) -> Vec<i64> {
579    outcome
580        .issues
581        .iter()
582        .copied()
583        .collect::<BTreeSet<_>>()
584        .into_iter()
585        .collect()
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    /// Two entries in the shape a real file has: a title, a lead paragraph,
593    /// the four report sections, and the provenance line.
594    const REAL: &str = "\
595## Backend headers never drive commitment CPFP retries
596
597The production ChainWatcher advances monitors and emits block.
598
599## Problem
600
601Configured chain backends route accepted headers through handleNewBlock.
602
603## Reproduction
604
6051. Configure a node with a watcher backend.
6062. Deliver height 101.
607
608## Impact
609
610Nodes do not retry stuck commitment packages on new blocks.
611
612## Expected behavior
613
614Run the pass exactly once for each accepted backend header.
615
616Found while working on #589.
617
618## Overlapping scans can move a recorded spend height backward
619
620## Problem
621
622checkOutputSpend applies its result with no arbitration against a later scan.
623
624## Impact
625
626A stale verdict can overwrite a newer one.
627
628Found while working on #590.
629";
630
631    /// The whole reason this module exists. A follow-up's own sections are
632    /// written at the same heading level as its title, so a naive split on
633    /// `## ` files six issues here, four of them called things like "Impact".
634    #[test]
635    fn an_entry_and_its_sections_are_not_confused_for_each_other() {
636        let entries = parse(REAL);
637        assert_eq!(
638            2,
639            entries.len(),
640            "{:#?}",
641            entries.iter().map(|e| &e.title).collect::<Vec<_>>()
642        );
643        assert!(entries[0].title.starts_with("Backend headers"));
644        assert!(entries[1].title.starts_with("Overlapping scans"));
645        // The sections stay with the entry they belong to.
646        assert!(
647            entries[0].body.contains("## Reproduction"),
648            "{}",
649            entries[0].body
650        );
651        assert!(entries[0].body.contains("Found while working on #589."));
652    }
653
654    /// The heading heuristic alone cannot see a title that collides with a
655    /// section name. The marker is what fixes that going forward, and spar
656    /// writes one above every entry it appends.
657    #[test]
658    fn a_marker_makes_the_boundary_exact() {
659        let text = format!(
660            "{FOLLOWUP_MARKER}\n## Impact\n\nThe first one.\n\n\
661             {FOLLOWUP_MARKER}\n## Problem\n\nThe second one.\n"
662        );
663        let entries = parse(&text);
664        assert_eq!(2, entries.len());
665        assert_eq!("Impact", entries[0].title);
666        assert_eq!("Problem", entries[1].title);
667    }
668
669    /// Keeping the queue by hand is a documented use, and it must not require
670    /// knowing spar's marker.
671    #[test]
672    fn a_hand_written_file_with_no_markers_still_parses() {
673        let text =
674            "## One thing\n\nprose\n\n## Another thing\n\nmore prose\n\n## A third\n\nyet more\n";
675        let entries = parse(text);
676        assert_eq!(3, entries.len());
677        assert_eq!("Another thing", entries[1].title);
678    }
679
680    /// A body is free to carry a snippet, and `style::issue_body` deliberately
681    /// protects fenced blocks from the length budget, so a snippet containing a
682    /// `## ` line would otherwise split the entry that quotes it.
683    #[test]
684    fn a_heading_inside_a_fenced_block_does_not_start_an_entry() {
685        let text = "## Real title\n\n```md\n## Problem\n## Not a title either\n```\n\nprose\n";
686        let entries = parse(text);
687        assert_eq!(1, entries.len(), "{:?}", entries);
688        assert_eq!("Real title", entries[0].title);
689    }
690
691    /// `is_section_heading` compares the whole heading. A prefix match would
692    /// swallow an entry whose title happens to open with a section word.
693    #[test]
694    fn an_entry_whose_title_opens_with_a_section_word_is_still_a_title() {
695        let text =
696            "## First\n\nprose\n\n## Reproduction steps are missing from the docs\n\nprose\n";
697        assert_eq!(2, parse(text).len());
698    }
699
700    /// A fifth section added to a bug report and forgotten here would split
701    /// every entry that used it, and the second half would be filed on its own.
702    #[test]
703    fn the_section_list_covers_every_heading_a_report_writes() {
704        for heading in report_headings() {
705            assert!(
706                is_section_heading(heading),
707                "`## {heading}` would be read as the start of a new follow-up"
708            );
709        }
710    }
711
712    /// A rewrite that re-renders what stays silently deletes whatever a person
713    /// added: a note at the top, an edit inside a body, a trailing reminder.
714    #[test]
715    fn text_the_parser_does_not_own_survives_a_rewrite() {
716        let text = "A note I keep at the top.\n\n\
717                    ## One\n\nfirst\n\n\
718                    ## Two\n\nsecond\n\n\
719                    ## Three\n\nthird\n";
720        let entries = parse(text);
721        assert_eq!(3, entries.len());
722        let out = without(text, &[entries[1].clone()]);
723        assert!(out.starts_with("A note I keep at the top."), "{out}");
724        assert!(out.contains("## One"), "{out}");
725        assert!(!out.contains("## Two"), "{out}");
726        assert!(out.contains("## Three"), "{out}");
727        assert!(out.contains("third"), "{out}");
728    }
729
730    /// The guard against splicing a splice. Spans index the text as it was
731    /// parsed, so removing one entry would shift every later offset, and the
732    /// run rewrites the file once per entry.
733    #[test]
734    fn removing_entries_one_at_a_time_matches_removing_them_at_once() {
735        let entries = parse(REAL);
736        let all_at_once = without(REAL, &entries);
737
738        let mut done = Vec::new();
739        let mut last = String::new();
740        for entry in &entries {
741            done.push(entry.clone());
742            last = without(REAL, &done);
743        }
744        assert_eq!(all_at_once, last);
745        assert!(last.is_empty(), "{last:?}");
746    }
747
748    /// A caller that recorded the same entry twice, or recorded them out of
749    /// order, must not corrupt the file or panic on a backwards slice.
750    #[test]
751    fn without_tolerates_a_repeated_or_unordered_span() {
752        let entries = parse(REAL);
753        let once = without(REAL, &[entries[0].clone()]);
754        let twice = without(REAL, &[entries[0].clone(), entries[0].clone()]);
755        assert_eq!(once, twice);
756
757        let forwards = without(REAL, &[entries[0].clone(), entries[1].clone()]);
758        let backwards = without(REAL, &[entries[1].clone(), entries[0].clone()]);
759        assert_eq!(forwards, backwards);
760    }
761
762    /// An emptied queue is an empty file, not a pile of blank lines that reads
763    /// as content to the next thing that opens it.
764    #[test]
765    fn removing_every_entry_leaves_an_empty_file() {
766        let entries = parse(REAL);
767        assert_eq!("", without(REAL, &entries));
768    }
769
770    /// The link back to the work that found a defect cannot be re-derived, so
771    /// the body has to carry it through unchanged.
772    #[test]
773    fn an_entry_keeps_the_provenance_it_was_written_with() {
774        let entries = parse(REAL);
775        assert!(entries[1].body.ends_with("Found while working on #590."));
776    }
777
778    /// A file edited on Windows must parse the same as one edited anywhere else.
779    #[test]
780    fn crlf_line_endings_parse_the_same_as_lf() {
781        let lf = "## One\n\nfirst\n\n## Two\n\nsecond\n";
782        let crlf = lf.replace('\n', "\r\n");
783        let a = parse(lf);
784        let b = parse(&crlf);
785        assert_eq!(a.len(), b.len());
786        assert_eq!(a[1].title, b[1].title);
787    }
788
789    /// A file whose first heading is a section name holds one entry called
790    /// that, rather than nothing at all.
791    #[test]
792    fn a_file_that_opens_with_a_section_name_still_holds_an_entry() {
793        let entries = parse("## Problem\n\nsomething is wrong\n");
794        assert_eq!(1, entries.len());
795        assert_eq!("Problem", entries[0].title);
796    }
797
798    fn verdict(entry: i64, v: Screened, dup: Option<i64>) -> ScreenVerdict {
799        ScreenVerdict {
800            entry,
801            verdict: v,
802            title: String::new(),
803            reason: "because".into(),
804            duplicate_of: dup,
805        }
806    }
807
808    /// "Duplicate of nothing in particular" is unfalsifiable, and the search on
809    /// the filing path is exactly the check for it. Dropping the entry on that
810    /// verdict would delete a real defect on no evidence.
811    #[test]
812    fn a_duplicate_verdict_with_nothing_to_point_at_would_still_be_filed() {
813        let with_number = verdict(1, Screened::Duplicate, Some(412));
814        let without_number = verdict(1, Screened::Duplicate, None);
815        let files = |v: &ScreenVerdict| {
816            v.verdict == Screened::StillRelevant
817                || (v.verdict == Screened::Duplicate && v.duplicate_of.is_none())
818        };
819        assert!(!files(&with_number));
820        assert!(files(&without_number));
821    }
822
823    /// A short answer must never read as "drop the rest". The entries the
824    /// screen did not rule on stay in the file.
825    #[test]
826    fn an_entry_with_no_verdict_is_not_disposed_of() {
827        let entries = parse(REAL);
828        let verdicts = [verdict(1, Screened::AlreadyFixed, None)];
829        let unruled: Vec<usize> = (1..=entries.len())
830            .filter(|n| !verdicts.iter().any(|v| v.entry == *n as i64))
831            .collect();
832        assert_eq!(vec![2], unruled);
833    }
834
835    /// An out of range index used as a slice index panics, and a verdict for an
836    /// entry that does not exist must not shift the ones that do.
837    #[test]
838    fn a_verdict_naming_an_entry_that_does_not_exist_is_ignored() {
839        let entries = parse(REAL);
840        let verdicts = [verdict(9, Screened::AlreadyFixed, None)];
841        assert_eq!("", summarise(&entries, &verdicts));
842    }
843
844    /// The summary is the only place a dropped entry is accounted for, and it
845    /// has to name what happened rather than only how many.
846    #[test]
847    fn the_summary_names_each_verdict_that_dropped_something() {
848        let entries = parse(REAL);
849        let verdicts = vec![
850            verdict(1, Screened::AlreadyFixed, None),
851            verdict(2, Screened::StillRelevant, None),
852        ];
853        let out = summarise(&entries, &verdicts);
854        assert!(out.contains("1 already fixed"), "{out}");
855        assert!(!out.contains("still relevant"), "{out}");
856    }
857
858    /// The reason is the only record of why an entry left the queue, so it has
859    /// to survive into the log line and the archive.
860    #[test]
861    fn a_dropped_entry_carries_its_reason_and_the_issue_it_duplicates() {
862        let note = dropped_note(&verdict(1, Screened::Duplicate, Some(412)));
863        assert!(note.contains("#412"), "{note}");
864        assert!(note.contains("because"), "{note}");
865    }
866}
867
868#[cfg(test)]
869mod real_file {
870    use super::*;
871
872    /// The queue one real run left on a real repository, captured verbatim.
873    /// Five follow-ups, twenty-five `## ` lines. This is the file the heuristic
874    /// is measured against rather than guessed at.
875    const CORPUS: &str = include_str!("../tests/fixtures/local_followups.md");
876
877    #[test]
878    fn the_real_queue_parses_as_five_follow_ups_not_twenty_five() {
879        let entries = parse(CORPUS);
880        assert_eq!(
881            5,
882            entries.len(),
883            "{:#?}",
884            entries.iter().map(|e| e.title.as_str()).collect::<Vec<_>>()
885        );
886        for entry in &entries {
887            assert!(
888                !is_section_heading(&entry.title),
889                "a section was filed as a follow-up: {}",
890                entry.title
891            );
892            assert!(!entry.body.trim().is_empty(), "{} has no body", entry.title);
893        }
894    }
895
896    /// Every entry in that file ends with the line that says which issue it
897    /// came out of, and an issue filed from it is the only place that link
898    /// survives.
899    #[test]
900    fn every_entry_in_the_real_queue_keeps_its_provenance() {
901        for entry in parse(CORPUS) {
902            assert!(
903                entry.body.contains("Found while working on #"),
904                "{} lost its provenance",
905                entry.title
906            );
907        }
908    }
909
910    /// Removing them one at a time, always against the original text, has to
911    /// end where removing them all at once does, and has to end empty.
912    #[test]
913    fn the_real_queue_drains_to_nothing_one_entry_at_a_time() {
914        let entries = parse(CORPUS);
915        let mut done = Vec::new();
916        let mut text = CORPUS.to_string();
917        for entry in &entries {
918            done.push(entry.clone());
919            text = without(CORPUS, &done);
920        }
921        assert_eq!("", text);
922        assert_eq!(without(CORPUS, &entries), text);
923    }
924
925    /// Taking the middle one out leaves the other four intact, including the
926    /// one a person would notice first if the seams were wrong.
927    #[test]
928    fn draining_one_entry_leaves_the_rest_byte_for_byte() {
929        let entries = parse(CORPUS);
930        let out = without(CORPUS, &[entries[2].clone()]);
931        let left = parse(&out);
932        assert_eq!(4, left.len());
933        for (before, after) in [(0, 0), (1, 1), (3, 2), (4, 3)] {
934            assert_eq!(entries[before].title, left[after].title);
935            assert_eq!(entries[before].body, left[after].body);
936        }
937    }
938}