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_title_chars: usize,
87 pub pr_comments: crate::config::PrComments,
89}
90
91impl Default for Style {
92 fn default() -> Self {
93 Self {
94 ban_em_dash: true,
95 ban_ai_attribution: true,
96 terse: true,
97 max_detail_chars: 320,
98 max_summary_chars: 200,
99 max_body_chars: 900,
100 max_title_chars: 90,
101 pr_comments: crate::config::PrComments::Outcome,
102 }
103 }
104}
105
106impl Style {
107 pub fn permissive() -> Self {
110 Self {
111 terse: false,
112 ..Self::default()
113 }
114 }
115}
116
117pub fn scrub(text: &str, style: &Style) -> String {
124 if text.is_empty() {
125 return String::new();
126 }
127 let mut out = text.to_string();
128
129 if style.ban_ai_attribution {
130 out = ATTRIBUTION_LINE.replace_all(&out, "").into_owned();
131 out = ATTRIBUTION_INLINE.replace_all(&out, "").into_owned();
132 out = out.replace('\u{1F916}', "");
133 }
134
135 if style.ban_em_dash {
136 out = DASH_RUN.replace_all(&out, ", ").into_owned();
140 }
141
142 out = TRAILING_SPACE.replace_all(&out, "").into_owned();
143 out = BLANK_RUN.replace_all(&out, "\n\n").into_owned();
144 out.trim().to_string()
145}
146
147pub fn violations(text: &str, style: &Style) -> Vec<String> {
150 let mut bad = Vec::new();
151 if style.ban_em_dash && ANY_DASH.is_match(text) {
152 bad.push("em/en dash present".to_string());
153 }
154 if style.ban_ai_attribution
155 && (ATTRIBUTION_LINE.is_match(text) || ATTRIBUTION_INLINE.is_match(text))
156 {
157 bad.push("AI attribution present".to_string());
158 }
159 bad
160}
161
162pub fn one_line(text: &str) -> String {
172 text.split_whitespace().collect::<Vec<_>>().join(" ")
173}
174
175pub fn clip(text: &str, max: usize) -> String {
181 let trimmed = text.trim();
182 if max == 0 {
183 return trimmed.to_string();
184 }
185 let chars: Vec<char> = trimmed.chars().collect();
186 if chars.len() <= max {
187 return trimmed.to_string();
188 }
189
190 let window = &chars[..max];
191
192 let mut sentence_end = None;
195 for (i, c) in window.iter().enumerate() {
196 if matches!(c, '.' | '!' | '?') && chars.get(i + 1).is_none_or(|n| n.is_whitespace()) {
202 sentence_end = Some(i + 1);
203 }
204 }
205 if let Some(cut) = sentence_end {
206 if cut * 2 >= max {
207 return window[..cut]
208 .iter()
209 .collect::<String>()
210 .trim_end()
211 .to_string();
212 }
213 }
214
215 const MARK: &str = "...";
219 if max <= MARK.len() {
220 return window.iter().collect::<String>().trim_end().to_string();
221 }
222 let budget = max - MARK.len();
223 let mut end = budget;
224 while end > 0 && !window[end - 1].is_whitespace() {
225 end -= 1;
226 }
227 if end == 0 {
228 end = budget;
229 }
230 let mut out: String = window[..end]
231 .iter()
232 .collect::<String>()
233 .trim_end()
234 .to_string();
235 out.push_str(MARK);
236 out
237}
238
239pub fn strip_empty_sections(text: &str) -> String {
242 let lines: Vec<&str> = text.lines().collect();
243 let mut keep: Vec<&str> = Vec::with_capacity(lines.len());
244
245 let mut i = 0;
246 while i < lines.len() {
247 let line = lines[i];
248 if HEADING.is_match(line) {
249 let mut j = i + 1;
251 while j < lines.len() && !HEADING.is_match(lines[j]) {
252 j += 1;
253 }
254 let body_is_empty = lines[i + 1..j].iter().all(|l| l.trim().is_empty());
255 let only_heading = lines.iter().filter(|l| HEADING.is_match(l)).count() == 1;
256
257 if body_is_empty {
258 i = j; continue;
260 }
261 if only_heading && NOISE_HEADING.is_match(line) {
262 i += 1; continue;
264 }
265 }
266 keep.push(line);
267 i += 1;
268 }
269
270 let joined = keep.join("\n");
271 BLANK_RUN.replace_all(&joined, "\n\n").trim().to_string()
272}
273
274pub fn tighten(text: &str, max: usize, style: &Style) -> String {
277 if !style.terse {
278 return text.trim().to_string();
279 }
280 clip(&strip_empty_sections(text), max)
281}
282
283pub fn title(text: &str, style: &Style) -> String {
285 let flat = one_line(text);
286 if style.terse {
287 clip(&flat, style.max_title_chars)
288 } else {
289 flat
290 }
291}
292
293pub fn sentence(text: &str, style: &Style) -> String {
296 let one = summary(text, style);
297 let mut chars = one.chars();
298 match chars.next() {
299 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
300 None => one,
301 }
302}
303
304pub fn summary(text: &str, style: &Style) -> String {
306 let flat = one_line(text);
307 if style.terse {
308 clip(&flat, style.max_summary_chars)
309 } else {
310 flat
311 }
312}
313
314pub fn detail(text: &str, style: &Style) -> String {
317 let flat = one_line(text);
318 if style.terse {
319 clip(&flat, style.max_detail_chars)
320 } else {
321 flat
322 }
323}
324
325pub fn body(text: &str, style: &Style) -> String {
327 tighten(text, style.max_body_chars, style)
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 fn s() -> Style {
335 Style::default()
336 }
337
338 #[test]
341 fn em_dash_removed() {
342 let out = scrub(
343 "Fix the parser, it was broken \u{2014} badly \u{2014} on empty input.",
344 &s(),
345 );
346 assert!(!out.contains('\u{2014}'));
347 assert!(violations(&out, &s()).is_empty());
348 }
349
350 #[test]
351 fn en_dash_removed() {
352 assert!(!scrub("range 1 \u{2013} 5", &s()).contains('\u{2013}'));
353 }
354
355 #[test]
356 fn horizontal_bar_removed() {
357 assert!(violations(&scrub("a \u{2015} b", &s()), &s()).is_empty());
358 }
359
360 #[test]
361 fn coauthor_trailer_stripped() {
362 let out = scrub(
363 "Add retry logic\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n",
364 &s(),
365 );
366 assert!(!out.contains("Co-Authored-By"));
367 assert!(out.contains("Add retry logic"));
368 }
369
370 #[test]
371 fn generated_with_footer_stripped() {
372 let out = scrub(
373 "Fix bug\n\n\u{1F916} Generated with [Claude Code](https://claude.com)\n",
374 &s(),
375 );
376 assert!(violations(&out, &s()).is_empty(), "{out}");
377 assert!(out.contains("Fix bug"));
378 }
379
380 #[test]
381 fn inline_attribution_stripped() {
382 let out = scrub("This patch was written by Claude to fix the leak.", &s());
383 assert!(violations(&out, &s()).is_empty(), "{out}");
384 }
385
386 #[test]
387 fn scrub_is_idempotent() {
388 let once = scrub("A \u{2014} B\n\nCo-Authored-By: Codex <x@y.z>", &s());
389 assert_eq!(once, scrub(&once, &s()));
390 }
391
392 #[test]
393 fn violations_detected_before_scrub() {
394 assert!(!violations("a \u{2014} b", &s()).is_empty());
395 assert!(!violations("Co-Authored-By: Claude <a@b.c>", &s()).is_empty());
396 }
397
398 #[test]
399 fn legitimate_prose_survives() {
400 let out = scrub("Refactor the AI-facing endpoint handler for clarity.", &s());
401 assert!(out.contains("endpoint handler"), "{out}");
402 }
403
404 #[test]
405 fn disabled_rules_are_respected() {
406 let off = Style {
407 ban_em_dash: false,
408 ban_ai_attribution: false,
409 ..s()
410 };
411 let text = "a \u{2014} b\nCo-Authored-By: Claude <x@y.z>";
412 assert!(scrub(text, &off).contains('\u{2014}'));
413 assert!(violations(text, &off).is_empty());
414 }
415
416 #[test]
417 fn dash_at_end_of_line_does_not_swallow_the_paragraph_break() {
418 let out = scrub("first line \u{2014}\n\nsecond paragraph", &s());
419 assert!(out.contains("\n\n"), "{out:?}");
420 }
421
422 #[test]
423 fn empty_input_is_empty_output() {
424 assert_eq!("", scrub("", &s()));
425 }
426
427 #[test]
430 fn one_line_flattens() {
431 assert_eq!("a b c", one_line(" a\n\n b\t c "));
432 }
433
434 #[test]
435 fn clip_leaves_short_text_alone() {
436 assert_eq!("short", clip("short", 40));
437 }
438
439 #[test]
440 fn clip_prefers_a_sentence_boundary() {
441 let text = "The loop never terminates. It also leaks a file descriptor on every pass.";
442 assert_eq!("The loop never terminates.", clip(text, 40));
443 }
444
445 #[test]
446 fn clip_falls_back_to_a_word_boundary() {
447 let out = clip("supercalifragilistic wording that runs on and on", 25);
448 assert!(out.ends_with("..."), "{out}");
449 assert!(out.chars().count() <= 25, "{out}");
450 assert!(!out.contains("wording that runs"), "{out}");
451 }
452
453 #[test]
454 fn clip_never_exceeds_the_budget() {
455 for max in 1..60 {
456 let out = clip("one two three four five six seven eight nine ten.", max);
457 assert!(out.chars().count() <= max, "max={max} out={out:?}");
458 }
459 }
460
461 #[test]
462 fn clip_handles_multibyte_text() {
463 let out = clip(&"\u{1f600}".repeat(50), 10);
464 assert!(out.chars().count() <= 10, "{out}");
465 }
466
467 #[test]
472 fn a_period_on_the_budget_boundary_is_not_a_sentence_end() {
473 assert_ne!(
474 "Version 1.",
475 clip("Version 1.4 of the parser mishandles input", 10)
476 );
477 assert_ne!(
478 "Panic in src/style.",
479 clip("Panic in src/style.rs when the budget lands mid word", 19)
480 );
481 }
482
483 #[test]
484 fn an_unmarked_clip_really_did_end_a_sentence() {
485 for max in 4..80 {
488 let text = "First sentence here. Second one follows it. Third trails off";
489 let out = clip(text, max);
490 if out.len() < text.len() && !out.ends_with("...") {
491 assert!(
492 out.ends_with('.') || out.ends_with('!') || out.ends_with('?'),
493 "max={max} out={out:?}"
494 );
495 let next = text[out.len()..].chars().next();
496 assert!(
497 next.is_none_or(|c| c.is_whitespace()),
498 "max={max} cut mid-token before {next:?}: {out:?}"
499 );
500 }
501 }
502 }
503
504 #[test]
505 fn clip_ignores_a_decimal_point_as_a_sentence_end() {
506 let text = "Version 1.4 of the parser mishandles empty input badly and loops.";
507 assert_ne!("Version 1.", clip(text, 30));
508 }
509
510 #[test]
511 fn empty_sections_are_dropped() {
512 let out = strip_empty_sections("## Context\n\n## Proposal\n\nDo the thing.\n");
513 assert!(!out.contains("Context"), "{out}");
514 assert!(out.contains("Do the thing."), "{out}");
515 }
516
517 #[test]
518 fn a_lone_label_heading_is_dropped() {
519 assert_eq!(
520 "The retry never fires.",
521 strip_empty_sections("## Summary\n\nThe retry never fires.")
522 );
523 }
524
525 #[test]
526 fn real_headings_survive_when_there_are_several() {
527 let text = "## Summary\n\nA thing.\n\n## Repro\n\nRun it.";
528 let out = strip_empty_sections(text);
529 assert!(
530 out.contains("## Summary") && out.contains("## Repro"),
531 "{out}"
532 );
533 }
534
535 #[test]
536 fn terse_off_leaves_length_alone() {
537 let loose = Style {
538 terse: false,
539 ..s()
540 };
541 let long = "word ".repeat(400);
542 assert_eq!(long.trim(), detail(&long, &loose));
543 }
544
545 #[test]
546 fn detail_is_capped_and_single_line() {
547 let out = detail(
548 &format!("first line\nsecond line\n{}", "filler ".repeat(200)),
549 &s(),
550 );
551 assert!(!out.contains('\n'));
552 assert!(out.chars().count() <= s().max_detail_chars);
553 }
554
555 #[test]
556 fn title_is_capped_and_single_line() {
557 let out = title(
558 "a very\nlong\ttitle that keeps going ".repeat(20).as_str(),
559 &s(),
560 );
561 assert!(!out.contains('\n'));
562 assert!(out.chars().count() <= s().max_title_chars);
563 }
564
565 #[test]
566 fn body_keeps_structure_but_bounds_length() {
567 let text = format!(
568 "## Summary\n\nreal content here.\n\n{}",
569 "more prose. ".repeat(300)
570 );
571 let out = body(&text, &s());
572 assert!(
573 out.chars().count() <= s().max_body_chars,
574 "{}",
575 out.chars().count()
576 );
577 assert!(out.contains("real content here"), "{out}");
578 }
579}
580
581#[cfg(test)]
582mod sentence_tests {
583 use super::*;
584
585 #[test]
586 fn a_fragment_reads_as_a_sentence() {
587 assert_eq!(
588 "The caller already validates it.",
589 sentence("the caller already validates it.", &Style::default())
590 );
591 }
592
593 #[test]
594 fn an_already_capitalised_one_is_untouched() {
595 assert_eq!(
596 "Already fine.",
597 sentence("Already fine.", &Style::default())
598 );
599 }
600
601 #[test]
602 fn empty_stays_empty_rather_than_panicking() {
603 assert_eq!("", sentence(" ", &Style::default()));
604 }
605
606 #[test]
607 fn a_multibyte_first_character_does_not_panic() {
608 assert_eq!("Ärger", sentence("ärger", &Style::default()));
609 }
610}