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//! **Concision.** The reader of a PR is a human with other work. Model prose
9//! defaults to three paragraphs where one sentence would do, and asking nicely
10//! has the same reliability problem as asking for no em-dashes. So spar
11//! composes every comment itself from structured fields and clips each field to
12//! a budget, rather than forwarding whatever the model felt like writing.
13
14use std::sync::LazyLock;
15
16use regex::Regex;
17
18/// Figure dash, en dash, em dash, horizontal bar. The Python original caught
19/// only en and em; a model that reaches for U+2015 should not slip through.
20const DASHES: &str = r"[\x{2012}-\x{2015}]";
21
22static ATTRIBUTION_LINE: LazyLock<Regex> = LazyLock::new(|| {
23    Regex::new(concat!(
24        r"(?im)^\s*(?:",
25        r"co-authored-by:\s*(?:claude|codex|openai|chatgpt|anthropic|gpt).*",
26        r"|\x{1F916}?\s*generated with .*",
27        r"|.*\bwritten by (?:claude|codex|chatgpt|an? ai)\b.*",
28        r"|assisted[- ]by:.*",
29        r")\s*$",
30    ))
31    .expect("attribution line pattern")
32});
33
34static ATTRIBUTION_INLINE: LazyLock<Regex> = LazyLock::new(|| {
35    Regex::new(concat!(
36        r"(?i)\b(?:",
37        r"generated (?:with|by) (?:claude|codex|openai|chatgpt|ai)",
38        r"|(?:written|authored|created) (?:with|by) (?:claude|codex|chatgpt|ai)",
39        r"|with the help of (?:claude|codex|chatgpt|ai)",
40        r"|using (?:claude code|codex|chatgpt)",
41        r"|ai[- ]generated",
42        r"|as an ai\b",
43        r")",
44    ))
45    .expect("attribution inline pattern")
46});
47
48static DASH_RUN: LazyLock<Regex> =
49    LazyLock::new(|| Regex::new(&format!(r"[ \t]*{DASHES}[ \t]*")).expect("dash pattern"));
50
51static ANY_DASH: LazyLock<Regex> = LazyLock::new(|| Regex::new(DASHES).expect("dash class"));
52
53static TRAILING_SPACE: LazyLock<Regex> =
54    LazyLock::new(|| Regex::new(r"(?m)[ \t]+$").expect("trailing space pattern"));
55
56static BLANK_RUN: LazyLock<Regex> =
57    LazyLock::new(|| Regex::new(r"\n{3,}").expect("blank run pattern"));
58
59static HEADING: LazyLock<Regex> =
60    LazyLock::new(|| Regex::new(r"^\s{0,3}#{1,6}\s+\S").expect("heading pattern"));
61
62/// Headings that only announce that a body follows. Dropping them costs the
63/// reader nothing and saves them a line.
64static NOISE_HEADING: LazyLock<Regex> = LazyLock::new(|| {
65    Regex::new(
66        r"(?i)^\s{0,3}#{1,6}\s*(summary|description|overview|context|details?|background)\s*:?\s*$",
67    )
68    .expect("noise heading pattern")
69});
70
71/// Everything the two gates need to know. Mirrors the `[style]` config block.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct Style {
74    pub ban_em_dash: bool,
75    pub ban_ai_attribution: bool,
76    /// Enforce the length budgets below. Off means model prose passes through
77    /// at whatever length it arrived at.
78    pub terse: bool,
79    /// A finding's explanatory detail, as shown in the PR thread.
80    pub max_detail_chars: usize,
81    /// A one-line verdict or disposition summary.
82    pub max_summary_chars: usize,
83    /// A PR body.
84    pub max_body_chars: usize,
85    /// A filed issue's body.
86    pub max_issue_body_chars: usize,
87    /// A finding title, issue title, or PR title.
88    pub max_title_chars: usize,
89    /// How much of its own working spar narrates into a pull request thread.
90    pub pr_comments: crate::config::PrComments,
91}
92
93impl Default for Style {
94    fn default() -> Self {
95        Self {
96            ban_em_dash: true,
97            ban_ai_attribution: true,
98            terse: true,
99            max_detail_chars: 320,
100            max_summary_chars: 200,
101            max_body_chars: 900,
102            max_issue_body_chars: 4000,
103            max_title_chars: 90,
104            pr_comments: crate::config::PrComments::Outcome,
105        }
106    }
107}
108
109impl Style {
110    /// Style rules only, no length budgets. Used for text spar composed itself
111    /// and has already sized.
112    pub fn permissive() -> Self {
113        Self {
114            terse: false,
115            ..Self::default()
116        }
117    }
118}
119
120// ---------------------------------------------------------------------------
121// Style gate
122// ---------------------------------------------------------------------------
123
124/// Remove banned style artifacts. Idempotent: scrubbing scrubbed text is a
125/// no-op, which matters because text passes through here more than once.
126pub fn scrub(text: &str, style: &Style) -> String {
127    if text.is_empty() {
128        return String::new();
129    }
130    let mut out = text.to_string();
131
132    if style.ban_ai_attribution {
133        out = ATTRIBUTION_LINE.replace_all(&out, "").into_owned();
134        out = ATTRIBUTION_INLINE.replace_all(&out, "").into_owned();
135        out = out.replace('\u{1F916}', "");
136    }
137
138    if style.ban_em_dash {
139        // "a - b" becomes "a, b". Bounded to spaces and tabs so a dash at the
140        // end of a line joins two lines with a comma instead of swallowing the
141        // paragraph break after it.
142        out = DASH_RUN.replace_all(&out, ", ").into_owned();
143    }
144
145    out = TRAILING_SPACE.replace_all(&out, "").into_owned();
146    out = BLANK_RUN.replace_all(&out, "\n\n").into_owned();
147    out.trim().to_string()
148}
149
150/// Anything the scrub should have caught. Used as a post-check, so that a
151/// pattern the scrub cannot fix becomes a loud failure rather than a leak.
152pub fn violations(text: &str, style: &Style) -> Vec<String> {
153    let mut bad = Vec::new();
154    if style.ban_em_dash && ANY_DASH.is_match(text) {
155        bad.push("em/en dash present".to_string());
156    }
157    if style.ban_ai_attribution
158        && (ATTRIBUTION_LINE.is_match(text) || ATTRIBUTION_INLINE.is_match(text))
159    {
160        bad.push("AI attribution present".to_string());
161    }
162    bad
163}
164
165// ---------------------------------------------------------------------------
166// Concision gate
167// ---------------------------------------------------------------------------
168
169/// Collapse to a single line of single-spaced words.
170///
171/// For a field that is displayed inline, such as a finding title or a one-line
172/// verdict. A model that returns a paragraph there would otherwise break the
173/// layout of everything around it.
174pub fn one_line(text: &str) -> String {
175    text.split_whitespace().collect::<Vec<_>>().join(" ")
176}
177
178/// Truncate to `max` characters, preferring a sentence boundary.
179///
180/// Cutting mid-sentence and marking it with an ellipsis is a last resort: a
181/// clipped finding still has to be actionable, and the first sentence of a
182/// review comment almost always is.
183pub fn clip(text: &str, max: usize) -> String {
184    let trimmed = text.trim();
185    if max == 0 {
186        return trimmed.to_string();
187    }
188    let chars: Vec<char> = trimmed.chars().collect();
189    if chars.len() <= max {
190        return trimmed.to_string();
191    }
192
193    let window = &chars[..max];
194
195    // The last sentence end inside the budget, if it keeps enough of the text
196    // to still be worth reading.
197    let mut sentence_end = None;
198    for (i, c) in window.iter().enumerate() {
199        // Look at the real next character, not `window`'s. A period landing on
200        // the last budget character has text after it; treating the end of the
201        // window as the end of a sentence cuts mid-path with no ellipsis, so
202        // "src/repo.rs:412" is silently served as "src/repo." and reads as
203        // finished prose.
204        if matches!(c, '.' | '!' | '?') && chars.get(i + 1).is_none_or(|n| n.is_whitespace()) {
205            sentence_end = Some(i + 1);
206        }
207    }
208    if let Some(cut) = sentence_end {
209        if cut * 2 >= max {
210            return window[..cut]
211                .iter()
212                .collect::<String>()
213                .trim_end()
214                .to_string();
215        }
216    }
217
218    // Otherwise the last word boundary, marked so the reader knows there is
219    // more where this came from. The mark is inside the budget, never added to
220    // it: a caller that asked for at most N characters gets at most N.
221    const MARK: &str = "...";
222    if max <= MARK.len() {
223        return window.iter().collect::<String>().trim_end().to_string();
224    }
225    let budget = max - MARK.len();
226    let mut end = budget;
227    while end > 0 && !window[end - 1].is_whitespace() {
228        end -= 1;
229    }
230    if end == 0 {
231        end = budget;
232    }
233    let mut out: String = window[..end]
234        .iter()
235        .collect::<String>()
236        .trim_end()
237        .to_string();
238    out.push_str(MARK);
239    out
240}
241
242/// Drop a heading whose section holds nothing, and the bare "## Summary" style
243/// heading that only announces the body underneath it.
244pub fn strip_empty_sections(text: &str) -> String {
245    let lines: Vec<&str> = text.lines().collect();
246    let mut keep: Vec<&str> = Vec::with_capacity(lines.len());
247
248    let mut i = 0;
249    while i < lines.len() {
250        let line = lines[i];
251        if HEADING.is_match(line) {
252            // Everything up to the next heading is this section's body.
253            let mut j = i + 1;
254            while j < lines.len() && !HEADING.is_match(lines[j]) {
255                j += 1;
256            }
257            let body_is_empty = lines[i + 1..j].iter().all(|l| l.trim().is_empty());
258            let only_heading = lines.iter().filter(|l| HEADING.is_match(l)).count() == 1;
259
260            if body_is_empty {
261                i = j; // drop the heading and the blank lines under it
262                continue;
263            }
264            if only_heading && NOISE_HEADING.is_match(line) {
265                i += 1; // drop the label, keep the body
266                continue;
267            }
268        }
269        keep.push(line);
270        i += 1;
271    }
272
273    let joined = keep.join("\n");
274    BLANK_RUN.replace_all(&joined, "\n\n").trim().to_string()
275}
276
277/// The full outbound treatment for a block of model prose: strip structural
278/// noise, then clip to a budget.
279pub fn tighten(text: &str, max: usize, style: &Style) -> String {
280    if !style.terse {
281        return text.trim().to_string();
282    }
283    clip(&strip_empty_sections(text), max)
284}
285
286/// A filed issue's body.
287///
288/// An issue is a work item. Somebody picks it up cold, possibly months later,
289/// with none of the context the pull request thread had, so the rules that keep
290/// a comment short are the wrong rules here. Two things follow.
291///
292/// A fenced code block is never truncated and never counts against the budget.
293/// A snippet cut in half is worse than useless: it is broken markdown and a
294/// misleading fragment of code. Steps to reproduce, a stack trace, the offending
295/// function: those are the reason the issue is worth filing at all.
296///
297/// And when prose does have to be dropped, whole blocks go from the end rather
298/// than a sentence being cut mid-word. What survives is complete.
299pub fn issue_body(text: &str, style: &Style) -> String {
300    if !style.terse {
301        return text.trim().to_string();
302    }
303    let cleaned = strip_empty_sections(text);
304    let blocks = split_blocks(&cleaned);
305
306    // A runaway model pasting an entire file is still worth stopping, so code
307    // is exempt from the prose budget but not from a far looser ceiling.
308    let ceiling = style.max_issue_body_chars.saturating_mul(4);
309
310    let mut kept: Vec<&Block> = Vec::new();
311    let mut prose = 0usize;
312    let mut total = 0usize;
313    for block in &blocks {
314        let len = block.text.chars().count();
315        let over_prose = !block.code && prose + len > style.max_issue_body_chars;
316        let over_ceiling = total + len > ceiling;
317        if (over_prose || over_ceiling) && !kept.is_empty() {
318            break;
319        }
320        if !block.code {
321            prose += len;
322        }
323        total += len;
324        kept.push(block);
325    }
326
327    kept.iter()
328        .map(|b| b.text.as_str())
329        .collect::<Vec<_>>()
330        .join("\n\n")
331        .trim()
332        .to_string()
333}
334
335struct Block {
336    text: String,
337    code: bool,
338}
339
340/// Split into paragraphs, keeping every fenced code block whole however many
341/// blank lines it contains.
342fn split_blocks(text: &str) -> Vec<Block> {
343    let mut blocks = Vec::new();
344    let mut current: Vec<&str> = Vec::new();
345    let mut in_fence = false;
346    let mut fence_block = false;
347
348    let flush = |lines: &mut Vec<&str>, code: bool, out: &mut Vec<Block>| {
349        let joined = lines.join("\n");
350        if !joined.trim().is_empty() {
351            out.push(Block {
352                text: joined.trim_end().to_string(),
353                code,
354            });
355        }
356        lines.clear();
357    };
358
359    for line in text.lines() {
360        let fence = line.trim_start().starts_with("```");
361        if fence {
362            if in_fence {
363                current.push(line);
364                in_fence = false;
365                flush(&mut current, true, &mut blocks);
366                fence_block = false;
367                continue;
368            }
369            // A fence starts here, so whatever came before is its own block.
370            flush(&mut current, false, &mut blocks);
371            in_fence = true;
372            fence_block = true;
373            current.push(line);
374            continue;
375        }
376        if in_fence {
377            current.push(line);
378            continue;
379        }
380        if line.trim().is_empty() {
381            flush(&mut current, false, &mut blocks);
382        } else {
383            current.push(line);
384        }
385    }
386    // An unterminated fence is still kept whole rather than split.
387    flush(&mut current, fence_block, &mut blocks);
388    blocks
389}
390
391/// A finding title, issue title, or PR title: always one line, always short.
392pub fn title(text: &str, style: &Style) -> String {
393    let flat = one_line(text);
394    if style.terse {
395        clip(&flat, style.max_title_chars)
396    } else {
397        flat
398    }
399}
400
401/// Capitalise the first letter, so a model's fragment reads as a sentence when
402/// spar sets it after one of its own.
403pub fn sentence(text: &str, style: &Style) -> String {
404    let one = summary(text, style);
405    let mut chars = one.chars();
406    match chars.next() {
407        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
408        None => one,
409    }
410}
411
412/// A one-sentence verdict or disposition reason.
413pub fn summary(text: &str, style: &Style) -> String {
414    let flat = one_line(text);
415    if style.terse {
416        clip(&flat, style.max_summary_chars)
417    } else {
418        flat
419    }
420}
421
422/// A finding's explanation, as it appears in the PR thread. Kept on one line so
423/// a bullet stays a bullet.
424pub fn detail(text: &str, style: &Style) -> String {
425    let flat = one_line(text);
426    if style.terse {
427        clip(&flat, style.max_detail_chars)
428    } else {
429        flat
430    }
431}
432
433/// An issue or PR body. Multi-line is fine here; bloat is not.
434pub fn body(text: &str, style: &Style) -> String {
435    tighten(text, style.max_body_chars, style)
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    fn s() -> Style {
443        Style::default()
444    }
445
446    // -- style gate ------------------------------------------------------
447
448    #[test]
449    fn em_dash_removed() {
450        let out = scrub(
451            "Fix the parser, it was broken \u{2014} badly \u{2014} on empty input.",
452            &s(),
453        );
454        assert!(!out.contains('\u{2014}'));
455        assert!(violations(&out, &s()).is_empty());
456    }
457
458    #[test]
459    fn en_dash_removed() {
460        assert!(!scrub("range 1 \u{2013} 5", &s()).contains('\u{2013}'));
461    }
462
463    #[test]
464    fn horizontal_bar_removed() {
465        assert!(violations(&scrub("a \u{2015} b", &s()), &s()).is_empty());
466    }
467
468    #[test]
469    fn coauthor_trailer_stripped() {
470        let out = scrub(
471            "Add retry logic\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n",
472            &s(),
473        );
474        assert!(!out.contains("Co-Authored-By"));
475        assert!(out.contains("Add retry logic"));
476    }
477
478    #[test]
479    fn generated_with_footer_stripped() {
480        let out = scrub(
481            "Fix bug\n\n\u{1F916} Generated with [Claude Code](https://claude.com)\n",
482            &s(),
483        );
484        assert!(violations(&out, &s()).is_empty(), "{out}");
485        assert!(out.contains("Fix bug"));
486    }
487
488    #[test]
489    fn inline_attribution_stripped() {
490        let out = scrub("This patch was written by Claude to fix the leak.", &s());
491        assert!(violations(&out, &s()).is_empty(), "{out}");
492    }
493
494    #[test]
495    fn scrub_is_idempotent() {
496        let once = scrub("A \u{2014} B\n\nCo-Authored-By: Codex <x@y.z>", &s());
497        assert_eq!(once, scrub(&once, &s()));
498    }
499
500    #[test]
501    fn violations_detected_before_scrub() {
502        assert!(!violations("a \u{2014} b", &s()).is_empty());
503        assert!(!violations("Co-Authored-By: Claude <a@b.c>", &s()).is_empty());
504    }
505
506    #[test]
507    fn legitimate_prose_survives() {
508        let out = scrub("Refactor the AI-facing endpoint handler for clarity.", &s());
509        assert!(out.contains("endpoint handler"), "{out}");
510    }
511
512    #[test]
513    fn disabled_rules_are_respected() {
514        let off = Style {
515            ban_em_dash: false,
516            ban_ai_attribution: false,
517            ..s()
518        };
519        let text = "a \u{2014} b\nCo-Authored-By: Claude <x@y.z>";
520        assert!(scrub(text, &off).contains('\u{2014}'));
521        assert!(violations(text, &off).is_empty());
522    }
523
524    #[test]
525    fn dash_at_end_of_line_does_not_swallow_the_paragraph_break() {
526        let out = scrub("first line \u{2014}\n\nsecond paragraph", &s());
527        assert!(out.contains("\n\n"), "{out:?}");
528    }
529
530    #[test]
531    fn empty_input_is_empty_output() {
532        assert_eq!("", scrub("", &s()));
533    }
534
535    // -- concision gate --------------------------------------------------
536
537    #[test]
538    fn one_line_flattens() {
539        assert_eq!("a b c", one_line("  a\n\n b\t c  "));
540    }
541
542    #[test]
543    fn clip_leaves_short_text_alone() {
544        assert_eq!("short", clip("short", 40));
545    }
546
547    #[test]
548    fn clip_prefers_a_sentence_boundary() {
549        let text = "The loop never terminates. It also leaks a file descriptor on every pass.";
550        assert_eq!("The loop never terminates.", clip(text, 40));
551    }
552
553    #[test]
554    fn clip_falls_back_to_a_word_boundary() {
555        let out = clip("supercalifragilistic wording that runs on and on", 25);
556        assert!(out.ends_with("..."), "{out}");
557        assert!(out.chars().count() <= 25, "{out}");
558        assert!(!out.contains("wording that runs"), "{out}");
559    }
560
561    #[test]
562    fn clip_never_exceeds_the_budget() {
563        for max in 1..60 {
564            let out = clip("one two three four five six seven eight nine ten.", max);
565            assert!(out.chars().count() <= max, "max={max} out={out:?}");
566        }
567    }
568
569    #[test]
570    fn clip_handles_multibyte_text() {
571        let out = clip(&"\u{1f600}".repeat(50), 10);
572        assert!(out.chars().count() <= 10, "{out}");
573    }
574
575    /// The sentence-end scan used to look at the last character of the *budget*
576    /// rather than of the *text*, so a period landing exactly on the boundary
577    /// read as the end of a sentence. The result came back with no ellipsis, so
578    /// a truncated file path looked like finished prose.
579    #[test]
580    fn a_period_on_the_budget_boundary_is_not_a_sentence_end() {
581        assert_ne!(
582            "Version 1.",
583            clip("Version 1.4 of the parser mishandles input", 10)
584        );
585        assert_ne!(
586            "Panic in src/style.",
587            clip("Panic in src/style.rs when the budget lands mid word", 19)
588        );
589    }
590
591    #[test]
592    fn an_unmarked_clip_really_did_end_a_sentence() {
593        // The only way to come back without an ellipsis is to stop where the
594        // author stopped.
595        for max in 4..80 {
596            let text = "First sentence here. Second one follows it. Third trails off";
597            let out = clip(text, max);
598            if out.len() < text.len() && !out.ends_with("...") {
599                assert!(
600                    out.ends_with('.') || out.ends_with('!') || out.ends_with('?'),
601                    "max={max} out={out:?}"
602                );
603                let next = text[out.len()..].chars().next();
604                assert!(
605                    next.is_none_or(|c| c.is_whitespace()),
606                    "max={max} cut mid-token before {next:?}: {out:?}"
607                );
608            }
609        }
610    }
611
612    #[test]
613    fn clip_ignores_a_decimal_point_as_a_sentence_end() {
614        let text = "Version 1.4 of the parser mishandles empty input badly and loops.";
615        assert_ne!("Version 1.", clip(text, 30));
616    }
617
618    #[test]
619    fn empty_sections_are_dropped() {
620        let out = strip_empty_sections("## Context\n\n## Proposal\n\nDo the thing.\n");
621        assert!(!out.contains("Context"), "{out}");
622        assert!(out.contains("Do the thing."), "{out}");
623    }
624
625    #[test]
626    fn a_lone_label_heading_is_dropped() {
627        assert_eq!(
628            "The retry never fires.",
629            strip_empty_sections("## Summary\n\nThe retry never fires.")
630        );
631    }
632
633    #[test]
634    fn real_headings_survive_when_there_are_several() {
635        let text = "## Summary\n\nA thing.\n\n## Repro\n\nRun it.";
636        let out = strip_empty_sections(text);
637        assert!(
638            out.contains("## Summary") && out.contains("## Repro"),
639            "{out}"
640        );
641    }
642
643    #[test]
644    fn terse_off_leaves_length_alone() {
645        let loose = Style {
646            terse: false,
647            ..s()
648        };
649        let long = "word ".repeat(400);
650        assert_eq!(long.trim(), detail(&long, &loose));
651    }
652
653    #[test]
654    fn detail_is_capped_and_single_line() {
655        let out = detail(
656            &format!("first line\nsecond line\n{}", "filler ".repeat(200)),
657            &s(),
658        );
659        assert!(!out.contains('\n'));
660        assert!(out.chars().count() <= s().max_detail_chars);
661    }
662
663    #[test]
664    fn title_is_capped_and_single_line() {
665        let out = title(
666            "a very\nlong\ttitle that keeps going ".repeat(20).as_str(),
667            &s(),
668        );
669        assert!(!out.contains('\n'));
670        assert!(out.chars().count() <= s().max_title_chars);
671    }
672
673    #[test]
674    fn body_keeps_structure_but_bounds_length() {
675        let text = format!(
676            "## Summary\n\nreal content here.\n\n{}",
677            "more prose. ".repeat(300)
678        );
679        let out = body(&text, &s());
680        assert!(
681            out.chars().count() <= s().max_body_chars,
682            "{}",
683            out.chars().count()
684        );
685        assert!(out.contains("real content here"), "{out}");
686    }
687}
688
689#[cfg(test)]
690mod sentence_tests {
691    use super::*;
692
693    #[test]
694    fn a_fragment_reads_as_a_sentence() {
695        assert_eq!(
696            "The caller already validates it.",
697            sentence("the caller already validates it.", &Style::default())
698        );
699    }
700
701    #[test]
702    fn an_already_capitalised_one_is_untouched() {
703        assert_eq!(
704            "Already fine.",
705            sentence("Already fine.", &Style::default())
706        );
707    }
708
709    #[test]
710    fn empty_stays_empty_rather_than_panicking() {
711        assert_eq!("", sentence("   ", &Style::default()));
712    }
713
714    #[test]
715    fn a_multibyte_first_character_does_not_panic() {
716        assert_eq!("Ärger", sentence("ärger", &Style::default()));
717    }
718}
719
720#[cfg(test)]
721mod issue_body_tests {
722    use super::*;
723
724    fn s() -> Style {
725        Style::default()
726    }
727
728    fn fences(text: &str) -> usize {
729        text.lines()
730            .filter(|l| l.trim_start().starts_with("```"))
731            .count()
732    }
733
734    /// The whole point. A snippet cut in half is broken markdown and a
735    /// misleading fragment of the code somebody is being asked to fix.
736    #[test]
737    fn a_code_block_is_never_truncated() {
738        let code = (0..400)
739            .map(|n| format!("    line_{n}();"))
740            .collect::<Vec<_>>()
741            .join("\n");
742        let text = format!("It spins forever.\n\n```rust\n{code}\n```\n\nThat is the loop.");
743        let out = issue_body(&text, &s());
744
745        assert!(
746            out.contains("line_0();") && out.contains("line_399();"),
747            "the block was cut"
748        );
749        assert_eq!(0, fences(&out) % 2, "left an unclosed fence:\n{out}");
750    }
751
752    /// Why there are two functions rather than one budget. The comment path
753    /// cuts wherever the character count runs out, which on a snippet means an
754    /// unclosed fence and a misleading half of the code.
755    #[test]
756    fn the_comment_budget_would_have_mangled_the_same_snippet() {
757        let code = (0..400)
758            .map(|n| format!("    line_{n}();"))
759            .collect::<Vec<_>>()
760            .join("\n");
761        let text = format!("It spins forever.\n\n```rust\n{code}\n```");
762
763        let as_comment = body(&text, &s());
764        assert_ne!(0, fences(&as_comment) % 2, "a comment cuts the fence open");
765
766        let as_issue = issue_body(&text, &s());
767        assert_eq!(0, fences(&as_issue) % 2, "an issue keeps it closed");
768    }
769
770    /// Code is exempt from the budget, so a long snippet cannot squeeze out the
771    /// prose that explains it.
772    #[test]
773    fn a_long_snippet_does_not_evict_the_explanation() {
774        let code = "x();\n".repeat(1500);
775        let text = format!(
776            "Reproduction:\n\n1. Call connect twice.\n2. Watch the retry count.\n\n```\n{code}```\n\nSuggested fix: bound the loop."
777        );
778        let out = issue_body(&text, &s());
779        assert!(out.contains("Call connect twice"), "{out}");
780        assert!(out.contains("Suggested fix"), "the tail survived");
781    }
782
783    #[test]
784    fn steps_to_reproduce_survive_intact() {
785        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.";
786        assert_eq!(text, issue_body(text, &s()));
787    }
788
789    #[test]
790    fn a_body_within_budget_is_untouched() {
791        let text = "One paragraph.\n\nAnd another.";
792        assert_eq!(text, issue_body(text, &s()));
793    }
794
795    /// When prose does have to go, whole blocks go from the end. Nothing is cut
796    /// mid-sentence and nothing gains an ellipsis.
797    #[test]
798    fn overlong_prose_drops_whole_blocks_from_the_end() {
799        let para = |n: usize| format!("Paragraph {n}. {}", "filler words here. ".repeat(30));
800        let text = (0..20).map(para).collect::<Vec<_>>().join("\n\n");
801        let out = issue_body(&text, &s());
802
803        assert!(out.starts_with("Paragraph 0."), "{out}");
804        assert!(!out.contains("..."), "no mid-sentence cut: {out}");
805        assert!(
806            out.trim_end().ends_with('.'),
807            "ends on a complete block: {out}"
808        );
809        assert!(
810            out.chars().count() <= s().max_issue_body_chars + 400,
811            "{}",
812            out.chars().count()
813        );
814    }
815
816    /// An issue gets far more room than a pull request comment, because it is
817    /// read cold by somebody with none of the context.
818    #[test]
819    fn an_issue_gets_much_more_room_than_a_comment() {
820        let text = "word ".repeat(500);
821        assert!(issue_body(&text, &s()).len() > body(&text, &s()).len() * 2);
822    }
823
824    #[test]
825    fn a_single_block_over_budget_is_kept_rather_than_mangled() {
826        let text = format!("```\n{}\n```", "y();\n".repeat(3000));
827        let out = issue_body(&text, &s());
828        assert!(!out.is_empty());
829        assert_eq!(0, fences(&out) % 2, "{}", &out[..80.min(out.len())]);
830    }
831
832    #[test]
833    fn an_unterminated_fence_is_still_kept_whole() {
834        let text = "Here is the code:\n\n```rust\nfn broken() {\n    loop {}";
835        let out = issue_body(text, &s());
836        assert!(out.contains("fn broken()"), "{out}");
837    }
838
839    #[test]
840    fn terse_off_leaves_an_issue_body_completely_alone() {
841        let loose = Style {
842            terse: false,
843            ..s()
844        };
845        let text = "a".repeat(50_000);
846        assert_eq!(text, issue_body(&text, &loose));
847    }
848
849    #[test]
850    fn issue_body_is_idempotent() {
851        let text = format!(
852            "Explanation.\n\n```\n{}\n```\n\n{}",
853            "z();\n".repeat(50),
854            "more prose. ".repeat(600)
855        );
856        let once = issue_body(&text, &s());
857        assert_eq!(once, issue_body(&once, &s()));
858    }
859}