1use std::sync::LazyLock;
15
16use regex::Regex;
17
18const 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
62static 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#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct Style {
74 pub ban_em_dash: bool,
75 pub ban_ai_attribution: bool,
76 pub terse: bool,
79 pub max_detail_chars: usize,
81 pub max_summary_chars: usize,
83 pub max_body_chars: usize,
85 pub max_issue_body_chars: usize,
87 pub max_title_chars: usize,
89 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 pub fn permissive() -> Self {
113 Self {
114 terse: false,
115 ..Self::default()
116 }
117 }
118}
119
120pub 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 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
150pub 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
165pub fn one_line(text: &str) -> String {
175 text.split_whitespace().collect::<Vec<_>>().join(" ")
176}
177
178pub 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 let mut sentence_end = None;
198 for (i, c) in window.iter().enumerate() {
199 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 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
242pub 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 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; continue;
263 }
264 if only_heading && NOISE_HEADING.is_match(line) {
265 i += 1; 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
277pub 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
286pub 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 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
340fn 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 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 flush(&mut current, fence_block, &mut blocks);
388 blocks
389}
390
391pub 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
401pub 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
412pub 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
422pub 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
433pub 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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}