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