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 pub inline_comments: bool,
144}
145
146impl Default for Style {
147 fn default() -> Self {
148 Self {
149 ban_em_dash: true,
150 ban_ai_attribution: true,
151 terse: true,
152 max_detail_chars: 6000,
153 max_summary_chars: 2000,
154 max_body_chars: 8000,
155 max_issue_body_chars: 20000,
156 max_title_chars: 140,
157 pr_comments: crate::config::PrComments::Outcome,
158 inline_comments: true,
159 }
160 }
161}
162
163impl Style {
164 pub fn permissive() -> Self {
167 Self {
168 terse: false,
169 ..Self::default()
170 }
171 }
172}
173
174pub fn scrub(text: &str, style: &Style) -> String {
181 if text.is_empty() {
182 return String::new();
183 }
184 let mut out = text.to_string();
185
186 if style.ban_ai_attribution {
187 out = ATTRIBUTION_LINE.replace_all(&out, "").into_owned();
188 out = outside_code(&out, |part| {
189 ATTRIBUTION_INLINE.replace_all(part, "").into_owned()
190 });
191 out = out.replace('\u{1F916}', "");
192 }
193
194 if style.ban_em_dash {
195 out = outside_code(&out, |part| DASH_RUN.replace_all(part, ", ").into_owned());
203 }
204
205 out = TRAILING_SPACE.replace_all(&out, "").into_owned();
206 out = BLANK_RUN.replace_all(&out, "\n\n").into_owned();
207 out.trim().to_string()
208}
209
210pub fn violations(text: &str, style: &Style) -> Vec<String> {
213 let mut bad = Vec::new();
214 if style.ban_em_dash && ANY_DASH.is_match(&without_code(text)) {
215 bad.push("em/en dash present".to_string());
216 }
217 if style.ban_ai_attribution
218 && (ATTRIBUTION_LINE.is_match(text)
219 || ATTRIBUTION_INLINE.is_match(&without_code(text))
220 || ATTRIBUTION_RESIDUE.is_match(text))
221 {
222 bad.push("AI attribution present".to_string());
223 }
224 bad
225}
226
227fn outside_code(text: &str, edit: impl Fn(&str) -> String) -> String {
233 let mut out = String::with_capacity(text.len());
234 let mut last = 0;
235 for (start, end) in code_regions(text) {
236 out.push_str(&edit(&text[last..start]));
237 out.push_str(&text[start..end]);
238 last = end;
239 }
240 out.push_str(&edit(&text[last..]));
241 out
242}
243
244fn without_code(text: &str) -> String {
246 let mut out = String::with_capacity(text.len());
247 let mut last = 0;
248 for (start, end) in code_regions(text) {
249 out.push_str(&text[last..start]);
250 out.extend(
252 text[start..end]
253 .chars()
254 .map(|c| if c == '\n' { '\n' } else { ' ' }),
255 );
256 last = end;
257 }
258 out.push_str(&text[last..]);
259 out
260}
261
262fn code_regions(text: &str) -> Vec<(usize, usize)> {
269 let bytes = text.as_bytes();
270 let mut regions = Vec::new();
271 let mut i = 0;
272 let mut at_line_start = true;
273 while i < bytes.len() {
274 let byte = bytes[i];
275 if byte == b'\n' {
276 at_line_start = true;
277 i += 1;
278 continue;
279 }
280 if byte != b'`' {
281 if !byte.is_ascii_whitespace() {
282 at_line_start = false;
283 }
284 i += 1;
285 continue;
286 }
287 let run = bytes[i..].iter().take_while(|b| **b == b'`').count();
288 if at_line_start && run >= 3 {
289 let after = i + run;
291 let end = find_closing_fence(text, after, run).unwrap_or(text.len());
292 regions.push((i, end));
293 i = end;
294 at_line_start = false;
295 continue;
296 }
297 let after = i + run;
299 let end = find_closing_ticks(bytes, after, run).unwrap_or(text.len());
300 regions.push((i, end));
301 i = end;
302 at_line_start = false;
303 }
304 regions
305}
306
307fn find_closing_fence(text: &str, from: usize, run: usize) -> Option<usize> {
308 let fence = "`".repeat(run);
309 let mut at = from;
310 while let Some(found) = text[at..].find(&fence) {
311 let start = at + found;
312 let line_start = text[..start].rfind('\n').map(|n| n + 1).unwrap_or(0);
313 if text[line_start..start].trim().is_empty() {
314 let end = start + fence.len();
315 return Some(
316 text[end..]
317 .find('\n')
318 .map(|n| end + n + 1)
319 .unwrap_or(text.len()),
320 );
321 }
322 at = start + fence.len();
323 }
324 None
325}
326
327fn find_closing_ticks(bytes: &[u8], from: usize, run: usize) -> Option<usize> {
328 let mut i = from;
329 while i < bytes.len() {
330 match bytes[i] {
331 b'\n' => return None,
332 b'`' => {
333 let here = bytes[i..].iter().take_while(|b| **b == b'`').count();
334 if here == run {
335 return Some(i + here);
336 }
337 i += here;
338 }
339 _ => i += 1,
340 }
341 }
342 None
343}
344
345pub fn one_line(text: &str) -> String {
355 text.split_whitespace().collect::<Vec<_>>().join(" ")
356}
357
358const OVERSHOOT: usize = 240;
365
366pub fn clip(text: &str, max: usize) -> String {
368 clip_marked(text, max, "...")
369}
370
371pub fn clip_bare(text: &str, max: usize) -> String {
377 clip_marked(text, max, "")
378}
379
380fn clip_marked(text: &str, max: usize, marker: &str) -> String {
381 let trimmed = text.trim();
382 if max == 0 {
383 return trimmed.to_string();
384 }
385 let chars: Vec<char> = trimmed.chars().collect();
386 if chars.len() <= max {
387 return trimmed.to_string();
388 }
389
390 let ends_sentence = |i: usize| -> bool {
391 matches!(chars[i], '.' | '!' | '?')
392 && chars.get(i + 1).is_none_or(|n| n.is_whitespace())
396 };
397
398 let within = (0..max).rfind(|i| ends_sentence(*i));
401 if let Some(cut) = within {
402 if (cut + 1) * 2 >= max {
403 return chars[..=cut]
404 .iter()
405 .collect::<String>()
406 .trim_end()
407 .to_string();
408 }
409 }
410
411 let ceiling = (max + OVERSHOOT.min(max)).min(chars.len());
416 if let Some(cut) = (max..ceiling).find(|i| ends_sentence(*i)) {
417 return chars[..=cut]
418 .iter()
419 .collect::<String>()
420 .trim_end()
421 .to_string();
422 }
423
424 if let Some(cut) = within {
426 return chars[..=cut]
427 .iter()
428 .collect::<String>()
429 .trim_end()
430 .to_string();
431 }
432
433 let budget = max.saturating_sub(marker.chars().count()).max(1);
436 let mut end = budget.min(chars.len());
437 while end > 0 && !chars[end - 1].is_whitespace() {
438 end -= 1;
439 }
440 if end == 0 {
441 end = budget.min(chars.len());
442 }
443 let mut out: String = chars[..end]
444 .iter()
445 .collect::<String>()
446 .trim_end()
447 .to_string();
448 out.push_str(marker);
449 out
450}
451
452pub fn strip_empty_sections(text: &str) -> String {
455 let lines: Vec<&str> = text.lines().collect();
456 let mut keep: Vec<&str> = Vec::with_capacity(lines.len());
457
458 let mut i = 0;
459 while i < lines.len() {
460 let line = lines[i];
461 if HEADING.is_match(line) {
462 let mut j = i + 1;
464 while j < lines.len() && !HEADING.is_match(lines[j]) {
465 j += 1;
466 }
467 let body_is_empty = lines[i + 1..j].iter().all(|l| l.trim().is_empty());
468 let only_heading = lines.iter().filter(|l| HEADING.is_match(l)).count() == 1;
469
470 if body_is_empty {
471 i = j; continue;
473 }
474 if only_heading && NOISE_HEADING.is_match(line) {
475 i += 1; continue;
477 }
478 }
479 keep.push(line);
480 i += 1;
481 }
482
483 let joined = keep.join("\n");
484 BLANK_RUN.replace_all(&joined, "\n\n").trim().to_string()
485}
486
487pub fn tighten(text: &str, max: usize, style: &Style) -> String {
490 if !style.terse {
491 return text.trim().to_string();
492 }
493 clip(&strip_empty_sections(text), max)
494}
495
496pub fn issue_body(text: &str, style: &Style) -> String {
510 if !style.terse {
511 return text.trim().to_string();
512 }
513 let cleaned = strip_empty_sections(text);
514 let blocks = split_blocks(&cleaned);
515
516 let ceiling = style.max_issue_body_chars.saturating_mul(4);
519
520 let mut kept: Vec<String> = Vec::new();
521 let mut prose = 0usize;
522 let mut total = 0usize;
523 for block in &blocks {
524 let len = block.text.chars().count();
525 if !block.code && prose + len > style.max_issue_body_chars && !kept.is_empty() {
526 break;
527 }
528 if total + len > ceiling {
529 if block.code {
533 if let Some(short) = shorten_code(&block.text, ceiling.saturating_sub(total)) {
534 kept.push(short);
535 }
536 }
537 break;
538 }
539 if !block.code {
540 prose += len;
541 }
542 total += len;
543 kept.push(block.text.clone());
544 }
545
546 kept.join("\n\n").trim().to_string()
547}
548
549fn shorten_code(block: &str, room: usize) -> Option<String> {
555 const NOTE: &str = "(snippet shortened)";
556 if room < 80 {
557 return None;
558 }
559 let mut lines = block.lines();
560 let opener = lines.next()?.to_string();
561 let mut out = vec![opener];
562 let mut used = out[0].chars().count() + NOTE.len() + 8;
563
564 for line in lines {
565 if line.trim_start().starts_with("```") {
566 break;
567 }
568 let len = line.chars().count() + 1;
569 if used + len > room {
570 break;
571 }
572 used += len;
573 out.push(line.to_string());
574 }
575 out.push("```".to_string());
576 out.push(String::new());
577 out.push(NOTE.to_string());
578 Some(out.join("\n"))
579}
580
581struct Block {
582 text: String,
583 code: bool,
584}
585
586fn split_blocks(text: &str) -> Vec<Block> {
589 let mut blocks = Vec::new();
590 let mut current: Vec<&str> = Vec::new();
591 let mut in_fence = false;
592 let mut fence_block = false;
593
594 let flush = |lines: &mut Vec<&str>, code: bool, out: &mut Vec<Block>| {
595 let joined = lines.join("\n");
596 if !joined.trim().is_empty() {
597 out.push(Block {
598 text: joined.trim_end().to_string(),
599 code,
600 });
601 }
602 lines.clear();
603 };
604
605 for line in text.lines() {
606 let fence = line.trim_start().starts_with("```");
607 if fence {
608 if in_fence {
609 current.push(line);
610 in_fence = false;
611 flush(&mut current, true, &mut blocks);
612 fence_block = false;
613 continue;
614 }
615 flush(&mut current, false, &mut blocks);
617 in_fence = true;
618 fence_block = true;
619 current.push(line);
620 continue;
621 }
622 if in_fence {
623 current.push(line);
624 continue;
625 }
626 if line.trim().is_empty() {
627 flush(&mut current, false, &mut blocks);
628 } else {
629 current.push(line);
630 }
631 }
632 flush(&mut current, fence_block, &mut blocks);
634 blocks
635}
636
637pub fn title(text: &str, style: &Style) -> String {
639 let flat = one_line(text);
640 if style.terse {
641 clip_bare(&flat, style.max_title_chars)
642 } else {
643 flat
644 }
645}
646
647pub fn sentence(text: &str, style: &Style) -> String {
650 let one = summary(text, style);
651 let mut chars = one.chars();
652 match chars.next() {
653 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
654 None => one,
655 }
656}
657
658pub fn summary(text: &str, style: &Style) -> String {
660 let flat = one_line(text);
661 if style.terse {
662 clip(&flat, style.max_summary_chars)
663 } else {
664 flat
665 }
666}
667
668pub fn detail(text: &str, style: &Style) -> String {
671 let flat = one_line(text);
672 if style.terse {
673 clip(&flat, style.max_detail_chars)
674 } else {
675 flat
676 }
677}
678
679pub fn body(text: &str, style: &Style) -> String {
681 tighten(text, style.max_body_chars, style)
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687
688 fn s() -> Style {
689 Style::default()
690 }
691
692 #[test]
695 fn em_dash_removed() {
696 let out = scrub(
697 "Fix the parser, it was broken \u{2014} badly \u{2014} on empty input.",
698 &s(),
699 );
700 assert!(!out.contains('\u{2014}'));
701 assert!(violations(&out, &s()).is_empty());
702 }
703
704 #[test]
705 fn en_dash_removed() {
706 assert!(!scrub("range 1 \u{2013} 5", &s()).contains('\u{2013}'));
707 }
708
709 #[test]
710 fn horizontal_bar_removed() {
711 assert!(violations(&scrub("a \u{2015} b", &s()), &s()).is_empty());
712 }
713
714 #[test]
721 fn the_forms_that_used_to_pass_are_caught_now() {
722 for text in [
723 "Fix bug. \u{1F916} Generated with [Claude Code](https://claude.com)",
724 "Co-authored-by: Cursor Agent <cursoragent@cursor.com>",
725 "co-authored-by: GitHub Copilot",
726 "Signed-off-by: Claude <noreply@anthropic.com>",
727 "Made with Claude",
728 "Generated by Anthropic's Claude",
729 "Assisted-by: gpt-5",
730 ] {
731 assert!(
732 !violations(text, &s()).is_empty(),
733 "the gate did not see it: {text}"
734 );
735 let out = scrub(text, &s());
736 assert!(
737 violations(&out, &s()).is_empty(),
738 "the scrub could not clean it: {text} -> {out}"
739 );
740 }
741 }
742
743 #[test]
745 fn a_human_trailer_is_left_exactly_where_it_is() {
746 for text in [
747 "Co-authored-by: Alex Rivera <alex@example.com>",
748 "Signed-off-by: Corey Phillips <corey@example.com>",
749 "Reviewed-by: the release team",
750 ] {
751 assert_eq!(text, scrub(text, &s()), "a person's trailer was rewritten");
752 assert!(violations(text, &s()).is_empty(), "{text}");
753 }
754 }
755
756 #[test]
762 fn code_keeps_its_dashes_and_its_words() {
763 let text = "The label is wrong:\n\n```rust\nlet s = \"Save \u{2014} Draft\";\n```\n\nFix it \u{2014} soon.";
764 let out = scrub(text, &s());
765 assert!(
766 out.contains("\"Save \u{2014} Draft\""),
767 "the evidence was rewritten:\n{out}"
768 );
769 assert!(
770 out.contains("Fix it, soon."),
771 "prose was not scrubbed:\n{out}"
772 );
773 assert!(violations(&out, &s()).is_empty(), "{out}");
774
775 let inline = scrub("The literal `a \u{2014} b` is the bug \u{2014} here.", &s());
776 assert!(inline.contains("`a \u{2014} b`"), "{inline}");
777 assert!(inline.contains("bug, here."), "{inline}");
778 }
779
780 #[test]
782 fn ordinary_prose_about_ai_survives() {
783 for text in [
784 "Reject AI-generated spam in the comment filter",
785 "The AI generated output is cached",
786 ] {
787 assert_eq!(text, scrub(text, &s()), "prose was edited");
788 assert!(violations(text, &s()).is_empty(), "{text}");
789 }
790 }
791
792 #[test]
794 fn the_nonbreaking_hyphen_and_the_two_em_dash_are_dashes() {
795 for dash in ['\u{2011}', '\u{2E3A}', '\u{2E3B}'] {
796 let text = format!("a {dash} b");
797 assert!(
798 !violations(&text, &s()).is_empty(),
799 "{dash:?} passed the gate"
800 );
801 assert!(
802 !scrub(&text, &s()).contains(dash),
803 "{dash:?} survived the scrub"
804 );
805 }
806 }
807
808 #[test]
811 fn a_vendor_address_the_scrub_leaves_is_a_hard_error() {
812 let text = "See the discussion with noreply@anthropic.com about it";
813 assert!(!violations(text, &s()).is_empty(), "{text}");
814 }
815
816 #[test]
817 fn coauthor_trailer_stripped() {
818 let out = scrub(
819 "Add retry logic\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n",
820 &s(),
821 );
822 assert!(!out.contains("Co-Authored-By"));
823 assert!(out.contains("Add retry logic"));
824 }
825
826 #[test]
827 fn generated_with_footer_stripped() {
828 let out = scrub(
829 "Fix bug\n\n\u{1F916} Generated with [Claude Code](https://claude.com)\n",
830 &s(),
831 );
832 assert!(violations(&out, &s()).is_empty(), "{out}");
833 assert!(out.contains("Fix bug"));
834 }
835
836 #[test]
837 fn inline_attribution_stripped() {
838 let out = scrub("This patch was written by Claude to fix the leak.", &s());
839 assert!(violations(&out, &s()).is_empty(), "{out}");
840 }
841
842 #[test]
843 fn scrub_is_idempotent() {
844 let once = scrub("A \u{2014} B\n\nCo-Authored-By: Codex <x@y.z>", &s());
845 assert_eq!(once, scrub(&once, &s()));
846 }
847
848 #[test]
849 fn violations_detected_before_scrub() {
850 assert!(!violations("a \u{2014} b", &s()).is_empty());
851 assert!(!violations("Co-Authored-By: Claude <a@b.c>", &s()).is_empty());
852 }
853
854 #[test]
855 fn legitimate_prose_survives() {
856 let out = scrub("Refactor the AI-facing endpoint handler for clarity.", &s());
857 assert!(out.contains("endpoint handler"), "{out}");
858 }
859
860 #[test]
861 fn disabled_rules_are_respected() {
862 let off = Style {
863 ban_em_dash: false,
864 ban_ai_attribution: false,
865 ..s()
866 };
867 let text = "a \u{2014} b\nCo-Authored-By: Claude <x@y.z>";
868 assert!(scrub(text, &off).contains('\u{2014}'));
869 assert!(violations(text, &off).is_empty());
870 }
871
872 #[test]
873 fn dash_at_end_of_line_does_not_swallow_the_paragraph_break() {
874 let out = scrub("first line \u{2014}\n\nsecond paragraph", &s());
875 assert!(out.contains("\n\n"), "{out:?}");
876 }
877
878 #[test]
879 fn empty_input_is_empty_output() {
880 assert_eq!("", scrub("", &s()));
881 }
882
883 #[test]
886 fn one_line_flattens() {
887 assert_eq!("a b c", one_line(" a\n\n b\t c "));
888 }
889
890 #[test]
891 fn clip_leaves_short_text_alone() {
892 assert_eq!("short", clip("short", 40));
893 }
894
895 #[test]
896 fn clip_prefers_a_sentence_boundary() {
897 let text = "The loop never terminates. It also leaks a file descriptor on every pass.";
898 assert_eq!("The loop never terminates.", clip(text, 40));
899 }
900
901 #[test]
902 fn clip_falls_back_to_a_word_boundary() {
903 let out = clip("supercalifragilistic wording that runs on and on", 25);
904 assert!(out.ends_with("..."), "{out}");
905 assert!(out.chars().count() <= 25, "{out}");
906 assert!(!out.contains("wording that runs"), "{out}");
907 }
908
909 #[test]
910 fn clip_overshoots_only_within_bounds() {
914 for max in 1..60 {
915 let out = clip("one two three four five six seven eight nine ten.", max);
916 assert!(out.chars().count() <= max * 2 + 3, "max={max} out={out:?}");
917 }
918 }
919
920 #[test]
921 fn clip_handles_multibyte_text() {
922 let out = clip(&"\u{1f600}".repeat(50), 10);
923 assert!(out.chars().count() <= 10, "{out}");
924 }
925
926 #[test]
931 fn a_period_on_the_budget_boundary_is_not_a_sentence_end() {
932 assert_ne!(
933 "Version 1.",
934 clip("Version 1.4 of the parser mishandles input", 10)
935 );
936 assert_ne!(
937 "Panic in src/style.",
938 clip("Panic in src/style.rs when the budget lands mid word", 19)
939 );
940 }
941
942 #[test]
943 fn an_unmarked_clip_really_did_end_a_sentence() {
944 for max in 4..80 {
947 let text = "First sentence here. Second one follows it. Third trails off";
948 let out = clip(text, max);
949 if out.len() < text.len() && !out.ends_with("...") {
950 assert!(
951 out.ends_with('.') || out.ends_with('!') || out.ends_with('?'),
952 "max={max} out={out:?}"
953 );
954 let next = text[out.len()..].chars().next();
955 assert!(
956 next.is_none_or(|c| c.is_whitespace()),
957 "max={max} cut mid-token before {next:?}: {out:?}"
958 );
959 }
960 }
961 }
962
963 #[test]
964 fn clip_ignores_a_decimal_point_as_a_sentence_end() {
965 let text = "Version 1.4 of the parser mishandles empty input badly and loops.";
966 assert_ne!("Version 1.", clip(text, 30));
967 }
968
969 #[test]
970 fn empty_sections_are_dropped() {
971 let out = strip_empty_sections("## Context\n\n## Proposal\n\nDo the thing.\n");
972 assert!(!out.contains("Context"), "{out}");
973 assert!(out.contains("Do the thing."), "{out}");
974 }
975
976 #[test]
977 fn a_lone_label_heading_is_dropped() {
978 assert_eq!(
979 "The retry never fires.",
980 strip_empty_sections("## Summary\n\nThe retry never fires.")
981 );
982 }
983
984 #[test]
985 fn real_headings_survive_when_there_are_several() {
986 let text = "## Summary\n\nA thing.\n\n## Repro\n\nRun it.";
987 let out = strip_empty_sections(text);
988 assert!(
989 out.contains("## Summary") && out.contains("## Repro"),
990 "{out}"
991 );
992 }
993
994 #[test]
995 fn terse_off_leaves_length_alone() {
996 let loose = Style {
997 terse: false,
998 ..s()
999 };
1000 let long = "word ".repeat(400);
1001 assert_eq!(long.trim(), detail(&long, &loose));
1002 }
1003
1004 #[test]
1005 fn detail_is_capped_and_single_line() {
1006 let out = detail(
1007 &format!("first line\nsecond line\n{}", "filler ".repeat(200)),
1008 &s(),
1009 );
1010 assert!(!out.contains('\n'));
1011 assert!(out.chars().count() <= s().max_detail_chars);
1012 }
1013
1014 #[test]
1015 fn title_is_capped_and_single_line() {
1016 let out = title(
1017 "a very\nlong\ttitle that keeps going ".repeat(20).as_str(),
1018 &s(),
1019 );
1020 assert!(!out.contains('\n'));
1021 assert!(out.chars().count() <= s().max_title_chars);
1022 }
1023
1024 #[test]
1025 fn body_keeps_structure_but_bounds_length() {
1026 let text = format!(
1027 "## Summary\n\nreal content here.\n\n{}",
1028 "more prose. ".repeat(300)
1029 );
1030 let out = body(&text, &s());
1031 assert!(
1032 out.chars().count() <= s().max_body_chars,
1033 "{}",
1034 out.chars().count()
1035 );
1036 assert!(out.contains("real content here"), "{out}");
1037 }
1038}
1039
1040#[cfg(test)]
1041mod sentence_tests {
1042 use super::*;
1043
1044 #[test]
1045 fn a_fragment_reads_as_a_sentence() {
1046 assert_eq!(
1047 "The caller already validates it.",
1048 sentence("the caller already validates it.", &Style::default())
1049 );
1050 }
1051
1052 #[test]
1053 fn an_already_capitalised_one_is_untouched() {
1054 assert_eq!(
1055 "Already fine.",
1056 sentence("Already fine.", &Style::default())
1057 );
1058 }
1059
1060 #[test]
1061 fn empty_stays_empty_rather_than_panicking() {
1062 assert_eq!("", sentence(" ", &Style::default()));
1063 }
1064
1065 #[test]
1066 fn a_multibyte_first_character_does_not_panic() {
1067 assert_eq!("Ärger", sentence("ärger", &Style::default()));
1068 }
1069}
1070
1071#[cfg(test)]
1072mod issue_body_tests {
1073 use super::*;
1074
1075 fn s() -> Style {
1076 Style::default()
1077 }
1078
1079 fn fences(text: &str) -> usize {
1080 text.lines()
1081 .filter(|l| l.trim_start().starts_with("```"))
1082 .count()
1083 }
1084
1085 #[test]
1088 fn a_code_block_is_never_truncated() {
1089 let code = (0..400)
1090 .map(|n| format!(" line_{n}();"))
1091 .collect::<Vec<_>>()
1092 .join("\n");
1093 let text = format!("It spins forever.\n\n```rust\n{code}\n```\n\nThat is the loop.");
1094 let out = issue_body(&text, &s());
1095
1096 assert!(
1097 out.contains("line_0();") && out.contains("line_399();"),
1098 "the block was cut"
1099 );
1100 assert_eq!(0, fences(&out) % 2, "left an unclosed fence:\n{out}");
1101 }
1102
1103 #[test]
1107 fn an_issue_keeps_the_fence_closed_however_long_the_snippet() {
1111 for lines in [50, 400, 4000] {
1112 let code = (0..lines)
1113 .map(|n| format!(" line_{n}();"))
1114 .collect::<Vec<_>>()
1115 .join("\n");
1116 let text = format!("It spins forever.\n\n```rust\n{code}\n```");
1117 let out = issue_body(&text, &s());
1118 assert_eq!(0, fences(&out) % 2, "unclosed fence at {lines} lines");
1119 assert!(
1120 out.contains("line_0();"),
1121 "the snippet went entirely at {lines} lines"
1122 );
1123 let whole = out.contains(&format!("line_{}();", lines - 1));
1126 assert!(
1127 whole || out.contains("snippet shortened"),
1128 "cut at {lines} lines without saying so"
1129 );
1130 }
1131 }
1132
1133 #[test]
1136 fn a_long_snippet_does_not_evict_the_explanation() {
1137 let code = "x();\n".repeat(1500);
1138 let text = format!(
1139 "Reproduction:\n\n1. Call connect twice.\n2. Watch the retry count.\n\n```\n{code}```\n\nSuggested fix: bound the loop."
1140 );
1141 let out = issue_body(&text, &s());
1142 assert!(out.contains("Call connect twice"), "{out}");
1143 assert!(out.contains("Suggested fix"), "the tail survived");
1144 }
1145
1146 #[test]
1147 fn steps_to_reproduce_survive_intact() {
1148 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.";
1149 assert_eq!(text, issue_body(text, &s()));
1150 }
1151
1152 #[test]
1153 fn a_body_within_budget_is_untouched() {
1154 let text = "One paragraph.\n\nAnd another.";
1155 assert_eq!(text, issue_body(text, &s()));
1156 }
1157
1158 #[test]
1161 fn overlong_prose_drops_whole_blocks_from_the_end() {
1162 let para = |n: usize| format!("Paragraph {n}. {}", "filler words here. ".repeat(30));
1163 let text = (0..20).map(para).collect::<Vec<_>>().join("\n\n");
1164 let out = issue_body(&text, &s());
1165
1166 assert!(out.starts_with("Paragraph 0."), "{out}");
1167 assert!(!out.contains("..."), "no mid-sentence cut: {out}");
1168 assert!(
1169 out.trim_end().ends_with('.'),
1170 "ends on a complete block: {out}"
1171 );
1172 assert!(
1173 out.chars().count() <= s().max_issue_body_chars + 400,
1174 "{}",
1175 out.chars().count()
1176 );
1177 }
1178
1179 #[test]
1182 fn an_issue_gets_much_more_room_than_a_comment() {
1183 let text = "word ".repeat(4000);
1184 assert!(
1185 issue_body(&text, &s()).len() > body(&text, &s()).len() * 2,
1186 "issue {} vs comment {}",
1187 issue_body(&text, &s()).len(),
1188 body(&text, &s()).len()
1189 );
1190 }
1191
1192 #[test]
1193 fn a_single_block_over_budget_is_kept_rather_than_mangled() {
1194 let text = format!("```\n{}\n```", "y();\n".repeat(3000));
1195 let out = issue_body(&text, &s());
1196 assert!(!out.is_empty());
1197 assert_eq!(0, fences(&out) % 2, "{}", &out[..80.min(out.len())]);
1198 }
1199
1200 #[test]
1201 fn an_unterminated_fence_is_still_kept_whole() {
1202 let text = "Here is the code:\n\n```rust\nfn broken() {\n loop {}";
1203 let out = issue_body(text, &s());
1204 assert!(out.contains("fn broken()"), "{out}");
1205 }
1206
1207 #[test]
1208 fn terse_off_leaves_an_issue_body_completely_alone() {
1209 let loose = Style {
1210 terse: false,
1211 ..s()
1212 };
1213 let text = "a".repeat(50_000);
1214 assert_eq!(text, issue_body(&text, &loose));
1215 }
1216
1217 #[test]
1218 fn issue_body_is_idempotent() {
1219 let text = format!(
1220 "Explanation.\n\n```\n{}\n```\n\n{}",
1221 "z();\n".repeat(50),
1222 "more prose. ".repeat(600)
1223 );
1224 let once = issue_body(&text, &s());
1225 assert_eq!(once, issue_body(&once, &s()));
1226 }
1227}
1228
1229#[cfg(test)]
1230mod sentence_completion_tests {
1231 use super::*;
1232
1233 fn s() -> Style {
1234 Style::default()
1235 }
1236
1237 const REAL: &str = "Disconnect() deliberately drops the instance's restore debt and stops \
1240 its poll, so re-arming _restoreOwed there would leave a field nothing consumes, and \
1241 surviving instances already reconnect and restore every remaining hash on their own.";
1242
1243 #[test]
1244 fn the_real_comment_now_finishes_its_sentence() {
1245 let out = summary(REAL, &s());
1246 assert!(!out.ends_with("..."), "{out}");
1247 assert!(out.ends_with('.'), "{out}");
1248 assert!(out.contains("on their own"), "the thought completes: {out}");
1249 }
1250
1251 #[test]
1253 fn a_sentence_running_just_past_the_budget_is_finished_not_cut() {
1254 let text = format!("{} and then it ends here.", "word ".repeat(78));
1255 let out = clip(&text, 400);
1256 assert!(out.ends_with("and then it ends here."), "{out}");
1257 assert!(out.chars().count() > 400, "it overshot on purpose");
1258 }
1259
1260 #[test]
1262 fn a_sentence_that_never_ends_is_still_cut() {
1263 let text = "word ".repeat(400);
1264 let out = clip(&text, 200);
1265 assert!(out.ends_with("..."), "{out}");
1266 assert!(out.chars().count() <= 200, "{}", out.chars().count());
1267 }
1268
1269 #[test]
1272 fn it_stops_within_budget_when_a_sentence_ends_there() {
1273 let text = "This sentence is complete. ".repeat(30);
1274 let out = clip(&text, 400);
1275 assert!(out.ends_with("complete."), "{out}");
1276 assert!(out.chars().count() <= 400, "{}", out.chars().count());
1277 }
1278
1279 #[test]
1281 fn overshoot_never_more_than_doubles_the_budget() {
1282 let text = format!("Short. {}", "word ".repeat(400));
1283 let out = clip(&text, 60);
1284 assert!(out.chars().count() <= 120, "{}", out.chars().count());
1285 }
1286
1287 #[test]
1290 fn a_title_never_wears_an_ellipsis() {
1291 let long = "disconnect() stops electrum for clients.network rather than \
1292 this.electrumNetwork and records the stop against a key nothing reads back"
1293 .to_string();
1294 let out = title(&long, &s());
1295 assert!(!out.ends_with("..."), "{out}");
1296 assert!(!out.contains("..."), "{out}");
1297 }
1298
1299 #[test]
1302 fn the_titles_that_were_cut_would_now_survive() {
1303 for real in [
1304 "Public subscribeToHeader/subscribeToAddresses re-register an instance disconnect() \
1305 deliberately dropped",
1306 "disconnect() stops electrum for clients.network, not this.electrumNetwork, and \
1307 records the stop against the wrong key",
1308 ] {
1309 let out = title(real, &s());
1310 assert_eq!(one_line(real), out, "still truncated: {out}");
1311 }
1312 }
1313
1314 #[test]
1315 fn the_budgets_leave_room_to_finish_a_thought() {
1316 let d = s();
1317 assert!(d.max_summary_chars >= 400, "a one line reason needs room");
1318 assert!(d.max_detail_chars >= 500);
1319 assert!(d.max_title_chars >= 140);
1320 }
1321
1322 #[test]
1323 fn a_short_text_is_still_left_completely_alone() {
1324 assert_eq!("Already short.", clip("Already short.", 400));
1325 assert_eq!("Already short.", clip_bare("Already short.", 400));
1326 }
1327}