1use std::collections::BTreeMap;
5
6use serde_json::Value;
7
8use crate::llms::SurfaceEntry;
9
10pub enum ApiSection<'a> {
17 Diff { against: &'a str, text: &'a str },
19 Initial(&'a [SurfaceEntry]),
21}
22
23pub struct PerfThresholds {
35 pub instructions_pct: f64,
36 pub allocs_pct: f64,
37}
38
39pub struct Change {
41 pub kind: String,
43 pub scope: Option<String>,
45 pub breaking: bool,
47 pub subject: String,
49 pub pr: Option<u32>,
51}
52
53const BOT_SUBJECTS: [&str; 3] = [
57 "regenerate CHANGELOG.md",
58 "regenerate API specs",
59 "regenerate derived docs",
60];
61
62const SECTIONS: [(&str, &[&str]); 7] = [
65 ("Breaking changes", &[]),
66 ("Features", &["feat"]),
67 ("Fixes", &["fix"]),
68 ("Performance", &["perf"]),
69 ("Documentation", &["docs"]),
70 ("Dependencies", &["deps"]),
71 ("Internal", &["refactor", "chore", "build", "test", "ci"]),
72];
73
74const BREAKING: &str = "Breaking changes";
75
76pub fn changes_from_subjects(subjects: &[String]) -> Vec<Change> {
80 subjects
81 .iter()
82 .filter_map(|line| parse_subject(line))
83 .filter(|change| !is_bookkeeping(&change.subject))
84 .collect()
85}
86
87pub fn unparsed_subjects(subjects: &[String]) -> Vec<&str> {
90 subjects
91 .iter()
92 .filter(|line| parse_subject(line).is_none())
93 .map(String::as_str)
94 .collect()
95}
96
97fn is_bookkeeping(subject: &str) -> bool {
98 subject.starts_with("release v") || BOT_SUBJECTS.contains(&subject)
99}
100
101fn parse_subject(line: &str) -> Option<Change> {
102 let (prefix, rest) = line.split_once(": ")?;
103 let (prefix, breaking) = match prefix.strip_suffix('!') {
104 Some(bare) => (bare, true),
105 None => (prefix, false),
106 };
107 let (kind, scope) = match prefix.split_once('(') {
108 Some((kind, scope)) => (kind, Some(scope.strip_suffix(')')?)),
109 None => (prefix, None),
110 };
111 let is_deps = scope.is_some_and(|s| s == "deps" || s.starts_with("deps-"));
114 let kind = if is_deps { "deps" } else { kind };
115 if !SECTIONS.iter().any(|(_, kinds)| kinds.contains(&kind)) {
116 return None;
117 }
118 let rest = rest.trim();
119 let (subject, pr) = match rest.rsplit_once(" (#") {
120 Some((head, tail)) => (head, tail.strip_suffix(')').and_then(|n| n.parse().ok())),
121 None => (rest, None),
122 };
123 Some(Change {
124 kind: kind.to_string(),
125 scope: scope.filter(|_| !is_deps).map(str::to_string),
126 breaking,
127 subject: subject.trim().to_string(),
128 pr,
129 })
130}
131
132pub struct DraftInputs<'a> {
134 pub api: ApiSection<'a>,
135 pub changes: &'a [Change],
137 pub icons: &'a Icons,
139 pub old_baseline: Option<&'a Value>,
141 pub new_baseline: &'a Value,
142 pub thresholds: PerfThresholds,
144}
145
146pub fn draft(inputs: &DraftInputs) -> String {
148 let heading = match &inputs.api {
149 ApiSection::Diff { against, .. } => format!("Unreleased (draft vs {against})"),
150 ApiSection::Initial(_) => "Unreleased (initial public surface)".to_string(),
151 };
152 let mut out = format!("## {heading}\n\n");
153 out.push_str(§ions(inputs.changes, inputs.icons));
154
155 let api = api_section(&inputs.api);
156 let perf = perf_section(inputs);
157 if api.is_empty() && perf.is_empty() {
158 out.truncate(out.trim_end().len());
159 out.push('\n');
160 return out;
161 }
162 out.push_str("---\n\n");
163 out.push_str(&api);
164 out.push_str(&perf);
165 out.truncate(out.trim_end().len());
166 out.push('\n');
167 out
168}
169
170fn sections(changes: &[Change], icons: &Icons) -> String {
173 let mut out = String::new();
174 for (name, kinds) in SECTIONS {
175 let mut entries = changes.iter().filter(|c| {
176 if name == BREAKING {
177 c.breaking
178 } else {
179 !c.breaking && kinds.contains(&c.kind.as_str())
180 }
181 });
182 let Some(first) = entries.next() else {
183 continue;
184 };
185 out.push_str(&format!("### {} {name}\n\n", icons.get(name)));
186 for change in std::iter::once(first).chain(entries) {
187 out.push_str(&format!("- {}\n", entry_line(change)));
188 }
189 out.push('\n');
190 }
191 out
192}
193
194fn entry_line(change: &Change) -> String {
195 let subject = sentence_case(&change.subject);
196 let scoped = match &change.scope {
197 Some(scope) => format!("{scope}: {subject}"),
198 None => subject,
199 };
200 match change.pr {
201 Some(pr) => format!("{scoped} (#{pr})"),
202 None => scoped,
203 }
204}
205
206fn sentence_case(subject: &str) -> String {
212 let first_token = subject.split_whitespace().next().unwrap_or_default();
213 if first_token.contains(['.', '_', '(', ':']) {
214 return subject.to_string();
215 }
216 let mut chars = subject.chars();
217 match chars.next() {
218 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
219 None => String::new(),
220 }
221}
222
223#[derive(Debug, Default)]
231pub struct Icons {
232 overrides: BTreeMap<String, String>,
233}
234
235impl Icons {
236 pub fn new(overrides: BTreeMap<String, String>) -> Result<Icons, String> {
239 for name in overrides.keys() {
240 if !SECTIONS
241 .iter()
242 .any(|(section, _)| section.eq_ignore_ascii_case(name))
243 {
244 let known: Vec<&str> = SECTIONS.iter().map(|(s, _)| *s).collect();
245 return Err(format!(
246 "`{name}` is not a changelog section; expected one of {}",
247 known.join(", ").to_lowercase()
248 ));
249 }
250 }
251 Ok(Icons {
252 overrides: overrides
253 .into_iter()
254 .map(|(name, icon)| (name.to_ascii_lowercase(), icon))
255 .collect(),
256 })
257 }
258
259 fn get(&self, section: &str) -> &str {
260 self.overrides
261 .get(§ion.to_ascii_lowercase())
262 .map(String::as_str)
263 .unwrap_or_else(|| default_icon(section))
264 }
265}
266
267fn default_icon(name: &str) -> &'static str {
268 match name {
269 "Breaking changes" => "\u{1F4A5}",
270 "Features" => "\u{2728}",
271 "Fixes" => "\u{1F41B}",
272 "Performance" => "\u{26A1}",
273 "Documentation" => "\u{1F4DD}",
274 "Dependencies" => "\u{1F4E6}",
275 _ => "\u{1F527}",
276 }
277}
278
279fn api_section(api: &ApiSection) -> String {
281 let body = match api {
282 ApiSection::Diff { text, .. } if is_empty_diff(text) => return String::new(),
283 ApiSection::Diff { text, .. } => format!("```\n{}\n```\n", text.trim_end()),
284 ApiSection::Initial([]) => {
287 "No public items found: nothing was extracted to describe.\n".to_string()
288 }
289 ApiSection::Initial(entries) => inventory(entries),
290 };
291 format!("### \u{1F50D} API surface\n\n{body}\n")
292}
293
294fn perf_section(inputs: &DraftInputs) -> String {
296 let mut out = String::new();
297 match inputs.old_baseline {
298 None => {
299 out.push_str(&crate::perf_table::markdown(inputs.new_baseline));
300 }
301 Some(old) => {
302 let t = &inputs.thresholds;
303 let metrics: [(&str, [&str; 2], f64); 2] = [
304 (
305 "instructions",
306 ["perfcnt", "instructions"],
307 t.instructions_pct,
308 ),
309 ("allocs", ["alloc", "allocs"], t.allocs_pct),
310 ];
311 let mut rows = String::new();
312 let empty = serde_json::Map::new();
313 let new_items = inputs.new_baseline["items"].as_object().unwrap_or(&empty);
314 for (id, m) in new_items {
315 for (label, path, threshold) in &metrics {
316 let new_v = m[path[0]][path[1]].as_f64();
317 let old_v = old["items"][id][path[0]][path[1]].as_f64();
318 if let (Some(o), Some(n)) = (old_v, new_v)
319 && o > 0.0
320 {
321 let delta = (n - o) / o * 100.0;
322 if delta.abs() >= *threshold {
323 rows.push_str(&format!(
324 "| `{id}` | {label} | {o:.1} | {n:.1} | {delta:+.1}% |\n"
325 ));
326 }
327 }
328 }
329 }
330 if rows.is_empty() {
331 return String::new();
332 }
333 out.push_str("| item | metric | was | now | delta |\n|---|---|---:|---:|---:|\n");
334 out.push_str(&rows);
335 }
336 }
337 format!("### \u{1F4CA} Gate movement\n\n{out}")
338}
339
340fn is_empty_diff(text: &str) -> bool {
342 text.trim().is_empty() || text.trim() == "surface: no public API changes"
343}
344
345fn inventory(entries: &[SurfaceEntry]) -> String {
349 let mut by_pkg: BTreeMap<&str, Vec<&SurfaceEntry>> = BTreeMap::new();
350 for e in entries {
351 by_pkg.entry(&e.pkg).or_default().push(e);
352 }
353
354 let mut out = format!(
355 "Initial release: {} public items across {} crates.\n",
356 entries.len(),
357 by_pkg.len()
358 );
359 for (pkg, mut items) in by_pkg {
360 items.sort_by(|a, b| a.path.cmp(&b.path));
361 out.push_str(&format!("\n#### `{pkg}` ({})\n\n", items.len()));
362 for e in items {
363 out.push_str(&format!("- `{}` ({})", e.path, e.kind));
364 if !e.summary.trim().is_empty() {
365 out.push_str(&format!(": {}", e.summary.trim()));
366 }
367 out.push('\n');
368 }
369 }
370 out
371}
372
373#[cfg(test)]
374mod tests {
375 use std::collections::BTreeMap;
376
377 use serde_json::json;
378
379 use super::{
380 ApiSection, Change, DraftInputs, Icons, PerfThresholds, changes_from_subjects, draft,
381 sections, unparsed_subjects,
382 };
383 use crate::llms::SurfaceEntry;
384
385 fn gate_thresholds() -> PerfThresholds {
387 PerfThresholds {
388 instructions_pct: 5.0,
389 allocs_pct: 5.0,
390 }
391 }
392
393 fn entry(pkg: &str, path: &str, kind: &str, summary: &str) -> SurfaceEntry {
394 SurfaceEntry {
395 path: path.into(),
396 kind: kind.into(),
397 signature: String::new(),
398 pkg: pkg.into(),
399 summary: summary.into(),
400 docs: String::new(),
401 }
402 }
403
404 fn subjects(lines: &[&str]) -> Vec<String> {
405 lines.iter().map(|l| l.to_string()).collect()
406 }
407
408 #[test]
409 fn drafts_diff_and_deltas() {
410 let old = json!({ "items": { "demo::f": { "perfcnt": { "instructions": 100 } } } });
411 let new = json!({ "items": { "demo::f": { "perfcnt": { "instructions": 90 } } } });
412 let text = draft(&DraftInputs {
413 changes: &[],
414 icons: &Icons::default(),
415 api: ApiSection::Diff {
416 against: "v1.0.0",
417 text: "ADDED demo::g\n",
418 },
419 old_baseline: Some(&old),
420 new_baseline: &new,
421 thresholds: gate_thresholds(),
422 });
423 assert!(text.contains("draft vs v1.0.0"));
424 assert!(text.contains("ADDED demo::g"));
425 assert!(text.contains("-10.0%"));
426 }
427
428 #[test]
429 fn an_unchanged_crates_sentinel_must_not_hide_findings() {
430 let new = json!({ "items": {} });
431 let text = draft(&DraftInputs {
432 changes: &[],
433 icons: &Icons::default(),
434 api: ApiSection::Diff {
435 against: "v1.0.0",
436 text: "# a\nsurface: no public API changes\n\n# b\nADDED b::x\n",
437 },
438 old_baseline: None,
439 new_baseline: &new,
440 thresholds: gate_thresholds(),
441 });
442 assert!(text.contains("ADDED b::x"), "{text}");
443 assert!(!text.contains("No public API changes"), "{text}");
444 }
445
446 #[test]
450 fn walltime_never_reaches_the_table_however_far_it_moved() {
451 let old = json!({ "items": { "demo::f": {
452 "walltime": { "median_ns": 275028.3 }, "alloc": { "allocs": 6 } } } });
453 let new = json!({ "items": { "demo::f": {
454 "walltime": { "median_ns": 324972.0 }, "alloc": { "allocs": 6 } } } });
455 let text = draft(&DraftInputs {
456 changes: &[],
457 icons: &Icons::default(),
458 api: ApiSection::Diff {
459 against: "v1.0.0",
460 text: "",
461 },
462 old_baseline: Some(&old),
463 new_baseline: &new,
464 thresholds: gate_thresholds(),
465 });
466 assert_eq!(text.trim(), "## Unreleased (draft vs v1.0.0)");
467 }
468
469 #[test]
471 fn an_allocation_regression_is_still_reported() {
472 let old = json!({ "items": { "demo::f": {
473 "walltime": { "median_ns": 100.0 }, "alloc": { "allocs": 100 } } } });
474 let new = json!({ "items": { "demo::f": {
475 "walltime": { "median_ns": 200.0 }, "alloc": { "allocs": 120 } } } });
476 let text = draft(&DraftInputs {
477 changes: &[],
478 icons: &Icons::default(),
479 api: ApiSection::Diff {
480 against: "v1.0.0",
481 text: "",
482 },
483 old_baseline: Some(&old),
484 new_baseline: &new,
485 thresholds: gate_thresholds(),
486 });
487 assert!(!text.contains("median_ns"), "{text}");
488 assert!(text.contains("allocs"), "{text}");
489 assert!(text.contains("+20.0%"), "{text}");
490 }
491
492 #[test]
495 fn nothing_to_compare_against_does_not_claim_nothing_changed() {
496 let new = json!({ "items": {} });
497 let unchanged = draft(&DraftInputs {
498 changes: &[],
499 icons: &Icons::default(),
500 api: ApiSection::Diff {
501 against: "v1.0.0",
502 text: "",
503 },
504 old_baseline: None,
505 new_baseline: &new,
506 thresholds: gate_thresholds(),
507 });
508 assert!(!unchanged.contains("API surface"), "{unchanged}");
509 assert!(unchanged.contains("draft vs v1.0.0"), "{unchanged}");
510
511 let items = [entry("demo", "demo::f", "fn", "Does a thing.")];
512 let initial = draft(&DraftInputs {
513 changes: &[],
514 icons: &Icons::default(),
515 api: ApiSection::Initial(&items),
516 old_baseline: None,
517 new_baseline: &new,
518 thresholds: gate_thresholds(),
519 });
520 assert!(initial.contains("API surface"), "{initial}");
521 assert!(initial.contains("initial public surface"), "{initial}");
522 assert!(
523 initial.contains("1 public items across 1 crates"),
524 "{initial}"
525 );
526 assert!(
527 initial.contains("`demo::f` (fn): Does a thing."),
528 "{initial}"
529 );
530 }
531
532 #[test]
533 fn an_empty_initial_surface_says_so_rather_than_looking_calm() {
534 let new = json!({ "items": {} });
535 let text = draft(&DraftInputs {
536 changes: &[],
537 icons: &Icons::default(),
538 api: ApiSection::Initial(&[]),
539 old_baseline: None,
540 new_baseline: &new,
541 thresholds: gate_thresholds(),
542 });
543 assert!(text.contains("No public items found"), "{text}");
544 }
545
546 #[test]
547 fn a_release_with_nothing_to_report_renders_only_its_heading() {
548 let new = json!({ "items": {} });
549 let old = json!({ "items": {} });
550 let text = draft(&DraftInputs {
551 changes: &[],
552 icons: &Icons::default(),
553 api: ApiSection::Diff {
554 against: "v1.0.0",
555 text: "",
556 },
557 old_baseline: Some(&old),
558 new_baseline: &new,
559 thresholds: gate_thresholds(),
560 });
561 assert_eq!(text.trim(), "## Unreleased (draft vs v1.0.0)");
562 }
563
564 #[test]
565 fn subjects_group_by_type_and_carry_their_pull_request() {
566 let changes = changes_from_subjects(&subjects(&[
567 "feat: add gate accept for reviewed regressions (#110)",
568 "fix: sync untracked config into gate worktrees (#113)",
569 "chore: release v0.1.19 (#112)",
570 "docs: regenerate CHANGELOG.md (#115)",
571 "docs: regenerate derived docs (#126)",
572 "not a conventional subject",
573 ]));
574 assert_eq!(changes.len(), 2);
575 assert_eq!(changes[0].kind, "feat");
576 assert_eq!(
577 changes[0].subject,
578 "add gate accept for reviewed regressions"
579 );
580 assert_eq!(changes[0].pr, Some(110));
581 assert_eq!(changes[1].pr, Some(113));
582 }
583
584 #[test]
585 fn scoped_subjects_keep_their_section_and_show_the_scope() {
586 let changes = changes_from_subjects(&subjects(&[
587 "feat(api): add grouped daily bars (#1)",
588 "fix(server): drop a stale cache (#2)",
589 "chore(deps): bump serde (#3)",
590 "build(deps): bump tokio (#4)",
591 "build: switch to lld (#5)",
592 "chore(deps-dev): bump typescript (#6)",
593 ]));
594 let kinds: Vec<&str> = changes.iter().map(|c| c.kind.as_str()).collect();
595 assert_eq!(kinds, ["feat", "fix", "deps", "deps", "build", "deps"]);
596 assert_eq!(changes[0].scope.as_deref(), Some("api"));
597 assert_eq!(changes[2].scope, None);
598 assert_eq!(changes[5].scope, None);
599 let text = sections(&changes, &Icons::default());
600 assert!(text.contains("- api: Add grouped daily bars (#1)"));
601 assert!(text.contains("- server: Drop a stale cache (#2)"));
602 assert!(
603 text.contains("### \u{1F4E6} Dependencies\n\n- Bump serde (#3)\n- Bump tokio (#4)\n- Bump typescript (#6)")
604 );
605 assert!(text.contains("### \u{1F527} Internal\n\n- Switch to lld (#5)"));
606 }
607
608 #[test]
609 fn a_breaking_marker_moves_the_entry_to_its_own_section() {
610 let changes = changes_from_subjects(&subjects(&[
611 "feat!: drop the v1 endpoints (#7)",
612 "fix(api)!: rename the cursor field (#8)",
613 "feat: add a thing (#9)",
614 ]));
615 assert!(changes[0].breaking && changes[1].breaking && !changes[2].breaking);
616 let text = sections(&changes, &Icons::default());
617 let breaking = text.find("Breaking changes").unwrap();
618 let features = text.find("Features").unwrap();
619 assert!(breaking < features);
620 assert!(
621 text.contains("- Drop the v1 endpoints (#7)\n- api: Rename the cursor field (#8)\n")
622 );
623 assert!(!text[features..].contains("v1 endpoints"));
624 }
625
626 #[test]
627 fn unparsed_subjects_are_reported_but_paperwork_is_not() {
628 let lines = subjects(&[
629 "feat: add a thing (#1)",
630 "wip: something",
631 "Merge branch 'x'",
632 "chore: release v0.2.0 (#3)",
633 "docs: regenerate CHANGELOG.md (#4)",
634 ]);
635 assert_eq!(
636 unparsed_subjects(&lines),
637 ["wip: something", "Merge branch 'x'"]
638 );
639 }
640
641 #[test]
642 fn a_configured_icon_replaces_only_the_section_it_names() {
643 let changes = changes_from_subjects(&subjects(&["feat: add a thing", "fix: mend a thing"]));
644 let icons =
645 Icons::new(BTreeMap::from([("features".to_string(), "A".to_string())])).unwrap();
646 let rendered = super::sections(&changes, &icons);
647 assert!(rendered.contains("### A Features"), "{rendered}");
648 assert!(rendered.contains("### \u{1F41B} Fixes"), "{rendered}");
649 }
650
651 #[test]
652 fn a_capitalized_section_name_is_honored_rather_than_quietly_ignored() {
653 let icons =
654 Icons::new(BTreeMap::from([("Features".to_string(), "A".to_string())])).unwrap();
655 let changes = changes_from_subjects(&subjects(&["feat: add a thing"]));
656 let rendered = super::sections(&changes, &icons);
657 assert!(rendered.contains("### A Features"), "{rendered}");
658 }
659
660 #[test]
661 fn dependency_bumps_get_their_own_section_rather_than_vanishing() {
662 let changes = changes_from_subjects(&subjects(&[
663 "deps: bump linkme from 0.3.36 to 0.3.37 (#85)",
664 "deps: bump actions/download-artifact from 4.3.0 to 8.0.1 (#90)",
665 "ci: harden the gate workflow (#91)",
666 ]));
667 let rendered = super::sections(&changes, &Icons::default());
668 assert!(rendered.contains("\u{1F4E6} Dependencies"), "{rendered}");
669 assert!(
670 rendered.contains("- Bump linkme from 0.3.36 to 0.3.37 (#85)"),
671 "{rendered}"
672 );
673 assert!(
674 rendered.contains("- Bump actions/download-artifact from 4.3.0 to 8.0.1 (#90)"),
675 "{rendered}"
676 );
677 let deps = rendered.find("Dependencies").unwrap();
678 let internal = rendered.find("Internal").unwrap();
679 assert!(deps < internal, "dependencies should precede internal");
680 assert!(
681 rendered.contains("- Harden the gate workflow (#91)"),
682 "{rendered}"
683 );
684 }
685
686 #[test]
687 fn a_real_feature_that_regenerates_something_is_not_mistaken_for_a_bot() {
688 let changes = changes_from_subjects(&subjects(&[
689 "feat: regenerate SDK clients on a spec change",
690 "docs: regenerate derived docs (#126)",
691 "chore: regenerate API specs (#99)",
692 ]));
693 assert_eq!(changes.len(), 1);
694 assert_eq!(
695 changes[0].subject,
696 "regenerate SDK clients on a spec change"
697 );
698 }
699
700 #[test]
701 fn an_icon_for_something_that_is_not_a_section_is_rejected() {
702 let bad = Icons::new(BTreeMap::from([("feature".to_string(), "A".to_string())]));
703 assert!(bad.unwrap_err().contains("not a changelog section"));
704 }
705
706 #[test]
707 fn a_subject_opening_on_an_identifier_keeps_its_case() {
708 let changes = changes_from_subjects(&subjects(&[
709 "fix: soothfast.lock moves to v2",
710 "fix: keep_alive is honored",
711 "fix: resume changelog regen",
712 ]));
713 let rendered = super::sections(&changes, &Icons::default());
714 assert!(
715 rendered.contains("- soothfast.lock moves to v2"),
716 "{rendered}"
717 );
718 assert!(rendered.contains("- keep_alive is honored"), "{rendered}");
719 assert!(rendered.contains("- Resume changelog regen"), "{rendered}");
720 }
721
722 #[test]
723 fn a_subject_without_a_pull_request_still_lists() {
724 let changes = changes_from_subjects(&subjects(&["fix: land a direct commit"]));
725 assert_eq!(changes.len(), 1);
726 assert_eq!(changes[0].pr, None);
727 }
728
729 #[test]
730 fn only_the_types_with_changes_get_a_section() {
731 let new = json!({ "items": {} });
732 let changes = vec![Change {
733 kind: "fix".into(),
734 scope: None,
735 breaking: false,
736 subject: "stop hashing comments".into(),
737 pr: Some(124),
738 }];
739 let text = draft(&DraftInputs {
740 changes: &changes,
741 icons: &Icons::default(),
742 api: ApiSection::Diff {
743 against: "v1.0.0",
744 text: "",
745 },
746 old_baseline: None,
747 new_baseline: &new,
748 thresholds: gate_thresholds(),
749 });
750 assert!(text.contains("Fixes"), "{text}");
751 assert!(text.contains("- Stop hashing comments (#124)"), "{text}");
752 assert!(!text.contains("Features"), "{text}");
753 assert!(!text.contains("Internal"), "{text}");
754 }
755}