use std::collections::BTreeMap;
use serde_json::Value;
use crate::llms::SurfaceEntry;
pub enum ApiSection<'a> {
Diff { against: &'a str, text: &'a str },
Initial(&'a [SurfaceEntry]),
}
pub struct PerfThresholds {
pub instructions_pct: f64,
pub allocs_pct: f64,
}
pub struct Change {
pub kind: String,
pub scope: Option<String>,
pub breaking: bool,
pub subject: String,
pub pr: Option<u32>,
}
const BOT_SUBJECTS: [&str; 3] = [
"regenerate CHANGELOG.md",
"regenerate API specs",
"regenerate derived docs",
];
const SECTIONS: [(&str, &[&str]); 7] = [
("Breaking changes", &[]),
("Features", &["feat"]),
("Fixes", &["fix"]),
("Performance", &["perf"]),
("Documentation", &["docs"]),
("Dependencies", &["deps"]),
("Internal", &["refactor", "chore", "build", "test", "ci"]),
];
const BREAKING: &str = "Breaking changes";
pub fn changes_from_subjects(subjects: &[String]) -> Vec<Change> {
subjects
.iter()
.filter_map(|line| parse_subject(line))
.filter(|change| !is_bookkeeping(&change.subject))
.collect()
}
pub fn unparsed_subjects(subjects: &[String]) -> Vec<&str> {
subjects
.iter()
.filter(|line| parse_subject(line).is_none())
.map(String::as_str)
.collect()
}
fn is_bookkeeping(subject: &str) -> bool {
subject.starts_with("release v") || BOT_SUBJECTS.contains(&subject)
}
fn parse_subject(line: &str) -> Option<Change> {
let (prefix, rest) = line.split_once(": ")?;
let (prefix, breaking) = match prefix.strip_suffix('!') {
Some(bare) => (bare, true),
None => (prefix, false),
};
let (kind, scope) = match prefix.split_once('(') {
Some((kind, scope)) => (kind, Some(scope.strip_suffix(')')?)),
None => (prefix, None),
};
let is_deps = scope.is_some_and(|s| s == "deps" || s.starts_with("deps-"));
let kind = if is_deps { "deps" } else { kind };
if !SECTIONS.iter().any(|(_, kinds)| kinds.contains(&kind)) {
return None;
}
let rest = rest.trim();
let (subject, pr) = match rest.rsplit_once(" (#") {
Some((head, tail)) => (head, tail.strip_suffix(')').and_then(|n| n.parse().ok())),
None => (rest, None),
};
Some(Change {
kind: kind.to_string(),
scope: scope.filter(|_| !is_deps).map(str::to_string),
breaking,
subject: subject.trim().to_string(),
pr,
})
}
pub struct DraftInputs<'a> {
pub api: ApiSection<'a>,
pub changes: &'a [Change],
pub icons: &'a Icons,
pub old_baseline: Option<&'a Value>,
pub new_baseline: &'a Value,
pub thresholds: PerfThresholds,
}
pub fn draft(inputs: &DraftInputs) -> String {
let heading = match &inputs.api {
ApiSection::Diff { against, .. } => format!("Unreleased (draft vs {against})"),
ApiSection::Initial(_) => "Unreleased (initial public surface)".to_string(),
};
let mut out = format!("## {heading}\n\n");
out.push_str(§ions(inputs.changes, inputs.icons));
let api = api_section(&inputs.api);
let perf = perf_section(inputs);
if api.is_empty() && perf.is_empty() {
out.truncate(out.trim_end().len());
out.push('\n');
return out;
}
out.push_str("---\n\n");
out.push_str(&api);
out.push_str(&perf);
out.truncate(out.trim_end().len());
out.push('\n');
out
}
fn sections(changes: &[Change], icons: &Icons) -> String {
let mut out = String::new();
for (name, kinds) in SECTIONS {
let mut entries = changes.iter().filter(|c| {
if name == BREAKING {
c.breaking
} else {
!c.breaking && kinds.contains(&c.kind.as_str())
}
});
let Some(first) = entries.next() else {
continue;
};
out.push_str(&format!("### {} {name}\n\n", icons.get(name)));
for change in std::iter::once(first).chain(entries) {
out.push_str(&format!("- {}\n", entry_line(change)));
}
out.push('\n');
}
out
}
fn entry_line(change: &Change) -> String {
let subject = sentence_case(&change.subject);
let scoped = match &change.scope {
Some(scope) => format!("{scope}: {subject}"),
None => subject,
};
match change.pr {
Some(pr) => format!("{scoped} (#{pr})"),
None => scoped,
}
}
fn sentence_case(subject: &str) -> String {
let first_token = subject.split_whitespace().next().unwrap_or_default();
if first_token.contains(['.', '_', '(', ':']) {
return subject.to_string();
}
let mut chars = subject.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
#[derive(Debug, Default)]
pub struct Icons {
overrides: BTreeMap<String, String>,
}
impl Icons {
pub fn new(overrides: BTreeMap<String, String>) -> Result<Icons, String> {
for name in overrides.keys() {
if !SECTIONS
.iter()
.any(|(section, _)| section.eq_ignore_ascii_case(name))
{
let known: Vec<&str> = SECTIONS.iter().map(|(s, _)| *s).collect();
return Err(format!(
"`{name}` is not a changelog section; expected one of {}",
known.join(", ").to_lowercase()
));
}
}
Ok(Icons {
overrides: overrides
.into_iter()
.map(|(name, icon)| (name.to_ascii_lowercase(), icon))
.collect(),
})
}
fn get(&self, section: &str) -> &str {
self.overrides
.get(§ion.to_ascii_lowercase())
.map(String::as_str)
.unwrap_or_else(|| default_icon(section))
}
}
fn default_icon(name: &str) -> &'static str {
match name {
"Breaking changes" => "\u{1F4A5}",
"Features" => "\u{2728}",
"Fixes" => "\u{1F41B}",
"Performance" => "\u{26A1}",
"Documentation" => "\u{1F4DD}",
"Dependencies" => "\u{1F4E6}",
_ => "\u{1F527}",
}
}
fn api_section(api: &ApiSection) -> String {
let body = match api {
ApiSection::Diff { text, .. } if is_empty_diff(text) => return String::new(),
ApiSection::Diff { text, .. } => format!("```\n{}\n```\n", text.trim_end()),
ApiSection::Initial([]) => {
"No public items found: nothing was extracted to describe.\n".to_string()
}
ApiSection::Initial(entries) => inventory(entries),
};
format!("### \u{1F50D} API surface\n\n{body}\n")
}
fn perf_section(inputs: &DraftInputs) -> String {
let mut out = String::new();
match inputs.old_baseline {
None => {
out.push_str(&crate::perf_table::markdown(inputs.new_baseline));
}
Some(old) => {
let t = &inputs.thresholds;
let metrics: [(&str, [&str; 2], f64); 2] = [
(
"instructions",
["perfcnt", "instructions"],
t.instructions_pct,
),
("allocs", ["alloc", "allocs"], t.allocs_pct),
];
let mut rows = String::new();
let empty = serde_json::Map::new();
let new_items = inputs.new_baseline["items"].as_object().unwrap_or(&empty);
for (id, m) in new_items {
for (label, path, threshold) in &metrics {
let new_v = m[path[0]][path[1]].as_f64();
let old_v = old["items"][id][path[0]][path[1]].as_f64();
if let (Some(o), Some(n)) = (old_v, new_v)
&& o > 0.0
{
let delta = (n - o) / o * 100.0;
if delta.abs() >= *threshold {
rows.push_str(&format!(
"| `{id}` | {label} | {o:.1} | {n:.1} | {delta:+.1}% |\n"
));
}
}
}
}
if rows.is_empty() {
return String::new();
}
out.push_str("| item | metric | was | now | delta |\n|---|---|---:|---:|---:|\n");
out.push_str(&rows);
}
}
format!("### \u{1F4CA} Gate movement\n\n{out}")
}
fn is_empty_diff(text: &str) -> bool {
text.trim().is_empty() || text.trim() == "surface: no public API changes"
}
fn inventory(entries: &[SurfaceEntry]) -> String {
let mut by_pkg: BTreeMap<&str, Vec<&SurfaceEntry>> = BTreeMap::new();
for e in entries {
by_pkg.entry(&e.pkg).or_default().push(e);
}
let mut out = format!(
"Initial release: {} public items across {} crates.\n",
entries.len(),
by_pkg.len()
);
for (pkg, mut items) in by_pkg {
items.sort_by(|a, b| a.path.cmp(&b.path));
out.push_str(&format!("\n#### `{pkg}` ({})\n\n", items.len()));
for e in items {
out.push_str(&format!("- `{}` ({})", e.path, e.kind));
if !e.summary.trim().is_empty() {
out.push_str(&format!(": {}", e.summary.trim()));
}
out.push('\n');
}
}
out
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use serde_json::json;
use super::{
ApiSection, Change, DraftInputs, Icons, PerfThresholds, changes_from_subjects, draft,
sections, unparsed_subjects,
};
use crate::llms::SurfaceEntry;
fn gate_thresholds() -> PerfThresholds {
PerfThresholds {
instructions_pct: 5.0,
allocs_pct: 5.0,
}
}
fn entry(pkg: &str, path: &str, kind: &str, summary: &str) -> SurfaceEntry {
SurfaceEntry {
path: path.into(),
kind: kind.into(),
signature: String::new(),
pkg: pkg.into(),
summary: summary.into(),
docs: String::new(),
}
}
fn subjects(lines: &[&str]) -> Vec<String> {
lines.iter().map(|l| l.to_string()).collect()
}
#[test]
fn drafts_diff_and_deltas() {
let old = json!({ "items": { "demo::f": { "perfcnt": { "instructions": 100 } } } });
let new = json!({ "items": { "demo::f": { "perfcnt": { "instructions": 90 } } } });
let text = draft(&DraftInputs {
changes: &[],
icons: &Icons::default(),
api: ApiSection::Diff {
against: "v1.0.0",
text: "ADDED demo::g\n",
},
old_baseline: Some(&old),
new_baseline: &new,
thresholds: gate_thresholds(),
});
assert!(text.contains("draft vs v1.0.0"));
assert!(text.contains("ADDED demo::g"));
assert!(text.contains("-10.0%"));
}
#[test]
fn an_unchanged_crates_sentinel_must_not_hide_findings() {
let new = json!({ "items": {} });
let text = draft(&DraftInputs {
changes: &[],
icons: &Icons::default(),
api: ApiSection::Diff {
against: "v1.0.0",
text: "# a\nsurface: no public API changes\n\n# b\nADDED b::x\n",
},
old_baseline: None,
new_baseline: &new,
thresholds: gate_thresholds(),
});
assert!(text.contains("ADDED b::x"), "{text}");
assert!(!text.contains("No public API changes"), "{text}");
}
#[test]
fn walltime_never_reaches_the_table_however_far_it_moved() {
let old = json!({ "items": { "demo::f": {
"walltime": { "median_ns": 275028.3 }, "alloc": { "allocs": 6 } } } });
let new = json!({ "items": { "demo::f": {
"walltime": { "median_ns": 324972.0 }, "alloc": { "allocs": 6 } } } });
let text = draft(&DraftInputs {
changes: &[],
icons: &Icons::default(),
api: ApiSection::Diff {
against: "v1.0.0",
text: "",
},
old_baseline: Some(&old),
new_baseline: &new,
thresholds: gate_thresholds(),
});
assert_eq!(text.trim(), "## Unreleased (draft vs v1.0.0)");
}
#[test]
fn an_allocation_regression_is_still_reported() {
let old = json!({ "items": { "demo::f": {
"walltime": { "median_ns": 100.0 }, "alloc": { "allocs": 100 } } } });
let new = json!({ "items": { "demo::f": {
"walltime": { "median_ns": 200.0 }, "alloc": { "allocs": 120 } } } });
let text = draft(&DraftInputs {
changes: &[],
icons: &Icons::default(),
api: ApiSection::Diff {
against: "v1.0.0",
text: "",
},
old_baseline: Some(&old),
new_baseline: &new,
thresholds: gate_thresholds(),
});
assert!(!text.contains("median_ns"), "{text}");
assert!(text.contains("allocs"), "{text}");
assert!(text.contains("+20.0%"), "{text}");
}
#[test]
fn nothing_to_compare_against_does_not_claim_nothing_changed() {
let new = json!({ "items": {} });
let unchanged = draft(&DraftInputs {
changes: &[],
icons: &Icons::default(),
api: ApiSection::Diff {
against: "v1.0.0",
text: "",
},
old_baseline: None,
new_baseline: &new,
thresholds: gate_thresholds(),
});
assert!(!unchanged.contains("API surface"), "{unchanged}");
assert!(unchanged.contains("draft vs v1.0.0"), "{unchanged}");
let items = [entry("demo", "demo::f", "fn", "Does a thing.")];
let initial = draft(&DraftInputs {
changes: &[],
icons: &Icons::default(),
api: ApiSection::Initial(&items),
old_baseline: None,
new_baseline: &new,
thresholds: gate_thresholds(),
});
assert!(initial.contains("API surface"), "{initial}");
assert!(initial.contains("initial public surface"), "{initial}");
assert!(
initial.contains("1 public items across 1 crates"),
"{initial}"
);
assert!(
initial.contains("`demo::f` (fn): Does a thing."),
"{initial}"
);
}
#[test]
fn an_empty_initial_surface_says_so_rather_than_looking_calm() {
let new = json!({ "items": {} });
let text = draft(&DraftInputs {
changes: &[],
icons: &Icons::default(),
api: ApiSection::Initial(&[]),
old_baseline: None,
new_baseline: &new,
thresholds: gate_thresholds(),
});
assert!(text.contains("No public items found"), "{text}");
}
#[test]
fn a_release_with_nothing_to_report_renders_only_its_heading() {
let new = json!({ "items": {} });
let old = json!({ "items": {} });
let text = draft(&DraftInputs {
changes: &[],
icons: &Icons::default(),
api: ApiSection::Diff {
against: "v1.0.0",
text: "",
},
old_baseline: Some(&old),
new_baseline: &new,
thresholds: gate_thresholds(),
});
assert_eq!(text.trim(), "## Unreleased (draft vs v1.0.0)");
}
#[test]
fn subjects_group_by_type_and_carry_their_pull_request() {
let changes = changes_from_subjects(&subjects(&[
"feat: add gate accept for reviewed regressions (#110)",
"fix: sync untracked config into gate worktrees (#113)",
"chore: release v0.1.19 (#112)",
"docs: regenerate CHANGELOG.md (#115)",
"docs: regenerate derived docs (#126)",
"not a conventional subject",
]));
assert_eq!(changes.len(), 2);
assert_eq!(changes[0].kind, "feat");
assert_eq!(
changes[0].subject,
"add gate accept for reviewed regressions"
);
assert_eq!(changes[0].pr, Some(110));
assert_eq!(changes[1].pr, Some(113));
}
#[test]
fn scoped_subjects_keep_their_section_and_show_the_scope() {
let changes = changes_from_subjects(&subjects(&[
"feat(api): add grouped daily bars (#1)",
"fix(server): drop a stale cache (#2)",
"chore(deps): bump serde (#3)",
"build(deps): bump tokio (#4)",
"build: switch to lld (#5)",
"chore(deps-dev): bump typescript (#6)",
]));
let kinds: Vec<&str> = changes.iter().map(|c| c.kind.as_str()).collect();
assert_eq!(kinds, ["feat", "fix", "deps", "deps", "build", "deps"]);
assert_eq!(changes[0].scope.as_deref(), Some("api"));
assert_eq!(changes[2].scope, None);
assert_eq!(changes[5].scope, None);
let text = sections(&changes, &Icons::default());
assert!(text.contains("- api: Add grouped daily bars (#1)"));
assert!(text.contains("- server: Drop a stale cache (#2)"));
assert!(
text.contains("### \u{1F4E6} Dependencies\n\n- Bump serde (#3)\n- Bump tokio (#4)\n- Bump typescript (#6)")
);
assert!(text.contains("### \u{1F527} Internal\n\n- Switch to lld (#5)"));
}
#[test]
fn a_breaking_marker_moves_the_entry_to_its_own_section() {
let changes = changes_from_subjects(&subjects(&[
"feat!: drop the v1 endpoints (#7)",
"fix(api)!: rename the cursor field (#8)",
"feat: add a thing (#9)",
]));
assert!(changes[0].breaking && changes[1].breaking && !changes[2].breaking);
let text = sections(&changes, &Icons::default());
let breaking = text.find("Breaking changes").unwrap();
let features = text.find("Features").unwrap();
assert!(breaking < features);
assert!(
text.contains("- Drop the v1 endpoints (#7)\n- api: Rename the cursor field (#8)\n")
);
assert!(!text[features..].contains("v1 endpoints"));
}
#[test]
fn unparsed_subjects_are_reported_but_paperwork_is_not() {
let lines = subjects(&[
"feat: add a thing (#1)",
"wip: something",
"Merge branch 'x'",
"chore: release v0.2.0 (#3)",
"docs: regenerate CHANGELOG.md (#4)",
]);
assert_eq!(
unparsed_subjects(&lines),
["wip: something", "Merge branch 'x'"]
);
}
#[test]
fn a_configured_icon_replaces_only_the_section_it_names() {
let changes = changes_from_subjects(&subjects(&["feat: add a thing", "fix: mend a thing"]));
let icons =
Icons::new(BTreeMap::from([("features".to_string(), "A".to_string())])).unwrap();
let rendered = super::sections(&changes, &icons);
assert!(rendered.contains("### A Features"), "{rendered}");
assert!(rendered.contains("### \u{1F41B} Fixes"), "{rendered}");
}
#[test]
fn a_capitalized_section_name_is_honored_rather_than_quietly_ignored() {
let icons =
Icons::new(BTreeMap::from([("Features".to_string(), "A".to_string())])).unwrap();
let changes = changes_from_subjects(&subjects(&["feat: add a thing"]));
let rendered = super::sections(&changes, &icons);
assert!(rendered.contains("### A Features"), "{rendered}");
}
#[test]
fn dependency_bumps_get_their_own_section_rather_than_vanishing() {
let changes = changes_from_subjects(&subjects(&[
"deps: bump linkme from 0.3.36 to 0.3.37 (#85)",
"deps: bump actions/download-artifact from 4.3.0 to 8.0.1 (#90)",
"ci: harden the gate workflow (#91)",
]));
let rendered = super::sections(&changes, &Icons::default());
assert!(rendered.contains("\u{1F4E6} Dependencies"), "{rendered}");
assert!(
rendered.contains("- Bump linkme from 0.3.36 to 0.3.37 (#85)"),
"{rendered}"
);
assert!(
rendered.contains("- Bump actions/download-artifact from 4.3.0 to 8.0.1 (#90)"),
"{rendered}"
);
let deps = rendered.find("Dependencies").unwrap();
let internal = rendered.find("Internal").unwrap();
assert!(deps < internal, "dependencies should precede internal");
assert!(
rendered.contains("- Harden the gate workflow (#91)"),
"{rendered}"
);
}
#[test]
fn a_real_feature_that_regenerates_something_is_not_mistaken_for_a_bot() {
let changes = changes_from_subjects(&subjects(&[
"feat: regenerate SDK clients on a spec change",
"docs: regenerate derived docs (#126)",
"chore: regenerate API specs (#99)",
]));
assert_eq!(changes.len(), 1);
assert_eq!(
changes[0].subject,
"regenerate SDK clients on a spec change"
);
}
#[test]
fn an_icon_for_something_that_is_not_a_section_is_rejected() {
let bad = Icons::new(BTreeMap::from([("feature".to_string(), "A".to_string())]));
assert!(bad.unwrap_err().contains("not a changelog section"));
}
#[test]
fn a_subject_opening_on_an_identifier_keeps_its_case() {
let changes = changes_from_subjects(&subjects(&[
"fix: soothfast.lock moves to v2",
"fix: keep_alive is honored",
"fix: resume changelog regen",
]));
let rendered = super::sections(&changes, &Icons::default());
assert!(
rendered.contains("- soothfast.lock moves to v2"),
"{rendered}"
);
assert!(rendered.contains("- keep_alive is honored"), "{rendered}");
assert!(rendered.contains("- Resume changelog regen"), "{rendered}");
}
#[test]
fn a_subject_without_a_pull_request_still_lists() {
let changes = changes_from_subjects(&subjects(&["fix: land a direct commit"]));
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].pr, None);
}
#[test]
fn only_the_types_with_changes_get_a_section() {
let new = json!({ "items": {} });
let changes = vec![Change {
kind: "fix".into(),
scope: None,
breaking: false,
subject: "stop hashing comments".into(),
pr: Some(124),
}];
let text = draft(&DraftInputs {
changes: &changes,
icons: &Icons::default(),
api: ApiSection::Diff {
against: "v1.0.0",
text: "",
},
old_baseline: None,
new_baseline: &new,
thresholds: gate_thresholds(),
});
assert!(text.contains("Fixes"), "{text}");
assert!(text.contains("- Stop hashing comments (#124)"), "{text}");
assert!(!text.contains("Features"), "{text}");
assert!(!text.contains("Internal"), "{text}");
}
}