1use std::sync::LazyLock;
22
23use regex::Regex;
24
25const DASHES: &str = r"[\x{2011}-\x{2015}\x{2E3A}\x{2E3B}]";
31
32const VENDORS: &str = concat!(
39 r"claude|anthropic|codex|openai|chatgpt|gpt[-\s]?[0-9o]|copilot|cursor",
40 r"|gemini|aider|devin|llama|an? ai\b|\bais?\b",
41);
42
43static ATTRIBUTION_LINE: LazyLock<Regex> = LazyLock::new(|| {
48 Regex::new(&format!(
49 concat!(
50 r"(?im)^\s*(?:[>*-]\s*)?(?:",
51 r"(?:co[-\s]?authored|signed[-\s]?off|assisted|generated|authored|written|created)",
52 r"[-\s]?by\s*:.*(?:{vendors}).*",
53 r"|\x{{1F916}}?\s*generated (?:with|by) .*",
54 r")\s*$",
55 ),
56 vendors = VENDORS
57 ))
58 .expect("attribution line pattern")
59});
60
61static ATTRIBUTION_INLINE: LazyLock<Regex> = LazyLock::new(|| {
67 Regex::new(&format!(
68 concat!(
69 r"(?i)\s*\x{{1F916}}?\s*\b(?:",
70 r"(?:generated|written|authored|created|made|built)\s+(?:with|by)",
71 r"|with the help of|using)\s+",
72 r"(?:the\s+help\s+of\s+)?[\[\(*_\x22'`]*",
73 r"[A-Za-z0-9.'\x2D\s]{{0,24}}?(?:{vendors})",
74 r"[A-Za-z0-9 ]{{0,16}}?[\]\)*_`]*",
75 r"(?:\([^)\n]{{0,160}}\))?",
76 r"|(?i)\bas an ai\b",
77 ),
78 vendors = VENDORS
79 ))
80 .expect("attribution inline pattern")
81});
82
83static ATTRIBUTION_RESIDUE: LazyLock<Regex> = LazyLock::new(|| {
91 Regex::new(concat!(
92 r"(?i)noreply@(?:anthropic|openai)\.com",
93 r"|\x{1F916}",
94 ))
95 .expect("attribution residue pattern")
96});
97
98static DASH_RUN: LazyLock<Regex> =
99 LazyLock::new(|| Regex::new(&format!(r"[ \t]*{DASHES}[ \t]*")).expect("dash pattern"));
100
101static ANY_DASH: LazyLock<Regex> = LazyLock::new(|| Regex::new(DASHES).expect("dash class"));
102
103static TRAILING_SPACE: LazyLock<Regex> =
104 LazyLock::new(|| Regex::new(r"(?m)[ \t]+$").expect("trailing space pattern"));
105
106static BLANK_RUN: LazyLock<Regex> =
107 LazyLock::new(|| Regex::new(r"\n{3,}").expect("blank run pattern"));
108
109static HEADING: LazyLock<Regex> =
110 LazyLock::new(|| Regex::new(r"^\s{0,3}#{1,6}\s+\S").expect("heading pattern"));
111
112static NOISE_HEADING: LazyLock<Regex> = LazyLock::new(|| {
115 Regex::new(
116 r"(?i)^\s{0,3}#{1,6}\s*(summary|description|overview|context|details?|background)\s*:?\s*$",
117 )
118 .expect("noise heading pattern")
119});
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct Style {
124 pub ban_em_dash: bool,
125 pub ban_ai_attribution: bool,
126 pub terse: bool,
129 pub max_detail_chars: usize,
131 pub max_summary_chars: usize,
133 pub max_body_chars: usize,
135 pub max_issue_body_chars: usize,
137 pub max_title_chars: usize,
139 pub pr_comments: crate::config::PrComments,
141}
142
143impl Default for Style {
144 fn default() -> Self {
145 Self {
146 ban_em_dash: true,
147 ban_ai_attribution: true,
148 terse: true,
149 max_detail_chars: 6000,
150 max_summary_chars: 2000,
151 max_body_chars: 8000,
152 max_issue_body_chars: 20000,
153 max_title_chars: 140,
154 pr_comments: crate::config::PrComments::Outcome,
155 }
156 }
157}
158
159impl Style {
160 pub fn permissive() -> Self {
163 Self {
164 terse: false,
165 ..Self::default()
166 }
167 }
168}
169
170pub fn scrub(text: &str, style: &Style) -> String {
177 if text.is_empty() {
178 return String::new();
179 }
180 let mut out = text.to_string();
181
182 if style.ban_ai_attribution {
183 out = ATTRIBUTION_LINE.replace_all(&out, "").into_owned();
184 out = outside_code(&out, |part| {
185 ATTRIBUTION_INLINE.replace_all(part, "").into_owned()
186 });
187 out = out.replace('\u{1F916}', "");
188 }
189
190 if style.ban_em_dash {
191 out = outside_code(&out, |part| DASH_RUN.replace_all(part, ", ").into_owned());
199 }
200
201 out = TRAILING_SPACE.replace_all(&out, "").into_owned();
202 out = BLANK_RUN.replace_all(&out, "\n\n").into_owned();
203 out.trim().to_string()
204}
205
206pub fn violations(text: &str, style: &Style) -> Vec<String> {
209 let mut bad = Vec::new();
210 if style.ban_em_dash && ANY_DASH.is_match(&without_code(text)) {
211 bad.push("em/en dash present".to_string());
212 }
213 if style.ban_ai_attribution
214 && (ATTRIBUTION_LINE.is_match(text)
215 || ATTRIBUTION_INLINE.is_match(&without_code(text))
216 || ATTRIBUTION_RESIDUE.is_match(text))
217 {
218 bad.push("AI attribution present".to_string());
219 }
220 bad
221}
222
223fn outside_code(text: &str, edit: impl Fn(&str) -> String) -> String {
229 let mut out = String::with_capacity(text.len());
230 let mut last = 0;
231 for (start, end) in code_regions(text) {
232 out.push_str(&edit(&text[last..start]));
233 out.push_str(&text[start..end]);
234 last = end;
235 }
236 out.push_str(&edit(&text[last..]));
237 out
238}
239
240fn without_code(text: &str) -> String {
242 let mut out = String::with_capacity(text.len());
243 let mut last = 0;
244 for (start, end) in code_regions(text) {
245 out.push_str(&text[last..start]);
246 out.extend(
248 text[start..end]
249 .chars()
250 .map(|c| if c == '\n' { '\n' } else { ' ' }),
251 );
252 last = end;
253 }
254 out.push_str(&text[last..]);
255 out
256}
257
258fn code_regions(text: &str) -> Vec<(usize, usize)> {
265 let bytes = text.as_bytes();
266 let mut regions = Vec::new();
267 let mut i = 0;
268 let mut at_line_start = true;
269 while i < bytes.len() {
270 let byte = bytes[i];
271 if byte == b'\n' {
272 at_line_start = true;
273 i += 1;
274 continue;
275 }
276 if byte != b'`' {
277 if !byte.is_ascii_whitespace() {
278 at_line_start = false;
279 }
280 i += 1;
281 continue;
282 }
283 let run = bytes[i..].iter().take_while(|b| **b == b'`').count();
284 if at_line_start && run >= 3 {
285 let after = i + run;
287 let end = find_closing_fence(text, after, run).unwrap_or(text.len());
288 regions.push((i, end));
289 i = end;
290 at_line_start = false;
291 continue;
292 }
293 let after = i + run;
295 let end = find_closing_ticks(bytes, after, run).unwrap_or(text.len());
296 regions.push((i, end));
297 i = end;
298 at_line_start = false;
299 }
300 regions
301}
302
303fn find_closing_fence(text: &str, from: usize, run: usize) -> Option<usize> {
304 let fence = "`".repeat(run);
305 let mut at = from;
306 while let Some(found) = text[at..].find(&fence) {
307 let start = at + found;
308 let line_start = text[..start].rfind('\n').map(|n| n + 1).unwrap_or(0);
309 if text[line_start..start].trim().is_empty() {
310 let end = start + fence.len();
311 return Some(
312 text[end..]
313 .find('\n')
314 .map(|n| end + n + 1)
315 .unwrap_or(text.len()),
316 );
317 }
318 at = start + fence.len();
319 }
320 None
321}
322
323fn find_closing_ticks(bytes: &[u8], from: usize, run: usize) -> Option<usize> {
324 let mut i = from;
325 while i < bytes.len() {
326 match bytes[i] {
327 b'\n' => return None,
328 b'`' => {
329 let here = bytes[i..].iter().take_while(|b| **b == b'`').count();
330 if here == run {
331 return Some(i + here);
332 }
333 i += here;
334 }
335 _ => i += 1,
336 }
337 }
338 None
339}
340
341pub fn one_line(text: &str) -> String {
351 text.split_whitespace().collect::<Vec<_>>().join(" ")
352}
353
354const OVERSHOOT: usize = 240;
361
362pub fn clip(text: &str, max: usize) -> String {
364 clip_marked(text, max, "...")
365}
366
367pub fn clip_bare(text: &str, max: usize) -> String {
373 clip_marked(text, max, "")
374}
375
376fn clip_marked(text: &str, max: usize, marker: &str) -> String {
377 let trimmed = text.trim();
378 if max == 0 {
379 return trimmed.to_string();
380 }
381 let chars: Vec<char> = trimmed.chars().collect();
382 if chars.len() <= max {
383 return trimmed.to_string();
384 }
385
386 let ends_sentence = |i: usize| -> bool {
387 matches!(chars[i], '.' | '!' | '?')
388 && chars.get(i + 1).is_none_or(|n| n.is_whitespace())
392 };
393
394 let within = (0..max).rfind(|i| ends_sentence(*i));
397 if let Some(cut) = within {
398 if (cut + 1) * 2 >= max {
399 return chars[..=cut]
400 .iter()
401 .collect::<String>()
402 .trim_end()
403 .to_string();
404 }
405 }
406
407 let ceiling = (max + OVERSHOOT.min(max)).min(chars.len());
412 if let Some(cut) = (max..ceiling).find(|i| ends_sentence(*i)) {
413 return chars[..=cut]
414 .iter()
415 .collect::<String>()
416 .trim_end()
417 .to_string();
418 }
419
420 if let Some(cut) = within {
422 return chars[..=cut]
423 .iter()
424 .collect::<String>()
425 .trim_end()
426 .to_string();
427 }
428
429 let budget = max.saturating_sub(marker.chars().count()).max(1);
432 let mut end = budget.min(chars.len());
433 while end > 0 && !chars[end - 1].is_whitespace() {
434 end -= 1;
435 }
436 if end == 0 {
437 end = budget.min(chars.len());
438 }
439 let mut out: String = chars[..end]
440 .iter()
441 .collect::<String>()
442 .trim_end()
443 .to_string();
444 out.push_str(marker);
445 out
446}
447
448pub fn strip_empty_sections(text: &str) -> String {
451 let lines: Vec<&str> = text.lines().collect();
452 let mut keep: Vec<&str> = Vec::with_capacity(lines.len());
453
454 let mut i = 0;
455 while i < lines.len() {
456 let line = lines[i];
457 if HEADING.is_match(line) {
458 let mut j = i + 1;
460 while j < lines.len() && !HEADING.is_match(lines[j]) {
461 j += 1;
462 }
463 let body_is_empty = lines[i + 1..j].iter().all(|l| l.trim().is_empty());
464 let only_heading = lines.iter().filter(|l| HEADING.is_match(l)).count() == 1;
465
466 if body_is_empty {
467 i = j; continue;
469 }
470 if only_heading && NOISE_HEADING.is_match(line) {
471 i += 1; continue;
473 }
474 }
475 keep.push(line);
476 i += 1;
477 }
478
479 let joined = keep.join("\n");
480 BLANK_RUN.replace_all(&joined, "\n\n").trim().to_string()
481}
482
483pub fn tighten(text: &str, max: usize, style: &Style) -> String {
486 if !style.terse {
487 return text.trim().to_string();
488 }
489 clip(&strip_empty_sections(text), max)
490}
491
492pub fn issue_body(text: &str, style: &Style) -> String {
506 if !style.terse {
507 return text.trim().to_string();
508 }
509 let cleaned = strip_empty_sections(text);
510 let blocks = split_blocks(&cleaned);
511
512 let ceiling = style.max_issue_body_chars.saturating_mul(4);
515
516 let mut kept: Vec<String> = Vec::new();
517 let mut prose = 0usize;
518 let mut total = 0usize;
519 for block in &blocks {
520 let len = block.text.chars().count();
521 if !block.code && prose + len > style.max_issue_body_chars && !kept.is_empty() {
522 break;
523 }
524 if total + len > ceiling {
525 if block.code {
529 if let Some(short) = shorten_code(&block.text, ceiling.saturating_sub(total)) {
530 kept.push(short);
531 }
532 }
533 break;
534 }
535 if !block.code {
536 prose += len;
537 }
538 total += len;
539 kept.push(block.text.clone());
540 }
541
542 kept.join("\n\n").trim().to_string()
543}
544
545fn shorten_code(block: &str, room: usize) -> Option<String> {
551 const NOTE: &str = "(snippet shortened)";
552 if room < 80 {
553 return None;
554 }
555 let mut lines = block.lines();
556 let opener = lines.next()?.to_string();
557 let mut out = vec![opener];
558 let mut used = out[0].chars().count() + NOTE.len() + 8;
559
560 for line in lines {
561 if line.trim_start().starts_with("```") {
562 break;
563 }
564 let len = line.chars().count() + 1;
565 if used + len > room {
566 break;
567 }
568 used += len;
569 out.push(line.to_string());
570 }
571 out.push("```".to_string());
572 out.push(String::new());
573 out.push(NOTE.to_string());
574 Some(out.join("\n"))
575}
576
577struct Block {
578 text: String,
579 code: bool,
580}
581
582fn split_blocks(text: &str) -> Vec<Block> {
585 let mut blocks = Vec::new();
586 let mut current: Vec<&str> = Vec::new();
587 let mut in_fence = false;
588 let mut fence_block = false;
589
590 let flush = |lines: &mut Vec<&str>, code: bool, out: &mut Vec<Block>| {
591 let joined = lines.join("\n");
592 if !joined.trim().is_empty() {
593 out.push(Block {
594 text: joined.trim_end().to_string(),
595 code,
596 });
597 }
598 lines.clear();
599 };
600
601 for line in text.lines() {
602 let fence = line.trim_start().starts_with("```");
603 if fence {
604 if in_fence {
605 current.push(line);
606 in_fence = false;
607 flush(&mut current, true, &mut blocks);
608 fence_block = false;
609 continue;
610 }
611 flush(&mut current, false, &mut blocks);
613 in_fence = true;
614 fence_block = true;
615 current.push(line);
616 continue;
617 }
618 if in_fence {
619 current.push(line);
620 continue;
621 }
622 if line.trim().is_empty() {
623 flush(&mut current, false, &mut blocks);
624 } else {
625 current.push(line);
626 }
627 }
628 flush(&mut current, fence_block, &mut blocks);
630 blocks
631}
632
633pub fn title(text: &str, style: &Style) -> String {
635 let flat = one_line(text);
636 if style.terse {
637 clip_bare(&flat, style.max_title_chars)
638 } else {
639 flat
640 }
641}
642
643pub fn sentence(text: &str, style: &Style) -> String {
646 let one = summary(text, style);
647 let mut chars = one.chars();
648 match chars.next() {
649 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
650 None => one,
651 }
652}
653
654pub fn summary(text: &str, style: &Style) -> String {
656 let flat = one_line(text);
657 if style.terse {
658 clip(&flat, style.max_summary_chars)
659 } else {
660 flat
661 }
662}
663
664pub fn detail(text: &str, style: &Style) -> String {
667 let flat = one_line(text);
668 if style.terse {
669 clip(&flat, style.max_detail_chars)
670 } else {
671 flat
672 }
673}
674
675pub fn body(text: &str, style: &Style) -> String {
677 tighten(text, style.max_body_chars, style)
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683
684 fn s() -> Style {
685 Style::default()
686 }
687
688 #[test]
691 fn em_dash_removed() {
692 let out = scrub(
693 "Fix the parser, it was broken \u{2014} badly \u{2014} on empty input.",
694 &s(),
695 );
696 assert!(!out.contains('\u{2014}'));
697 assert!(violations(&out, &s()).is_empty());
698 }
699
700 #[test]
701 fn en_dash_removed() {
702 assert!(!scrub("range 1 \u{2013} 5", &s()).contains('\u{2013}'));
703 }
704
705 #[test]
706 fn horizontal_bar_removed() {
707 assert!(violations(&scrub("a \u{2015} b", &s()), &s()).is_empty());
708 }
709
710 #[test]
717 fn the_forms_that_used_to_pass_are_caught_now() {
718 for text in [
719 "Fix bug. \u{1F916} Generated with [Claude Code](https://claude.com)",
720 "Co-authored-by: Cursor Agent <cursoragent@cursor.com>",
721 "co-authored-by: GitHub Copilot",
722 "Signed-off-by: Claude <noreply@anthropic.com>",
723 "Made with Claude",
724 "Generated by Anthropic's Claude",
725 "Assisted-by: gpt-5",
726 ] {
727 assert!(
728 !violations(text, &s()).is_empty(),
729 "the gate did not see it: {text}"
730 );
731 let out = scrub(text, &s());
732 assert!(
733 violations(&out, &s()).is_empty(),
734 "the scrub could not clean it: {text} -> {out}"
735 );
736 }
737 }
738
739 #[test]
741 fn a_human_trailer_is_left_exactly_where_it_is() {
742 for text in [
743 "Co-authored-by: Alex Rivera <alex@example.com>",
744 "Signed-off-by: Corey Phillips <corey@example.com>",
745 "Reviewed-by: the release team",
746 ] {
747 assert_eq!(text, scrub(text, &s()), "a person's trailer was rewritten");
748 assert!(violations(text, &s()).is_empty(), "{text}");
749 }
750 }
751
752 #[test]
758 fn code_keeps_its_dashes_and_its_words() {
759 let text = "The label is wrong:\n\n```rust\nlet s = \"Save \u{2014} Draft\";\n```\n\nFix it \u{2014} soon.";
760 let out = scrub(text, &s());
761 assert!(
762 out.contains("\"Save \u{2014} Draft\""),
763 "the evidence was rewritten:\n{out}"
764 );
765 assert!(
766 out.contains("Fix it, soon."),
767 "prose was not scrubbed:\n{out}"
768 );
769 assert!(violations(&out, &s()).is_empty(), "{out}");
770
771 let inline = scrub("The literal `a \u{2014} b` is the bug \u{2014} here.", &s());
772 assert!(inline.contains("`a \u{2014} b`"), "{inline}");
773 assert!(inline.contains("bug, here."), "{inline}");
774 }
775
776 #[test]
778 fn ordinary_prose_about_ai_survives() {
779 for text in [
780 "Reject AI-generated spam in the comment filter",
781 "The AI generated output is cached",
782 ] {
783 assert_eq!(text, scrub(text, &s()), "prose was edited");
784 assert!(violations(text, &s()).is_empty(), "{text}");
785 }
786 }
787
788 #[test]
790 fn the_nonbreaking_hyphen_and_the_two_em_dash_are_dashes() {
791 for dash in ['\u{2011}', '\u{2E3A}', '\u{2E3B}'] {
792 let text = format!("a {dash} b");
793 assert!(
794 !violations(&text, &s()).is_empty(),
795 "{dash:?} passed the gate"
796 );
797 assert!(
798 !scrub(&text, &s()).contains(dash),
799 "{dash:?} survived the scrub"
800 );
801 }
802 }
803
804 #[test]
807 fn a_vendor_address_the_scrub_leaves_is_a_hard_error() {
808 let text = "See the discussion with noreply@anthropic.com about it";
809 assert!(!violations(text, &s()).is_empty(), "{text}");
810 }
811
812 #[test]
813 fn coauthor_trailer_stripped() {
814 let out = scrub(
815 "Add retry logic\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n",
816 &s(),
817 );
818 assert!(!out.contains("Co-Authored-By"));
819 assert!(out.contains("Add retry logic"));
820 }
821
822 #[test]
823 fn generated_with_footer_stripped() {
824 let out = scrub(
825 "Fix bug\n\n\u{1F916} Generated with [Claude Code](https://claude.com)\n",
826 &s(),
827 );
828 assert!(violations(&out, &s()).is_empty(), "{out}");
829 assert!(out.contains("Fix bug"));
830 }
831
832 #[test]
833 fn inline_attribution_stripped() {
834 let out = scrub("This patch was written by Claude to fix the leak.", &s());
835 assert!(violations(&out, &s()).is_empty(), "{out}");
836 }
837
838 #[test]
839 fn scrub_is_idempotent() {
840 let once = scrub("A \u{2014} B\n\nCo-Authored-By: Codex <x@y.z>", &s());
841 assert_eq!(once, scrub(&once, &s()));
842 }
843
844 #[test]
845 fn violations_detected_before_scrub() {
846 assert!(!violations("a \u{2014} b", &s()).is_empty());
847 assert!(!violations("Co-Authored-By: Claude <a@b.c>", &s()).is_empty());
848 }
849
850 #[test]
851 fn legitimate_prose_survives() {
852 let out = scrub("Refactor the AI-facing endpoint handler for clarity.", &s());
853 assert!(out.contains("endpoint handler"), "{out}");
854 }
855
856 #[test]
857 fn disabled_rules_are_respected() {
858 let off = Style {
859 ban_em_dash: false,
860 ban_ai_attribution: false,
861 ..s()
862 };
863 let text = "a \u{2014} b\nCo-Authored-By: Claude <x@y.z>";
864 assert!(scrub(text, &off).contains('\u{2014}'));
865 assert!(violations(text, &off).is_empty());
866 }
867
868 #[test]
869 fn dash_at_end_of_line_does_not_swallow_the_paragraph_break() {
870 let out = scrub("first line \u{2014}\n\nsecond paragraph", &s());
871 assert!(out.contains("\n\n"), "{out:?}");
872 }
873
874 #[test]
875 fn empty_input_is_empty_output() {
876 assert_eq!("", scrub("", &s()));
877 }
878
879 #[test]
882 fn one_line_flattens() {
883 assert_eq!("a b c", one_line(" a\n\n b\t c "));
884 }
885
886 #[test]
887 fn clip_leaves_short_text_alone() {
888 assert_eq!("short", clip("short", 40));
889 }
890
891 #[test]
892 fn clip_prefers_a_sentence_boundary() {
893 let text = "The loop never terminates. It also leaks a file descriptor on every pass.";
894 assert_eq!("The loop never terminates.", clip(text, 40));
895 }
896
897 #[test]
898 fn clip_falls_back_to_a_word_boundary() {
899 let out = clip("supercalifragilistic wording that runs on and on", 25);
900 assert!(out.ends_with("..."), "{out}");
901 assert!(out.chars().count() <= 25, "{out}");
902 assert!(!out.contains("wording that runs"), "{out}");
903 }
904
905 #[test]
906 fn clip_overshoots_only_within_bounds() {
910 for max in 1..60 {
911 let out = clip("one two three four five six seven eight nine ten.", max);
912 assert!(out.chars().count() <= max * 2 + 3, "max={max} out={out:?}");
913 }
914 }
915
916 #[test]
917 fn clip_handles_multibyte_text() {
918 let out = clip(&"\u{1f600}".repeat(50), 10);
919 assert!(out.chars().count() <= 10, "{out}");
920 }
921
922 #[test]
927 fn a_period_on_the_budget_boundary_is_not_a_sentence_end() {
928 assert_ne!(
929 "Version 1.",
930 clip("Version 1.4 of the parser mishandles input", 10)
931 );
932 assert_ne!(
933 "Panic in src/style.",
934 clip("Panic in src/style.rs when the budget lands mid word", 19)
935 );
936 }
937
938 #[test]
939 fn an_unmarked_clip_really_did_end_a_sentence() {
940 for max in 4..80 {
943 let text = "First sentence here. Second one follows it. Third trails off";
944 let out = clip(text, max);
945 if out.len() < text.len() && !out.ends_with("...") {
946 assert!(
947 out.ends_with('.') || out.ends_with('!') || out.ends_with('?'),
948 "max={max} out={out:?}"
949 );
950 let next = text[out.len()..].chars().next();
951 assert!(
952 next.is_none_or(|c| c.is_whitespace()),
953 "max={max} cut mid-token before {next:?}: {out:?}"
954 );
955 }
956 }
957 }
958
959 #[test]
960 fn clip_ignores_a_decimal_point_as_a_sentence_end() {
961 let text = "Version 1.4 of the parser mishandles empty input badly and loops.";
962 assert_ne!("Version 1.", clip(text, 30));
963 }
964
965 #[test]
966 fn empty_sections_are_dropped() {
967 let out = strip_empty_sections("## Context\n\n## Proposal\n\nDo the thing.\n");
968 assert!(!out.contains("Context"), "{out}");
969 assert!(out.contains("Do the thing."), "{out}");
970 }
971
972 #[test]
973 fn a_lone_label_heading_is_dropped() {
974 assert_eq!(
975 "The retry never fires.",
976 strip_empty_sections("## Summary\n\nThe retry never fires.")
977 );
978 }
979
980 #[test]
981 fn real_headings_survive_when_there_are_several() {
982 let text = "## Summary\n\nA thing.\n\n## Repro\n\nRun it.";
983 let out = strip_empty_sections(text);
984 assert!(
985 out.contains("## Summary") && out.contains("## Repro"),
986 "{out}"
987 );
988 }
989
990 #[test]
991 fn terse_off_leaves_length_alone() {
992 let loose = Style {
993 terse: false,
994 ..s()
995 };
996 let long = "word ".repeat(400);
997 assert_eq!(long.trim(), detail(&long, &loose));
998 }
999
1000 #[test]
1001 fn detail_is_capped_and_single_line() {
1002 let out = detail(
1003 &format!("first line\nsecond line\n{}", "filler ".repeat(200)),
1004 &s(),
1005 );
1006 assert!(!out.contains('\n'));
1007 assert!(out.chars().count() <= s().max_detail_chars);
1008 }
1009
1010 #[test]
1011 fn title_is_capped_and_single_line() {
1012 let out = title(
1013 "a very\nlong\ttitle that keeps going ".repeat(20).as_str(),
1014 &s(),
1015 );
1016 assert!(!out.contains('\n'));
1017 assert!(out.chars().count() <= s().max_title_chars);
1018 }
1019
1020 #[test]
1021 fn body_keeps_structure_but_bounds_length() {
1022 let text = format!(
1023 "## Summary\n\nreal content here.\n\n{}",
1024 "more prose. ".repeat(300)
1025 );
1026 let out = body(&text, &s());
1027 assert!(
1028 out.chars().count() <= s().max_body_chars,
1029 "{}",
1030 out.chars().count()
1031 );
1032 assert!(out.contains("real content here"), "{out}");
1033 }
1034}
1035
1036#[cfg(test)]
1037mod sentence_tests {
1038 use super::*;
1039
1040 #[test]
1041 fn a_fragment_reads_as_a_sentence() {
1042 assert_eq!(
1043 "The caller already validates it.",
1044 sentence("the caller already validates it.", &Style::default())
1045 );
1046 }
1047
1048 #[test]
1049 fn an_already_capitalised_one_is_untouched() {
1050 assert_eq!(
1051 "Already fine.",
1052 sentence("Already fine.", &Style::default())
1053 );
1054 }
1055
1056 #[test]
1057 fn empty_stays_empty_rather_than_panicking() {
1058 assert_eq!("", sentence(" ", &Style::default()));
1059 }
1060
1061 #[test]
1062 fn a_multibyte_first_character_does_not_panic() {
1063 assert_eq!("Ärger", sentence("ärger", &Style::default()));
1064 }
1065}
1066
1067#[cfg(test)]
1068mod issue_body_tests {
1069 use super::*;
1070
1071 fn s() -> Style {
1072 Style::default()
1073 }
1074
1075 fn fences(text: &str) -> usize {
1076 text.lines()
1077 .filter(|l| l.trim_start().starts_with("```"))
1078 .count()
1079 }
1080
1081 #[test]
1084 fn a_code_block_is_never_truncated() {
1085 let code = (0..400)
1086 .map(|n| format!(" line_{n}();"))
1087 .collect::<Vec<_>>()
1088 .join("\n");
1089 let text = format!("It spins forever.\n\n```rust\n{code}\n```\n\nThat is the loop.");
1090 let out = issue_body(&text, &s());
1091
1092 assert!(
1093 out.contains("line_0();") && out.contains("line_399();"),
1094 "the block was cut"
1095 );
1096 assert_eq!(0, fences(&out) % 2, "left an unclosed fence:\n{out}");
1097 }
1098
1099 #[test]
1103 fn an_issue_keeps_the_fence_closed_however_long_the_snippet() {
1107 for lines in [50, 400, 4000] {
1108 let code = (0..lines)
1109 .map(|n| format!(" line_{n}();"))
1110 .collect::<Vec<_>>()
1111 .join("\n");
1112 let text = format!("It spins forever.\n\n```rust\n{code}\n```");
1113 let out = issue_body(&text, &s());
1114 assert_eq!(0, fences(&out) % 2, "unclosed fence at {lines} lines");
1115 assert!(
1116 out.contains("line_0();"),
1117 "the snippet went entirely at {lines} lines"
1118 );
1119 let whole = out.contains(&format!("line_{}();", lines - 1));
1122 assert!(
1123 whole || out.contains("snippet shortened"),
1124 "cut at {lines} lines without saying so"
1125 );
1126 }
1127 }
1128
1129 #[test]
1132 fn a_long_snippet_does_not_evict_the_explanation() {
1133 let code = "x();\n".repeat(1500);
1134 let text = format!(
1135 "Reproduction:\n\n1. Call connect twice.\n2. Watch the retry count.\n\n```\n{code}```\n\nSuggested fix: bound the loop."
1136 );
1137 let out = issue_body(&text, &s());
1138 assert!(out.contains("Call connect twice"), "{out}");
1139 assert!(out.contains("Suggested fix"), "the tail survived");
1140 }
1141
1142 #[test]
1143 fn steps_to_reproduce_survive_intact() {
1144 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.";
1145 assert_eq!(text, issue_body(text, &s()));
1146 }
1147
1148 #[test]
1149 fn a_body_within_budget_is_untouched() {
1150 let text = "One paragraph.\n\nAnd another.";
1151 assert_eq!(text, issue_body(text, &s()));
1152 }
1153
1154 #[test]
1157 fn overlong_prose_drops_whole_blocks_from_the_end() {
1158 let para = |n: usize| format!("Paragraph {n}. {}", "filler words here. ".repeat(30));
1159 let text = (0..20).map(para).collect::<Vec<_>>().join("\n\n");
1160 let out = issue_body(&text, &s());
1161
1162 assert!(out.starts_with("Paragraph 0."), "{out}");
1163 assert!(!out.contains("..."), "no mid-sentence cut: {out}");
1164 assert!(
1165 out.trim_end().ends_with('.'),
1166 "ends on a complete block: {out}"
1167 );
1168 assert!(
1169 out.chars().count() <= s().max_issue_body_chars + 400,
1170 "{}",
1171 out.chars().count()
1172 );
1173 }
1174
1175 #[test]
1178 fn an_issue_gets_much_more_room_than_a_comment() {
1179 let text = "word ".repeat(4000);
1180 assert!(
1181 issue_body(&text, &s()).len() > body(&text, &s()).len() * 2,
1182 "issue {} vs comment {}",
1183 issue_body(&text, &s()).len(),
1184 body(&text, &s()).len()
1185 );
1186 }
1187
1188 #[test]
1189 fn a_single_block_over_budget_is_kept_rather_than_mangled() {
1190 let text = format!("```\n{}\n```", "y();\n".repeat(3000));
1191 let out = issue_body(&text, &s());
1192 assert!(!out.is_empty());
1193 assert_eq!(0, fences(&out) % 2, "{}", &out[..80.min(out.len())]);
1194 }
1195
1196 #[test]
1197 fn an_unterminated_fence_is_still_kept_whole() {
1198 let text = "Here is the code:\n\n```rust\nfn broken() {\n loop {}";
1199 let out = issue_body(text, &s());
1200 assert!(out.contains("fn broken()"), "{out}");
1201 }
1202
1203 #[test]
1204 fn terse_off_leaves_an_issue_body_completely_alone() {
1205 let loose = Style {
1206 terse: false,
1207 ..s()
1208 };
1209 let text = "a".repeat(50_000);
1210 assert_eq!(text, issue_body(&text, &loose));
1211 }
1212
1213 #[test]
1214 fn issue_body_is_idempotent() {
1215 let text = format!(
1216 "Explanation.\n\n```\n{}\n```\n\n{}",
1217 "z();\n".repeat(50),
1218 "more prose. ".repeat(600)
1219 );
1220 let once = issue_body(&text, &s());
1221 assert_eq!(once, issue_body(&once, &s()));
1222 }
1223}
1224
1225#[cfg(test)]
1226mod sentence_completion_tests {
1227 use super::*;
1228
1229 fn s() -> Style {
1230 Style::default()
1231 }
1232
1233 const REAL: &str = "Disconnect() deliberately drops the instance's restore debt and stops \
1236 its poll, so re-arming _restoreOwed there would leave a field nothing consumes, and \
1237 surviving instances already reconnect and restore every remaining hash on their own.";
1238
1239 #[test]
1240 fn the_real_comment_now_finishes_its_sentence() {
1241 let out = summary(REAL, &s());
1242 assert!(!out.ends_with("..."), "{out}");
1243 assert!(out.ends_with('.'), "{out}");
1244 assert!(out.contains("on their own"), "the thought completes: {out}");
1245 }
1246
1247 #[test]
1249 fn a_sentence_running_just_past_the_budget_is_finished_not_cut() {
1250 let text = format!("{} and then it ends here.", "word ".repeat(78));
1251 let out = clip(&text, 400);
1252 assert!(out.ends_with("and then it ends here."), "{out}");
1253 assert!(out.chars().count() > 400, "it overshot on purpose");
1254 }
1255
1256 #[test]
1258 fn a_sentence_that_never_ends_is_still_cut() {
1259 let text = "word ".repeat(400);
1260 let out = clip(&text, 200);
1261 assert!(out.ends_with("..."), "{out}");
1262 assert!(out.chars().count() <= 200, "{}", out.chars().count());
1263 }
1264
1265 #[test]
1268 fn it_stops_within_budget_when_a_sentence_ends_there() {
1269 let text = "This sentence is complete. ".repeat(30);
1270 let out = clip(&text, 400);
1271 assert!(out.ends_with("complete."), "{out}");
1272 assert!(out.chars().count() <= 400, "{}", out.chars().count());
1273 }
1274
1275 #[test]
1277 fn overshoot_never_more_than_doubles_the_budget() {
1278 let text = format!("Short. {}", "word ".repeat(400));
1279 let out = clip(&text, 60);
1280 assert!(out.chars().count() <= 120, "{}", out.chars().count());
1281 }
1282
1283 #[test]
1286 fn a_title_never_wears_an_ellipsis() {
1287 let long = "disconnect() stops electrum for clients.network rather than \
1288 this.electrumNetwork and records the stop against a key nothing reads back"
1289 .to_string();
1290 let out = title(&long, &s());
1291 assert!(!out.ends_with("..."), "{out}");
1292 assert!(!out.contains("..."), "{out}");
1293 }
1294
1295 #[test]
1298 fn the_titles_that_were_cut_would_now_survive() {
1299 for real in [
1300 "Public subscribeToHeader/subscribeToAddresses re-register an instance disconnect() \
1301 deliberately dropped",
1302 "disconnect() stops electrum for clients.network, not this.electrumNetwork, and \
1303 records the stop against the wrong key",
1304 ] {
1305 let out = title(real, &s());
1306 assert_eq!(one_line(real), out, "still truncated: {out}");
1307 }
1308 }
1309
1310 #[test]
1311 fn the_budgets_leave_room_to_finish_a_thought() {
1312 let d = s();
1313 assert!(d.max_summary_chars >= 400, "a one line reason needs room");
1314 assert!(d.max_detail_chars >= 500);
1315 assert!(d.max_title_chars >= 140);
1316 }
1317
1318 #[test]
1319 fn a_short_text_is_still_left_completely_alone() {
1320 assert_eq!("Already short.", clip("Already short.", 400));
1321 assert_eq!("Already short.", clip_bare("Already short.", 400));
1322 }
1323}