Skip to main content

spar/
style.rs

1//! Two gates over every string spar sends to GitHub.
2//!
3//! **Style.** Models comply unreliably with negative instructions, especially
4//! over a long run, so prompting is necessary but not sufficient. Every commit
5//! message, PR body, and comment is scrubbed deterministically and then
6//! re-verified. A leak is a hard error, not a warning.
7//!
8//! **Shape.** The reader of a PR is a human with other work. What made a thread
9//! unreadable was never the length of the findings, it was spar narrating
10//! itself: which agent spoke, which round it was, counts of things listed on the
11//! next line. So spar composes every comment itself from structured fields, and
12//! that is where brevity comes from.
13//!
14//! The length budgets below are safety valves, not editors. They are sized so
15//! that real content is never touched, and when one does fire it completes the
16//! sentence in progress rather than stopping mid-thought. Cutting substance was
17//! a mistake worth naming: a reader who cannot act on a finding has been given
18//! nothing, and the characters saved bought nothing. Brevity is asked for in the
19//! prompts, which is free, and enforced only on shape.
20
21use std::sync::LazyLock;
22
23use regex::Regex;
24
25/// Figure dash, en dash, em dash, horizontal bar. The Python original caught
26/// only en and em; a model that reaches for U+2015 should not slip through.
27const DASHES: &str = r"[\x{2012}-\x{2015}]";
28
29static ATTRIBUTION_LINE: LazyLock<Regex> = LazyLock::new(|| {
30    Regex::new(concat!(
31        r"(?im)^\s*(?:",
32        r"co-authored-by:\s*(?:claude|codex|openai|chatgpt|anthropic|gpt).*",
33        r"|\x{1F916}?\s*generated with .*",
34        r"|.*\bwritten by (?:claude|codex|chatgpt|an? ai)\b.*",
35        r"|assisted[- ]by:.*",
36        r")\s*$",
37    ))
38    .expect("attribution line pattern")
39});
40
41static ATTRIBUTION_INLINE: LazyLock<Regex> = LazyLock::new(|| {
42    Regex::new(concat!(
43        r"(?i)\b(?:",
44        r"generated (?:with|by) (?:claude|codex|openai|chatgpt|ai)",
45        r"|(?:written|authored|created) (?:with|by) (?:claude|codex|chatgpt|ai)",
46        r"|with the help of (?:claude|codex|chatgpt|ai)",
47        r"|using (?:claude code|codex|chatgpt)",
48        r"|ai[- ]generated",
49        r"|as an ai\b",
50        r")",
51    ))
52    .expect("attribution inline pattern")
53});
54
55static DASH_RUN: LazyLock<Regex> =
56    LazyLock::new(|| Regex::new(&format!(r"[ \t]*{DASHES}[ \t]*")).expect("dash pattern"));
57
58static ANY_DASH: LazyLock<Regex> = LazyLock::new(|| Regex::new(DASHES).expect("dash class"));
59
60static TRAILING_SPACE: LazyLock<Regex> =
61    LazyLock::new(|| Regex::new(r"(?m)[ \t]+$").expect("trailing space pattern"));
62
63static BLANK_RUN: LazyLock<Regex> =
64    LazyLock::new(|| Regex::new(r"\n{3,}").expect("blank run pattern"));
65
66static HEADING: LazyLock<Regex> =
67    LazyLock::new(|| Regex::new(r"^\s{0,3}#{1,6}\s+\S").expect("heading pattern"));
68
69/// Headings that only announce that a body follows. Dropping them costs the
70/// reader nothing and saves them a line.
71static NOISE_HEADING: LazyLock<Regex> = LazyLock::new(|| {
72    Regex::new(
73        r"(?i)^\s{0,3}#{1,6}\s*(summary|description|overview|context|details?|background)\s*:?\s*$",
74    )
75    .expect("noise heading pattern")
76});
77
78/// Everything the two gates need to know. Mirrors the `[style]` config block.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct Style {
81    pub ban_em_dash: bool,
82    pub ban_ai_attribution: bool,
83    /// Enforce the length budgets below. Off means model prose passes through
84    /// at whatever length it arrived at.
85    pub terse: bool,
86    /// A finding's explanatory detail, as shown in the PR thread.
87    pub max_detail_chars: usize,
88    /// A one-line verdict or disposition summary.
89    pub max_summary_chars: usize,
90    /// A PR body.
91    pub max_body_chars: usize,
92    /// A filed issue's body.
93    pub max_issue_body_chars: usize,
94    /// A finding title, issue title, or PR title.
95    pub max_title_chars: usize,
96    /// How much of its own working spar narrates into a pull request thread.
97    pub pr_comments: crate::config::PrComments,
98}
99
100impl Default for Style {
101    fn default() -> Self {
102        Self {
103            ban_em_dash: true,
104            ban_ai_attribution: true,
105            terse: true,
106            max_detail_chars: 2000,
107            max_summary_chars: 1200,
108            max_body_chars: 2000,
109            max_issue_body_chars: 8000,
110            max_title_chars: 140,
111            pr_comments: crate::config::PrComments::Outcome,
112        }
113    }
114}
115
116impl Style {
117    /// Style rules only, no length budgets. Used for text spar composed itself
118    /// and has already sized.
119    pub fn permissive() -> Self {
120        Self {
121            terse: false,
122            ..Self::default()
123        }
124    }
125}
126
127// ---------------------------------------------------------------------------
128// Style gate
129// ---------------------------------------------------------------------------
130
131/// Remove banned style artifacts. Idempotent: scrubbing scrubbed text is a
132/// no-op, which matters because text passes through here more than once.
133pub fn scrub(text: &str, style: &Style) -> String {
134    if text.is_empty() {
135        return String::new();
136    }
137    let mut out = text.to_string();
138
139    if style.ban_ai_attribution {
140        out = ATTRIBUTION_LINE.replace_all(&out, "").into_owned();
141        out = ATTRIBUTION_INLINE.replace_all(&out, "").into_owned();
142        out = out.replace('\u{1F916}', "");
143    }
144
145    if style.ban_em_dash {
146        // "a - b" becomes "a, b". Bounded to spaces and tabs so a dash at the
147        // end of a line joins two lines with a comma instead of swallowing the
148        // paragraph break after it.
149        out = DASH_RUN.replace_all(&out, ", ").into_owned();
150    }
151
152    out = TRAILING_SPACE.replace_all(&out, "").into_owned();
153    out = BLANK_RUN.replace_all(&out, "\n\n").into_owned();
154    out.trim().to_string()
155}
156
157/// Anything the scrub should have caught. Used as a post-check, so that a
158/// pattern the scrub cannot fix becomes a loud failure rather than a leak.
159pub fn violations(text: &str, style: &Style) -> Vec<String> {
160    let mut bad = Vec::new();
161    if style.ban_em_dash && ANY_DASH.is_match(text) {
162        bad.push("em/en dash present".to_string());
163    }
164    if style.ban_ai_attribution
165        && (ATTRIBUTION_LINE.is_match(text) || ATTRIBUTION_INLINE.is_match(text))
166    {
167        bad.push("AI attribution present".to_string());
168    }
169    bad
170}
171
172// ---------------------------------------------------------------------------
173// Concision gate
174// ---------------------------------------------------------------------------
175
176/// Collapse to a single line of single-spaced words.
177///
178/// For a field that is displayed inline, such as a finding title or a one-line
179/// verdict. A model that returns a paragraph there would otherwise break the
180/// layout of everything around it.
181pub fn one_line(text: &str) -> String {
182    text.split_whitespace().collect::<Vec<_>>().join(" ")
183}
184
185/// How far past a budget it is worth going to finish the sentence in progress.
186///
187/// A budget is a target, not a guillotine. Stopping mid-clause costs the reader
188/// the point being made and gains a handful of characters, which is a bad
189/// trade: a real close comment ended "and surviving instances already reconnect
190/// and..." and told nobody anything.
191const OVERSHOOT: usize = 240;
192
193/// Truncate to roughly `max` characters, ending on a complete sentence.
194pub fn clip(text: &str, max: usize) -> String {
195    clip_marked(text, max, "...")
196}
197
198/// The same, without an ellipsis when it does have to cut.
199///
200/// For a title, where a trailing "..." reads as broken rather than as
201/// shortened. Two issues were filed on a real repository with titles ending in
202/// a literal ellipsis, which is how this was found.
203pub fn clip_bare(text: &str, max: usize) -> String {
204    clip_marked(text, max, "")
205}
206
207fn clip_marked(text: &str, max: usize, marker: &str) -> String {
208    let trimmed = text.trim();
209    if max == 0 {
210        return trimmed.to_string();
211    }
212    let chars: Vec<char> = trimmed.chars().collect();
213    if chars.len() <= max {
214        return trimmed.to_string();
215    }
216
217    let ends_sentence = |i: usize| -> bool {
218        matches!(chars[i], '.' | '!' | '?')
219            // Look at the real next character, not the end of some window: a
220            // period landing on the budget has text after it, and treating that
221            // as the end of a sentence cuts a file path in half.
222            && chars.get(i + 1).is_none_or(|n| n.is_whitespace())
223    };
224
225    // The last sentence that ends at or before the budget, if it keeps enough
226    // of the text to be worth reading.
227    let within = (0..max).rfind(|i| ends_sentence(*i));
228    if let Some(cut) = within {
229        if (cut + 1) * 2 >= max {
230            return chars[..=cut]
231                .iter()
232                .collect::<String>()
233                .trim_end()
234                .to_string();
235        }
236    }
237
238    // Otherwise finish the sentence that is in progress, so long as it ends
239    // somewhere reasonable rather than running on forever. Bounded by the
240    // budget as well as by a constant, so a small budget cannot be doubled and
241    // doubled again by one long sentence.
242    let ceiling = (max + OVERSHOOT.min(max)).min(chars.len());
243    if let Some(cut) = (max..ceiling).find(|i| ends_sentence(*i)) {
244        return chars[..=cut]
245            .iter()
246            .collect::<String>()
247            .trim_end()
248            .to_string();
249    }
250
251    // A short complete sentence still beats a long severed one.
252    if let Some(cut) = within {
253        return chars[..=cut]
254            .iter()
255            .collect::<String>()
256            .trim_end()
257            .to_string();
258    }
259
260    // No sentence in sight. Cut at a word boundary, and say so unless the
261    // caller would rather not.
262    let budget = max.saturating_sub(marker.chars().count()).max(1);
263    let mut end = budget.min(chars.len());
264    while end > 0 && !chars[end - 1].is_whitespace() {
265        end -= 1;
266    }
267    if end == 0 {
268        end = budget.min(chars.len());
269    }
270    let mut out: String = chars[..end]
271        .iter()
272        .collect::<String>()
273        .trim_end()
274        .to_string();
275    out.push_str(marker);
276    out
277}
278
279/// Drop a heading whose section holds nothing, and the bare "## Summary" style
280/// heading that only announces the body underneath it.
281pub fn strip_empty_sections(text: &str) -> String {
282    let lines: Vec<&str> = text.lines().collect();
283    let mut keep: Vec<&str> = Vec::with_capacity(lines.len());
284
285    let mut i = 0;
286    while i < lines.len() {
287        let line = lines[i];
288        if HEADING.is_match(line) {
289            // Everything up to the next heading is this section's body.
290            let mut j = i + 1;
291            while j < lines.len() && !HEADING.is_match(lines[j]) {
292                j += 1;
293            }
294            let body_is_empty = lines[i + 1..j].iter().all(|l| l.trim().is_empty());
295            let only_heading = lines.iter().filter(|l| HEADING.is_match(l)).count() == 1;
296
297            if body_is_empty {
298                i = j; // drop the heading and the blank lines under it
299                continue;
300            }
301            if only_heading && NOISE_HEADING.is_match(line) {
302                i += 1; // drop the label, keep the body
303                continue;
304            }
305        }
306        keep.push(line);
307        i += 1;
308    }
309
310    let joined = keep.join("\n");
311    BLANK_RUN.replace_all(&joined, "\n\n").trim().to_string()
312}
313
314/// The full outbound treatment for a block of model prose: strip structural
315/// noise, then clip to a budget.
316pub fn tighten(text: &str, max: usize, style: &Style) -> String {
317    if !style.terse {
318        return text.trim().to_string();
319    }
320    clip(&strip_empty_sections(text), max)
321}
322
323/// A filed issue's body.
324///
325/// An issue is a work item. Somebody picks it up cold, possibly months later,
326/// with none of the context the pull request thread had, so the rules that keep
327/// a comment short are the wrong rules here. Two things follow.
328///
329/// A fenced code block is never truncated and never counts against the budget.
330/// A snippet cut in half is worse than useless: it is broken markdown and a
331/// misleading fragment of code. Steps to reproduce, a stack trace, the offending
332/// function: those are the reason the issue is worth filing at all.
333///
334/// And when prose does have to be dropped, whole blocks go from the end rather
335/// than a sentence being cut mid-word. What survives is complete.
336pub fn issue_body(text: &str, style: &Style) -> String {
337    if !style.terse {
338        return text.trim().to_string();
339    }
340    let cleaned = strip_empty_sections(text);
341    let blocks = split_blocks(&cleaned);
342
343    // A runaway model pasting an entire file is still worth stopping, so code
344    // is exempt from the prose budget but not from a far looser ceiling.
345    let ceiling = style.max_issue_body_chars.saturating_mul(4);
346
347    let mut kept: Vec<String> = Vec::new();
348    let mut prose = 0usize;
349    let mut total = 0usize;
350    for block in &blocks {
351        let len = block.text.chars().count();
352        if !block.code && prose + len > style.max_issue_body_chars && !kept.is_empty() {
353            break;
354        }
355        if total + len > ceiling {
356            // Past the point GitHub itself would take. Shortening a snippet at
357            // a line boundary with the fence closed still leaves something
358            // usable; dropping it leaves nothing.
359            if block.code {
360                if let Some(short) = shorten_code(&block.text, ceiling.saturating_sub(total)) {
361                    kept.push(short);
362                }
363            }
364            break;
365        }
366        if !block.code {
367            prose += len;
368        }
369        total += len;
370        kept.push(block.text.clone());
371    }
372
373    kept.join("\n\n").trim().to_string()
374}
375
376/// Keep as many whole lines of a fenced block as fit, and close the fence.
377///
378/// Only ever reached by a snippet large enough that GitHub would refuse the
379/// comment outright. Cutting on a line boundary keeps the code readable and
380/// keeps the markdown valid, and the note says plainly that there was more.
381fn shorten_code(block: &str, room: usize) -> Option<String> {
382    const NOTE: &str = "(snippet shortened)";
383    if room < 80 {
384        return None;
385    }
386    let mut lines = block.lines();
387    let opener = lines.next()?.to_string();
388    let mut out = vec![opener];
389    let mut used = out[0].chars().count() + NOTE.len() + 8;
390
391    for line in lines {
392        if line.trim_start().starts_with("```") {
393            break;
394        }
395        let len = line.chars().count() + 1;
396        if used + len > room {
397            break;
398        }
399        used += len;
400        out.push(line.to_string());
401    }
402    out.push("```".to_string());
403    out.push(String::new());
404    out.push(NOTE.to_string());
405    Some(out.join("\n"))
406}
407
408struct Block {
409    text: String,
410    code: bool,
411}
412
413/// Split into paragraphs, keeping every fenced code block whole however many
414/// blank lines it contains.
415fn split_blocks(text: &str) -> Vec<Block> {
416    let mut blocks = Vec::new();
417    let mut current: Vec<&str> = Vec::new();
418    let mut in_fence = false;
419    let mut fence_block = false;
420
421    let flush = |lines: &mut Vec<&str>, code: bool, out: &mut Vec<Block>| {
422        let joined = lines.join("\n");
423        if !joined.trim().is_empty() {
424            out.push(Block {
425                text: joined.trim_end().to_string(),
426                code,
427            });
428        }
429        lines.clear();
430    };
431
432    for line in text.lines() {
433        let fence = line.trim_start().starts_with("```");
434        if fence {
435            if in_fence {
436                current.push(line);
437                in_fence = false;
438                flush(&mut current, true, &mut blocks);
439                fence_block = false;
440                continue;
441            }
442            // A fence starts here, so whatever came before is its own block.
443            flush(&mut current, false, &mut blocks);
444            in_fence = true;
445            fence_block = true;
446            current.push(line);
447            continue;
448        }
449        if in_fence {
450            current.push(line);
451            continue;
452        }
453        if line.trim().is_empty() {
454            flush(&mut current, false, &mut blocks);
455        } else {
456            current.push(line);
457        }
458    }
459    // An unterminated fence is still kept whole rather than split.
460    flush(&mut current, fence_block, &mut blocks);
461    blocks
462}
463
464/// A finding title, issue title, or PR title: always one line, always short.
465pub fn title(text: &str, style: &Style) -> String {
466    let flat = one_line(text);
467    if style.terse {
468        clip_bare(&flat, style.max_title_chars)
469    } else {
470        flat
471    }
472}
473
474/// Capitalise the first letter, so a model's fragment reads as a sentence when
475/// spar sets it after one of its own.
476pub fn sentence(text: &str, style: &Style) -> String {
477    let one = summary(text, style);
478    let mut chars = one.chars();
479    match chars.next() {
480        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
481        None => one,
482    }
483}
484
485/// A one-sentence verdict or disposition reason.
486pub fn summary(text: &str, style: &Style) -> String {
487    let flat = one_line(text);
488    if style.terse {
489        clip(&flat, style.max_summary_chars)
490    } else {
491        flat
492    }
493}
494
495/// A finding's explanation, as it appears in the PR thread. Kept on one line so
496/// a bullet stays a bullet.
497pub fn detail(text: &str, style: &Style) -> String {
498    let flat = one_line(text);
499    if style.terse {
500        clip(&flat, style.max_detail_chars)
501    } else {
502        flat
503    }
504}
505
506/// An issue or PR body. Multi-line is fine here; bloat is not.
507pub fn body(text: &str, style: &Style) -> String {
508    tighten(text, style.max_body_chars, style)
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    fn s() -> Style {
516        Style::default()
517    }
518
519    // -- style gate ------------------------------------------------------
520
521    #[test]
522    fn em_dash_removed() {
523        let out = scrub(
524            "Fix the parser, it was broken \u{2014} badly \u{2014} on empty input.",
525            &s(),
526        );
527        assert!(!out.contains('\u{2014}'));
528        assert!(violations(&out, &s()).is_empty());
529    }
530
531    #[test]
532    fn en_dash_removed() {
533        assert!(!scrub("range 1 \u{2013} 5", &s()).contains('\u{2013}'));
534    }
535
536    #[test]
537    fn horizontal_bar_removed() {
538        assert!(violations(&scrub("a \u{2015} b", &s()), &s()).is_empty());
539    }
540
541    #[test]
542    fn coauthor_trailer_stripped() {
543        let out = scrub(
544            "Add retry logic\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n",
545            &s(),
546        );
547        assert!(!out.contains("Co-Authored-By"));
548        assert!(out.contains("Add retry logic"));
549    }
550
551    #[test]
552    fn generated_with_footer_stripped() {
553        let out = scrub(
554            "Fix bug\n\n\u{1F916} Generated with [Claude Code](https://claude.com)\n",
555            &s(),
556        );
557        assert!(violations(&out, &s()).is_empty(), "{out}");
558        assert!(out.contains("Fix bug"));
559    }
560
561    #[test]
562    fn inline_attribution_stripped() {
563        let out = scrub("This patch was written by Claude to fix the leak.", &s());
564        assert!(violations(&out, &s()).is_empty(), "{out}");
565    }
566
567    #[test]
568    fn scrub_is_idempotent() {
569        let once = scrub("A \u{2014} B\n\nCo-Authored-By: Codex <x@y.z>", &s());
570        assert_eq!(once, scrub(&once, &s()));
571    }
572
573    #[test]
574    fn violations_detected_before_scrub() {
575        assert!(!violations("a \u{2014} b", &s()).is_empty());
576        assert!(!violations("Co-Authored-By: Claude <a@b.c>", &s()).is_empty());
577    }
578
579    #[test]
580    fn legitimate_prose_survives() {
581        let out = scrub("Refactor the AI-facing endpoint handler for clarity.", &s());
582        assert!(out.contains("endpoint handler"), "{out}");
583    }
584
585    #[test]
586    fn disabled_rules_are_respected() {
587        let off = Style {
588            ban_em_dash: false,
589            ban_ai_attribution: false,
590            ..s()
591        };
592        let text = "a \u{2014} b\nCo-Authored-By: Claude <x@y.z>";
593        assert!(scrub(text, &off).contains('\u{2014}'));
594        assert!(violations(text, &off).is_empty());
595    }
596
597    #[test]
598    fn dash_at_end_of_line_does_not_swallow_the_paragraph_break() {
599        let out = scrub("first line \u{2014}\n\nsecond paragraph", &s());
600        assert!(out.contains("\n\n"), "{out:?}");
601    }
602
603    #[test]
604    fn empty_input_is_empty_output() {
605        assert_eq!("", scrub("", &s()));
606    }
607
608    // -- concision gate --------------------------------------------------
609
610    #[test]
611    fn one_line_flattens() {
612        assert_eq!("a b c", one_line("  a\n\n b\t c  "));
613    }
614
615    #[test]
616    fn clip_leaves_short_text_alone() {
617        assert_eq!("short", clip("short", 40));
618    }
619
620    #[test]
621    fn clip_prefers_a_sentence_boundary() {
622        let text = "The loop never terminates. It also leaks a file descriptor on every pass.";
623        assert_eq!("The loop never terminates.", clip(text, 40));
624    }
625
626    #[test]
627    fn clip_falls_back_to_a_word_boundary() {
628        let out = clip("supercalifragilistic wording that runs on and on", 25);
629        assert!(out.ends_with("..."), "{out}");
630        assert!(out.chars().count() <= 25, "{out}");
631        assert!(!out.contains("wording that runs"), "{out}");
632    }
633
634    #[test]
635    /// The budget is a target that rounds up to the end of a sentence, so it
636    /// can be exceeded on purpose. What must hold is that the overshoot is
637    /// bounded: a budget cannot be run away with.
638    fn clip_overshoots_only_within_bounds() {
639        for max in 1..60 {
640            let out = clip("one two three four five six seven eight nine ten.", max);
641            assert!(out.chars().count() <= max * 2 + 3, "max={max} out={out:?}");
642        }
643    }
644
645    #[test]
646    fn clip_handles_multibyte_text() {
647        let out = clip(&"\u{1f600}".repeat(50), 10);
648        assert!(out.chars().count() <= 10, "{out}");
649    }
650
651    /// The sentence-end scan used to look at the last character of the *budget*
652    /// rather than of the *text*, so a period landing exactly on the boundary
653    /// read as the end of a sentence. The result came back with no ellipsis, so
654    /// a truncated file path looked like finished prose.
655    #[test]
656    fn a_period_on_the_budget_boundary_is_not_a_sentence_end() {
657        assert_ne!(
658            "Version 1.",
659            clip("Version 1.4 of the parser mishandles input", 10)
660        );
661        assert_ne!(
662            "Panic in src/style.",
663            clip("Panic in src/style.rs when the budget lands mid word", 19)
664        );
665    }
666
667    #[test]
668    fn an_unmarked_clip_really_did_end_a_sentence() {
669        // The only way to come back without an ellipsis is to stop where the
670        // author stopped.
671        for max in 4..80 {
672            let text = "First sentence here. Second one follows it. Third trails off";
673            let out = clip(text, max);
674            if out.len() < text.len() && !out.ends_with("...") {
675                assert!(
676                    out.ends_with('.') || out.ends_with('!') || out.ends_with('?'),
677                    "max={max} out={out:?}"
678                );
679                let next = text[out.len()..].chars().next();
680                assert!(
681                    next.is_none_or(|c| c.is_whitespace()),
682                    "max={max} cut mid-token before {next:?}: {out:?}"
683                );
684            }
685        }
686    }
687
688    #[test]
689    fn clip_ignores_a_decimal_point_as_a_sentence_end() {
690        let text = "Version 1.4 of the parser mishandles empty input badly and loops.";
691        assert_ne!("Version 1.", clip(text, 30));
692    }
693
694    #[test]
695    fn empty_sections_are_dropped() {
696        let out = strip_empty_sections("## Context\n\n## Proposal\n\nDo the thing.\n");
697        assert!(!out.contains("Context"), "{out}");
698        assert!(out.contains("Do the thing."), "{out}");
699    }
700
701    #[test]
702    fn a_lone_label_heading_is_dropped() {
703        assert_eq!(
704            "The retry never fires.",
705            strip_empty_sections("## Summary\n\nThe retry never fires.")
706        );
707    }
708
709    #[test]
710    fn real_headings_survive_when_there_are_several() {
711        let text = "## Summary\n\nA thing.\n\n## Repro\n\nRun it.";
712        let out = strip_empty_sections(text);
713        assert!(
714            out.contains("## Summary") && out.contains("## Repro"),
715            "{out}"
716        );
717    }
718
719    #[test]
720    fn terse_off_leaves_length_alone() {
721        let loose = Style {
722            terse: false,
723            ..s()
724        };
725        let long = "word ".repeat(400);
726        assert_eq!(long.trim(), detail(&long, &loose));
727    }
728
729    #[test]
730    fn detail_is_capped_and_single_line() {
731        let out = detail(
732            &format!("first line\nsecond line\n{}", "filler ".repeat(200)),
733            &s(),
734        );
735        assert!(!out.contains('\n'));
736        assert!(out.chars().count() <= s().max_detail_chars);
737    }
738
739    #[test]
740    fn title_is_capped_and_single_line() {
741        let out = title(
742            "a very\nlong\ttitle that keeps going ".repeat(20).as_str(),
743            &s(),
744        );
745        assert!(!out.contains('\n'));
746        assert!(out.chars().count() <= s().max_title_chars);
747    }
748
749    #[test]
750    fn body_keeps_structure_but_bounds_length() {
751        let text = format!(
752            "## Summary\n\nreal content here.\n\n{}",
753            "more prose. ".repeat(300)
754        );
755        let out = body(&text, &s());
756        assert!(
757            out.chars().count() <= s().max_body_chars,
758            "{}",
759            out.chars().count()
760        );
761        assert!(out.contains("real content here"), "{out}");
762    }
763}
764
765#[cfg(test)]
766mod sentence_tests {
767    use super::*;
768
769    #[test]
770    fn a_fragment_reads_as_a_sentence() {
771        assert_eq!(
772            "The caller already validates it.",
773            sentence("the caller already validates it.", &Style::default())
774        );
775    }
776
777    #[test]
778    fn an_already_capitalised_one_is_untouched() {
779        assert_eq!(
780            "Already fine.",
781            sentence("Already fine.", &Style::default())
782        );
783    }
784
785    #[test]
786    fn empty_stays_empty_rather_than_panicking() {
787        assert_eq!("", sentence("   ", &Style::default()));
788    }
789
790    #[test]
791    fn a_multibyte_first_character_does_not_panic() {
792        assert_eq!("Ärger", sentence("ärger", &Style::default()));
793    }
794}
795
796#[cfg(test)]
797mod issue_body_tests {
798    use super::*;
799
800    fn s() -> Style {
801        Style::default()
802    }
803
804    fn fences(text: &str) -> usize {
805        text.lines()
806            .filter(|l| l.trim_start().starts_with("```"))
807            .count()
808    }
809
810    /// The whole point. A snippet cut in half is broken markdown and a
811    /// misleading fragment of the code somebody is being asked to fix.
812    #[test]
813    fn a_code_block_is_never_truncated() {
814        let code = (0..400)
815            .map(|n| format!("    line_{n}();"))
816            .collect::<Vec<_>>()
817            .join("\n");
818        let text = format!("It spins forever.\n\n```rust\n{code}\n```\n\nThat is the loop.");
819        let out = issue_body(&text, &s());
820
821        assert!(
822            out.contains("line_0();") && out.contains("line_399();"),
823            "the block was cut"
824        );
825        assert_eq!(0, fences(&out) % 2, "left an unclosed fence:\n{out}");
826    }
827
828    /// Why there are two functions rather than one budget. The comment path
829    /// cuts wherever the character count runs out, which on a snippet means an
830    /// unclosed fence and a misleading half of the code.
831    #[test]
832    /// However long the snippet, an issue keeps the fence closed. A comment
833    /// budget is character counted and knows nothing about fences, which is why
834    /// issues do not go through it.
835    fn an_issue_keeps_the_fence_closed_however_long_the_snippet() {
836        for lines in [50, 400, 4000] {
837            let code = (0..lines)
838                .map(|n| format!("    line_{n}();"))
839                .collect::<Vec<_>>()
840                .join("\n");
841            let text = format!("It spins forever.\n\n```rust\n{code}\n```");
842            let out = issue_body(&text, &s());
843            assert_eq!(0, fences(&out) % 2, "unclosed fence at {lines} lines");
844            if lines <= 400 {
845                assert!(
846                    out.contains(&format!("line_{}();", lines - 1)),
847                    "cut at {lines} lines"
848                );
849            } else {
850                // Past what GitHub would accept at all. Shortened rather than
851                // dropped, on a line boundary, and it says so.
852                assert!(out.contains("line_0();"), "the snippet went entirely");
853                assert!(
854                    out.contains("snippet shortened"),
855                    "the reader is not told: {}",
856                    &out[out.len().saturating_sub(80)..]
857                );
858            }
859        }
860    }
861
862    /// Code is exempt from the budget, so a long snippet cannot squeeze out the
863    /// prose that explains it.
864    #[test]
865    fn a_long_snippet_does_not_evict_the_explanation() {
866        let code = "x();\n".repeat(1500);
867        let text = format!(
868            "Reproduction:\n\n1. Call connect twice.\n2. Watch the retry count.\n\n```\n{code}```\n\nSuggested fix: bound the loop."
869        );
870        let out = issue_body(&text, &s());
871        assert!(out.contains("Call connect twice"), "{out}");
872        assert!(out.contains("Suggested fix"), "the tail survived");
873    }
874
875    #[test]
876    fn steps_to_reproduce_survive_intact() {
877        let text = "The retry never fires.\n\n1. Start the daemon.\n2. Kill the peer.\n3. Observe connectedToElectrum stays true.\n\nsrc/electrum/index.ts:289 is where the guard is.";
878        assert_eq!(text, issue_body(text, &s()));
879    }
880
881    #[test]
882    fn a_body_within_budget_is_untouched() {
883        let text = "One paragraph.\n\nAnd another.";
884        assert_eq!(text, issue_body(text, &s()));
885    }
886
887    /// When prose does have to go, whole blocks go from the end. Nothing is cut
888    /// mid-sentence and nothing gains an ellipsis.
889    #[test]
890    fn overlong_prose_drops_whole_blocks_from_the_end() {
891        let para = |n: usize| format!("Paragraph {n}. {}", "filler words here. ".repeat(30));
892        let text = (0..20).map(para).collect::<Vec<_>>().join("\n\n");
893        let out = issue_body(&text, &s());
894
895        assert!(out.starts_with("Paragraph 0."), "{out}");
896        assert!(!out.contains("..."), "no mid-sentence cut: {out}");
897        assert!(
898            out.trim_end().ends_with('.'),
899            "ends on a complete block: {out}"
900        );
901        assert!(
902            out.chars().count() <= s().max_issue_body_chars + 400,
903            "{}",
904            out.chars().count()
905        );
906    }
907
908    /// An issue gets far more room than a pull request comment, because it is
909    /// read cold by somebody with none of the context.
910    #[test]
911    fn an_issue_gets_much_more_room_than_a_comment() {
912        let text = "word ".repeat(4000);
913        assert!(
914            issue_body(&text, &s()).len() > body(&text, &s()).len() * 2,
915            "issue {} vs comment {}",
916            issue_body(&text, &s()).len(),
917            body(&text, &s()).len()
918        );
919    }
920
921    #[test]
922    fn a_single_block_over_budget_is_kept_rather_than_mangled() {
923        let text = format!("```\n{}\n```", "y();\n".repeat(3000));
924        let out = issue_body(&text, &s());
925        assert!(!out.is_empty());
926        assert_eq!(0, fences(&out) % 2, "{}", &out[..80.min(out.len())]);
927    }
928
929    #[test]
930    fn an_unterminated_fence_is_still_kept_whole() {
931        let text = "Here is the code:\n\n```rust\nfn broken() {\n    loop {}";
932        let out = issue_body(text, &s());
933        assert!(out.contains("fn broken()"), "{out}");
934    }
935
936    #[test]
937    fn terse_off_leaves_an_issue_body_completely_alone() {
938        let loose = Style {
939            terse: false,
940            ..s()
941        };
942        let text = "a".repeat(50_000);
943        assert_eq!(text, issue_body(&text, &loose));
944    }
945
946    #[test]
947    fn issue_body_is_idempotent() {
948        let text = format!(
949            "Explanation.\n\n```\n{}\n```\n\n{}",
950            "z();\n".repeat(50),
951            "more prose. ".repeat(600)
952        );
953        let once = issue_body(&text, &s());
954        assert_eq!(once, issue_body(&once, &s()));
955    }
956}
957
958#[cfg(test)]
959mod sentence_completion_tests {
960    use super::*;
961
962    fn s() -> Style {
963        Style::default()
964    }
965
966    /// The real close comment from beignet#493, which stopped mid-clause and
967    /// told the reader nothing.
968    const REAL: &str = "Disconnect() deliberately drops the instance's restore debt and stops \
969        its poll, so re-arming _restoreOwed there would leave a field nothing consumes, and \
970        surviving instances already reconnect and restore every remaining hash on their own.";
971
972    #[test]
973    fn the_real_comment_now_finishes_its_sentence() {
974        let out = summary(REAL, &s());
975        assert!(!out.ends_with("..."), "{out}");
976        assert!(out.ends_with('.'), "{out}");
977        assert!(out.contains("on their own"), "the thought completes: {out}");
978    }
979
980    /// The point of the overshoot: a budget is a target, not a guillotine.
981    #[test]
982    fn a_sentence_running_just_past_the_budget_is_finished_not_cut() {
983        let text = format!("{} and then it ends here.", "word ".repeat(78));
984        let out = clip(&text, 400);
985        assert!(out.ends_with("and then it ends here."), "{out}");
986        assert!(out.chars().count() > 400, "it overshot on purpose");
987    }
988
989    /// But not forever. Prose with no sentence end in sight still gets cut.
990    #[test]
991    fn a_sentence_that_never_ends_is_still_cut() {
992        let text = "word ".repeat(400);
993        let out = clip(&text, 200);
994        assert!(out.ends_with("..."), "{out}");
995        assert!(out.chars().count() <= 200, "{}", out.chars().count());
996    }
997
998    /// Overshoot is for a sentence straddling the budget, not a licence to
999    /// ignore it. Where sentences end regularly, it stops within budget.
1000    #[test]
1001    fn it_stops_within_budget_when_a_sentence_ends_there() {
1002        let text = "This sentence is complete. ".repeat(30);
1003        let out = clip(&text, 400);
1004        assert!(out.ends_with("complete."), "{out}");
1005        assert!(out.chars().count() <= 400, "{}", out.chars().count());
1006    }
1007
1008    /// And a small budget cannot be run away with by one long sentence.
1009    #[test]
1010    fn overshoot_never_more_than_doubles_the_budget() {
1011        let text = format!("Short. {}", "word ".repeat(400));
1012        let out = clip(&text, 60);
1013        assert!(out.chars().count() <= 120, "{}", out.chars().count());
1014    }
1015
1016    /// Two issues were filed on a real repository with titles ending in a
1017    /// literal ellipsis. A title is not a place for one.
1018    #[test]
1019    fn a_title_never_wears_an_ellipsis() {
1020        let long = "disconnect() stops electrum for clients.network rather than \
1021                    this.electrumNetwork and records the stop against a key nothing reads back"
1022            .to_string();
1023        let out = title(&long, &s());
1024        assert!(!out.ends_with("..."), "{out}");
1025        assert!(!out.contains("..."), "{out}");
1026    }
1027
1028    /// The two real titles that were truncated were 85 and 89 characters after
1029    /// spar cut them. The raised budget keeps titles of that length whole.
1030    #[test]
1031    fn the_titles_that_were_cut_would_now_survive() {
1032        for real in [
1033            "Public subscribeToHeader/subscribeToAddresses re-register an instance disconnect() \
1034             deliberately dropped",
1035            "disconnect() stops electrum for clients.network, not this.electrumNetwork, and \
1036             records the stop against the wrong key",
1037        ] {
1038            let out = title(real, &s());
1039            assert_eq!(one_line(real), out, "still truncated: {out}");
1040        }
1041    }
1042
1043    #[test]
1044    fn the_budgets_leave_room_to_finish_a_thought() {
1045        let d = s();
1046        assert!(d.max_summary_chars >= 400, "a one line reason needs room");
1047        assert!(d.max_detail_chars >= 500);
1048        assert!(d.max_title_chars >= 140);
1049    }
1050
1051    #[test]
1052    fn a_short_text_is_still_left_completely_alone() {
1053        assert_eq!("Already short.", clip("Already short.", 400));
1054        assert_eq!("Already short.", clip_bare("Already short.", 400));
1055    }
1056}