Skip to main content

soothfast_report/
changelog.rs

1//! Changelog drafting: API-surface diff + perf deltas rendered as a draft
2//! section, assembled from recorded facts instead of backfilled from memory.
3
4use std::collections::BTreeMap;
5
6use serde_json::Value;
7
8use crate::llms::SurfaceEntry;
9
10/// What the API section has to report.
11///
12/// The two cases are separate types rather than an empty-string convention:
13/// "nothing changed" and "there was nothing to compare against" read the
14/// same in prose but mean opposite things, and a changelog that confuses
15/// them reports calm where it had no input at all.
16pub enum ApiSection<'a> {
17    /// Diff against an earlier release.
18    Diff { against: &'a str, text: &'a str },
19    /// No earlier release exists, so the surface itself is the news.
20    Initial(&'a [SurfaceEntry]),
21}
22
23/// Per-metric movement a delta table is willing to report, as percentages.
24///
25/// These mirror the gate's own thresholds and are passed in rather than
26/// defined here, so the changelog reports exactly what the gate would have
27/// flagged instead of drifting from it.
28///
29/// Only deterministic metrics have thresholds, because a changelog is a
30/// permanent record: walltime moves 15-20% between two runs of identical
31/// code on a shared CI runner, so recording it means rewriting the section
32/// on every merge to say nothing. Walltime regressions are `gate`'s job,
33/// where a human reads the verdict against a live noise floor.
34pub struct PerfThresholds {
35    pub instructions_pct: f64,
36    pub allocs_pct: f64,
37}
38
39/// One merged change, read off a conventional-commit subject.
40pub struct Change {
41    /// Commit type: `feat`, `fix`, `perf`, `docs`, and the rest.
42    pub kind: String,
43    /// Conventional-commit scope, the parenthesised part after the type.
44    pub scope: Option<String>,
45    /// The subject carried the `!` breaking-change marker.
46    pub breaking: bool,
47    /// Subject line with the type prefix and pull request number removed.
48    pub subject: String,
49    /// Pull request the subject ended with, when it named one.
50    pub pr: Option<u32>,
51}
52
53/// Subjects the repo's own bots commit, matched whole. A prefix would be
54/// tidier and would also swallow a real `regenerate ...` feature, which is
55/// the worse failure: a stray bot line is visible, a missing feature is not.
56const BOT_SUBJECTS: [&str; 3] = [
57    "regenerate CHANGELOG.md",
58    "regenerate API specs",
59    "regenerate derived docs",
60];
61
62/// Reader-facing sections, in the order a release renders them. Anything
63/// whose type is missing here lands under the last entry.
64const 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
76/// Parse `type(scope)!: subject (#N)` subjects. Release commits and the bots
77/// that regenerate derived artifacts are dropped, since a release listing
78/// its own paperwork is noise.
79pub 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
87/// Subjects that are not conventional commits of a known type: the ones a
88/// release would otherwise drop in silence.
89pub 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    // A `deps` or `deps-dev` scope names the section itself, whatever type
112    // carries it.
113    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
132/// Inputs already computed by the CLI (API section, two baselines).
133pub struct DraftInputs<'a> {
134    pub api: ApiSection<'a>,
135    /// Merged changes since the reference, newest first.
136    pub changes: &'a [Change],
137    /// Section icons; `Icons::default()` for the shipped set.
138    pub icons: &'a Icons,
139    /// Reference baseline (older) — None renders current-only.
140    pub old_baseline: Option<&'a Value>,
141    pub new_baseline: &'a Value,
142    /// Only consulted when `old_baseline` is Some.
143    pub thresholds: PerfThresholds,
144}
145
146/// Render the "Unreleased" draft section: API surface + perf table.
147pub 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(&sections(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
170/// One `### <emoji> <name>` block per type that has changes, omitting the
171/// rest so an empty section never ships.
172fn 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
206/// Commit subjects are imperative and lowercase; a release note reads as a
207/// list of sentences. A subject opening on an identifier is left alone,
208/// since `soothfast.lock` is not a word to capitalize. `-` is deliberately
209/// not in that set: `cargo-soothfast` and `version-coupled` are the same
210/// shape, and the ordinary word is the commoner case.
211fn 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/// Section icons, keyed by the lowercased section name a release renders
224/// (`breaking changes`, `features`, `fixes`, `performance`, `documentation`,
225/// `dependencies`, `internal`).
226///
227/// A repo overrides any subset through `[changelog.icons]` in its
228/// `soothfast.toml`; whatever it leaves out keeps the shipped default, so a
229/// consumer never has to restate the whole vocabulary to change one glyph.
230#[derive(Debug, Default)]
231pub struct Icons {
232    overrides: BTreeMap<String, String>,
233}
234
235impl Icons {
236    /// Build from configured pairs, rejecting a name that is not a section.
237    /// A typo silently keeping the default is how config drifts from intent.
238    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(&section.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
279/// The API surface block, empty when there is nothing to report.
280fn 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        // An initial surface with nothing in it is a real finding, not an
285        // empty section: the extraction produced nothing to describe.
286        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
294/// The measured-movement table, empty when nothing moved past a threshold.
295fn 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
340/// A diff the surface engine produced no findings for.
341fn is_empty_diff(text: &str) -> bool {
342    text.trim().is_empty() || text.trim() == "surface: no public API changes"
343}
344
345/// Every public item, grouped by crate. Nothing is capped: a first release
346/// listing "the 12 most interesting items" would be a claim about taste,
347/// not a record of what shipped.
348fn 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    /// The values `cargo-soothfast` passes from its gate constants.
386    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    /// A changelog is permanent, so it records only metrics that reproduce.
447    /// Two CI runs of identical code moved walltime -15.2% and +18.2% on
448    /// separate items, which rewrote this section on every merge.
449    #[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    /// The deterministic metrics are the point: they must still land.
470    #[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    /// The bug this section's types exist to prevent: an empty diff and a
493    /// missing reference are different facts and must not read alike.
494    #[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}