vdev/commands/release/
generate_cue.rs

1use std::{
2    env,
3    fmt::Write as _,
4    fs,
5    path::{Path, PathBuf},
6    process::Command,
7};
8
9use anyhow::{Context, Result, anyhow, bail};
10use chrono::Utc;
11use regex::Regex;
12use semver::Version;
13use serde_json::json;
14
15use crate::utils::{git, paths};
16
17const RELEASES_DIR: &str = "website/cue/reference/releases";
18const CHANGELOG_DIR: &str = "changelog.d";
19
20/// Conventional-commit types that require a scope.
21const TYPES_REQUIRING_SCOPES: &[&str] = &["feat", "enhancement", "fix"];
22
23/// Allowed conventional-commit types.
24const ALLOWED_TYPES: &[&str] = &[
25    "chore",
26    "docs",
27    "feat",
28    "fix",
29    "enhancement",
30    "perf",
31    "revert",
32];
33
34/// Generate the release CUE file for the given new version. Returns the path that was written.
35pub(super) fn run(new_version: &Version) -> Result<PathBuf> {
36    let repo_root = paths::find_repo_root()?;
37    env::set_current_dir(&repo_root)?;
38
39    info!("Creating release meta file...");
40
41    let last_version = find_latest_release_tag()?;
42    let commits = fetch_commits_since(&last_version)?;
43
44    validate_single_bump(&last_version, new_version)?;
45    let new_version = new_version.clone();
46
47    let cue_path = repo_root
48        .join(RELEASES_DIR)
49        .join(format!("{new_version}.cue"));
50    if cue_path.exists() {
51        bail!(
52            "{} already exists. Delete it (or move it aside) and re-run.",
53            cue_path.display()
54        );
55    }
56
57    // Drop any commits that have already been recorded in a previous
58    // release CUE file. `--cherry-pick --right-only` only catches
59    // patch-id-equivalent commits, so non-identical backports of the same
60    // change (different SHA, same PR number) can otherwise re-appear in the
61    // next release CUE.
62    let already_released = collect_released_identifiers(&repo_root.join(RELEASES_DIR))?;
63    let commits: Vec<Commit> = commits
64        .into_iter()
65        .filter(|c| {
66            !already_released.shas.contains(&c.sha)
67                && c.pr_number
68                    .is_none_or(|pr| !already_released.pr_numbers.contains(&pr))
69        })
70        .collect();
71
72    if commits.is_empty() {
73        bail!("No commits found since v{last_version}; nothing to release.");
74    }
75
76    for c in &commits {
77        c.validate()?;
78    }
79
80    let changelog_dir = repo_root.join(CHANGELOG_DIR);
81    let changelog_entries = read_changelog_fragments(&changelog_dir)?;
82
83    let cue_text = render_release_cue(&new_version, &changelog_entries, &commits);
84    fs::write(&cue_path, cue_text)
85        .with_context(|| format!("Failed to write {}", cue_path.display()))?;
86
87    // Retire the changelog fragments via `git rm` (preserves README.md).
88    retire_changelog_fragments(&changelog_dir)?;
89
90    // Format with `cue fmt` (best-effort: warn but do not fail if cue is missing).
91    if let Err(e) = run_cue_fmt(&cue_path) {
92        warn!("cue fmt failed (skipping format): {e}");
93    }
94
95    success!("Wrote {}", cue_path.display());
96    Ok(cue_path)
97}
98
99// ---------- Tag / version discovery ----------
100
101/// Set of commit identifiers already recorded in `website/cue/reference/releases/*.cue`.
102struct ReleasedIdentifiers {
103    shas: std::collections::HashSet<String>,
104    pr_numbers: std::collections::HashSet<u64>,
105}
106
107/// Scan every existing release CUE file for the `sha:` and `pr_number:`
108/// fields inside its `commits:` array and return the union as two sets.
109///
110/// We extract via simple regexes rather than running `cue export`. The shape
111/// of these files is well-defined (auto-generated and `cue fmt`-normalised
112/// against `urls.cue`) so this is the cheapest correct option, and avoids a
113/// runtime dependency on the `cue` binary just for de-duplication.
114fn collect_released_identifiers(releases_dir: &Path) -> Result<ReleasedIdentifiers> {
115    let mut out = ReleasedIdentifiers {
116        shas: std::collections::HashSet::new(),
117        pr_numbers: std::collections::HashSet::new(),
118    };
119    if !releases_dir.is_dir() {
120        return Ok(out);
121    }
122    let sha_re = Regex::new(r#"sha:[ \t]*"([0-9a-fA-F]{7,64})""#).unwrap();
123    let pr_re = Regex::new(r"pr_number:[ \t]*([0-9]+)").unwrap();
124    for entry in fs::read_dir(releases_dir)? {
125        let entry = entry?;
126        let path = entry.path();
127        if path.extension().is_none_or(|e| e != "cue") {
128            continue;
129        }
130        let text = fs::read_to_string(&path)
131            .with_context(|| format!("Failed to read {}", path.display()))?;
132        for caps in sha_re.captures_iter(&text) {
133            out.shas.insert(caps[1].to_string());
134        }
135        for caps in pr_re.captures_iter(&text) {
136            if let Ok(n) = caps[1].parse::<u64>() {
137                out.pr_numbers.insert(n);
138            }
139        }
140    }
141    Ok(out)
142}
143
144/// Find the latest semver release tag of the form `vX.Y.Z`, ignoring `vdev-v...` tags.
145pub(super) fn find_latest_release_tag() -> Result<Version> {
146    let tag_re = Regex::new(r"^v[0-9]+\.[0-9]+\.[0-9]+$").unwrap();
147    let output = git::run_and_check_output(&["tag", "--list", "--sort=-v:refname"])?;
148    for tag in output.lines() {
149        if tag.starts_with("vdev-v") {
150            continue;
151        }
152        if tag_re.is_match(tag) {
153            let v = Version::parse(tag.trim_start_matches('v'))
154                .with_context(|| format!("Failed to parse version from tag {tag}"))?;
155            return Ok(v);
156        }
157    }
158    bail!("No valid semantic version tag found (e.g. v1.2.3)")
159}
160
161fn validate_single_bump(last: &Version, new: &Version) -> Result<()> {
162    if bump_type(last, new).is_none() {
163        bail!(
164            "The specified version '{new}' must be a single patch, minor, or major bump from {last}"
165        );
166    }
167    Ok(())
168}
169
170/// Returns Some("patch"|"minor"|"major") if `new` is exactly one bump above `last`, else None.
171fn bump_type(last: &Version, new: &Version) -> Option<&'static str> {
172    if new <= last {
173        return None;
174    }
175    let patch = Version::new(last.major, last.minor, last.patch + 1);
176    let minor = Version::new(last.major, last.minor + 1, 0);
177    let major = if last.major == 0 {
178        Version::new(0, last.minor + 1, 0)
179    } else {
180        Version::new(last.major + 1, 0, 0)
181    };
182    if *new == patch {
183        Some("patch")
184    } else if *new == minor {
185        Some("minor")
186    } else if *new == major {
187        Some("major")
188    } else {
189        None
190    }
191}
192
193// ---------- Commit fetching / parsing ----------
194
195#[derive(Debug, Clone)]
196struct Commit {
197    sha: String,
198    author: String,
199    date: String,
200    description: String,
201    r#type: Option<String>,
202    scopes: Vec<String>,
203    breaking_change: bool,
204    pr_number: Option<u64>,
205    files_count: u64,
206    insertions_count: u64,
207    deletions_count: u64,
208}
209
210impl Commit {
211    fn validate(&self) -> Result<()> {
212        // The release path *must* refuse to write a release CUE that contains
213        // commits whose subject didn't match the conventional-commit format —
214        // otherwise a malformed PR title slips silently into the published
215        // release notes. The Ruby release flow used a strict (`!`-suffixed)
216        // parser at this point for the same reason.
217        let Some(t) = self.r#type.as_deref() else {
218            bail!(
219                "Commit {} ({}) does not match the conventional-commit format \
220                 (`type(scope): description (#pr)`); fix the PR title or amend \
221                 the commit subject before tagging the release.",
222                self.sha,
223                self.description
224            );
225        };
226        if !ALLOWED_TYPES.contains(&t) {
227            bail!(
228                "Commit {} has invalid type '{}'. Allowed types: {:?}",
229                self.sha,
230                t,
231                ALLOWED_TYPES
232            );
233        }
234        if TYPES_REQUIRING_SCOPES.contains(&t) && self.scopes.is_empty() {
235            bail!(
236                "Commit {} of type '{}' requires a scope. Description: {}",
237                self.sha,
238                t,
239                self.description
240            );
241        }
242        Ok(())
243    }
244
245    fn render_cue(&self) -> String {
246        let scopes_json = serde_json::to_string(&self.scopes).expect("scopes serialise");
247        let pr_number = match self.pr_number {
248            Some(n) => n.to_string(),
249            None => "null".to_string(),
250        };
251        let type_json = match &self.r#type {
252            Some(t) => serde_json::to_string(t).unwrap(),
253            None => "null".to_string(),
254        };
255        format!(
256            "{{sha: {sha}, date: {date}, description: {description}, pr_number: {pr_number}, scopes: {scopes}, type: {type_field}, breaking_change: {breaking}, author: {author}, files_count: {files}, insertions_count: {ins}, deletions_count: {del}}}",
257            sha = json!(self.sha),
258            date = json!(self.date),
259            description = json!(self.description),
260            scopes = scopes_json,
261            type_field = type_json,
262            breaking = self.breaking_change,
263            author = json!(self.author),
264            files = self.files_count,
265            ins = self.insertions_count,
266            del = self.deletions_count,
267        )
268    }
269}
270
271fn fetch_commits_since(last_version: &Version) -> Result<Vec<Commit>> {
272    // Use the three-dot symmetric-difference range so `--cherry-pick`
273    // / `--right-only` filter out commits already released on the previous
274    // tag's branch (matches the Ruby `v#{last_version}...` form).
275    let range = format!("v{last_version}...");
276    let log_output = git::run_and_check_output(&[
277        "log",
278        &range,
279        "--cherry-pick",
280        "--right-only",
281        "--no-merges",
282        "--pretty=format:%H\t%s\t%aN\t%aI",
283    ])?;
284
285    let mut commits: Vec<Commit> = Vec::new();
286    for line in log_output.lines().rev() {
287        let parts: Vec<&str> = line.splitn(4, '\t').collect();
288        if parts.len() != 4 {
289            warn!("Skipping unparsable git log line: {line}");
290            continue;
291        }
292        let sha = parts[0].to_string();
293        let message = parts[1];
294        let author = parts[2].to_string();
295        let date = format_commit_date(parts[3]);
296        let conv = ConventionalParts::parse(message);
297        let (files, ins, del) = commit_stats(&sha)?;
298
299        commits.push(Commit {
300            sha,
301            author,
302            date,
303            description: conv.description,
304            r#type: conv.r#type,
305            scopes: conv.scopes,
306            breaking_change: conv.breaking_change,
307            pr_number: conv.pr_number,
308            files_count: files,
309            insertions_count: ins,
310            deletions_count: del,
311        });
312    }
313    Ok(commits)
314}
315
316/// Convert an ISO-8601 commit date (`%aI`) to the "YYYY-MM-DD HH:MM:SS UTC" form
317/// used in existing release CUE files.
318fn format_commit_date(iso: &str) -> String {
319    chrono::DateTime::parse_from_rfc3339(iso).map_or_else(
320        |_| iso.to_string(),
321        |dt| {
322            dt.with_timezone(&Utc)
323                .format("%Y-%m-%d %H:%M:%S UTC")
324                .to_string()
325        },
326    )
327}
328
329/// Returns `(files_changed, insertions, deletions)` from `git show --shortstat`.
330fn commit_stats(sha: &str) -> Result<(u64, u64, u64)> {
331    let out = git::run_and_check_output(&["show", "--shortstat", "--oneline", sha])?;
332    let stats_line = out.lines().last().unwrap_or("");
333    if !stats_line.contains("file") {
334        return Ok((0, 0, 0));
335    }
336    let mut files = 0u64;
337    let mut ins = 0u64;
338    let mut del = 0u64;
339    for part in stats_line.split(',') {
340        let part = part.trim();
341        let count: u64 = part
342            .split_whitespace()
343            .next()
344            .and_then(|n| n.parse().ok())
345            .unwrap_or(0);
346        if part.contains("insertion") {
347            ins = count;
348        } else if part.contains("deletion") {
349            del = count;
350        } else if part.contains("file") {
351            files = count;
352        }
353    }
354    Ok((files, ins, del))
355}
356
357#[derive(Debug)]
358struct ConventionalParts {
359    r#type: Option<String>,
360    scopes: Vec<String>,
361    breaking_change: bool,
362    description: String,
363    pr_number: Option<u64>,
364}
365
366impl ConventionalParts {
367    fn parse(message: &str) -> Self {
368        let re = Regex::new(
369            r"^(?P<type>[a-z]*)(\((?P<scope>[a-zA-Z0-9_, ]*)\))?(?P<breaking>!)?: (?P<desc>.*?)( \(#(?P<pr>[0-9]+)\))?$",
370        )
371        .unwrap();
372
373        if let Some(caps) = re.captures(message) {
374            let r#type = caps
375                .name("type")
376                .map(|m| m.as_str().to_string())
377                .filter(|s| !s.is_empty());
378            let scopes: Vec<String> = caps
379                .name("scope")
380                .map(|m| {
381                    m.as_str()
382                        .split(',')
383                        .map(|s| s.trim().to_string())
384                        .filter(|s| !s.is_empty())
385                        .collect()
386                })
387                .unwrap_or_default();
388            let breaking_change = caps.name("breaking").is_some();
389            let description = caps
390                .name("desc")
391                .map(|m| m.as_str().to_string())
392                .unwrap_or_default();
393            let pr_number = caps.name("pr").and_then(|m| m.as_str().parse::<u64>().ok());
394            ConventionalParts {
395                r#type,
396                scopes,
397                breaking_change,
398                description,
399                pr_number,
400            }
401        } else {
402            ConventionalParts {
403                r#type: None,
404                scopes: Vec::new(),
405                breaking_change: false,
406                description: message.to_string(),
407                pr_number: None,
408            }
409        }
410    }
411}
412
413// ---------- Changelog.d processing ----------
414
415#[derive(Debug)]
416struct ChangelogEntry {
417    /// Mapped CUE type ("chore" | "fix" | "feat" | "enhancement").
418    cue_type: String,
419    description: String,
420    contributors: Vec<String>,
421}
422
423fn read_changelog_fragments(dir: &Path) -> Result<Vec<ChangelogEntry>> {
424    if !dir.is_dir() {
425        return Ok(Vec::new());
426    }
427    let mut entries = Vec::new();
428    let mut paths: Vec<PathBuf> = fs::read_dir(dir)?
429        .filter_map(|e| e.ok().map(|e| e.path()))
430        .filter(|p| p.extension().is_some_and(|x| x == "md"))
431        .filter(|p| p.file_name().and_then(|n| n.to_str()) != Some("README.md"))
432        .collect();
433    paths.sort();
434    for path in paths {
435        let entry = parse_changelog_fragment(&path)?;
436        entries.push(entry);
437    }
438    Ok(entries)
439}
440
441fn parse_changelog_fragment(path: &Path) -> Result<ChangelogEntry> {
442    let stem = path
443        .file_stem()
444        .and_then(|n| n.to_str())
445        .ok_or_else(|| anyhow!("Bad fragment filename: {}", path.display()))?;
446    let parts: Vec<&str> = stem.split('.').collect();
447    if parts.len() != 2 {
448        bail!(
449            "Changelog fragment {} is invalid (filename must be <name>.<type>.md)",
450            path.display()
451        );
452    }
453    let fragment_type = parts[1];
454    let cue_type = match fragment_type {
455        "breaking" | "deprecation" => "chore",
456        "security" | "fix" => "fix",
457        "feature" => "feat",
458        "enhancement" => "enhancement",
459        other => bail!(
460            "Changelog fragment {} has unrecognized type '{}'",
461            path.display(),
462            other
463        ),
464    };
465
466    let raw =
467        fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
468
469    let mut lines: Vec<&str> = raw.lines().collect();
470    let mut contributors: Vec<String> = Vec::new();
471    if let Some(last) = lines.last()
472        && let Some(rest) = last.strip_prefix("authors: ")
473    {
474        contributors = rest.split_whitespace().map(String::from).collect();
475        lines.pop();
476    }
477    let description = lines.join("\n").trim().to_string();
478
479    Ok(ChangelogEntry {
480        cue_type: cue_type.to_string(),
481        description,
482        contributors,
483    })
484}
485
486fn retire_changelog_fragments(dir: &Path) -> Result<()> {
487    if !dir.is_dir() {
488        return Ok(());
489    }
490    for entry in fs::read_dir(dir)? {
491        let entry = entry?;
492        let path = entry.path();
493        if path.extension().is_none_or(|x| x != "md") {
494            continue;
495        }
496        if path.file_name().and_then(|n| n.to_str()) == Some("README.md") {
497            continue;
498        }
499        let rel = path.strip_prefix(env::current_dir()?).unwrap_or(&path);
500        git::rm(&rel.to_string_lossy())?;
501    }
502    Ok(())
503}
504
505// ---------- CUE rendering ----------
506
507fn render_release_cue(
508    version: &Version,
509    changelog: &[ChangelogEntry],
510    commits: &[Commit],
511) -> String {
512    let date = Utc::now().format("%Y-%m-%d").to_string();
513    let changelog_block = render_changelog(changelog);
514    let commits_block = commits
515        .iter()
516        .map(Commit::render_cue)
517        .collect::<Vec<_>>()
518        .join(",\n    ");
519
520    format!(
521        "package metadata\n\
522         \n\
523         releases: \"{version}\": {{\n\
524         \tdate:     \"{date}\"\n\
525         \n\
526         \twhats_next: []\n\
527         \n\
528         \tchangelog: [\n\
529         {changelog_block}\n\
530         \t]\n\
531         \n\
532         \tcommits: [\n    {commits_block}\n\t]\n\
533         }}\n"
534    )
535}
536
537fn render_changelog(entries: &[ChangelogEntry]) -> String {
538    entries
539        .iter()
540        .map(|e| {
541            let mut s = String::new();
542            s.push_str("\t\t{\n");
543            writeln!(s, "\t\t\ttype: {}", json!(e.cue_type)).unwrap();
544            s.push_str("\t\t\tdescription: #\"\"\"\n");
545            for line in e.description.lines() {
546                writeln!(s, "\t\t\t\t{line}").unwrap();
547            }
548            s.push_str("\t\t\t\t\"\"\"#\n");
549            if !e.contributors.is_empty() {
550                let json_contribs = serde_json::to_string(&e.contributors).unwrap();
551                writeln!(s, "\t\t\tcontributors: {json_contribs}").unwrap();
552            }
553            s.push_str("\t\t}");
554            s
555        })
556        .collect::<Vec<_>>()
557        .join(",\n")
558}
559
560fn run_cue_fmt(path: &Path) -> Result<()> {
561    let status = Command::new("cue").arg("fmt").arg(path).status()?;
562    if !status.success() {
563        bail!("cue fmt exited with {status}");
564    }
565    Ok(())
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn parse_conventional_basic() {
574        let p = ConventionalParts::parse("feat(kafka source): add new metric (#123)");
575        assert_eq!(p.r#type.as_deref(), Some("feat"));
576        assert_eq!(p.scopes, vec!["kafka source".to_string()]);
577        assert!(!p.breaking_change);
578        assert_eq!(p.description, "add new metric");
579        assert_eq!(p.pr_number, Some(123));
580    }
581
582    #[test]
583    fn parse_conventional_breaking() {
584        let p = ConventionalParts::parse("feat(api)!: drop legacy endpoint (#9)");
585        assert_eq!(p.r#type.as_deref(), Some("feat"));
586        assert!(p.breaking_change);
587        assert_eq!(p.description, "drop legacy endpoint");
588        assert_eq!(p.pr_number, Some(9));
589    }
590
591    #[test]
592    fn parse_conventional_multi_scope() {
593        let p = ConventionalParts::parse("fix(a, b): wip");
594        assert_eq!(p.scopes, vec!["a".to_string(), "b".to_string()]);
595        assert_eq!(p.pr_number, None);
596    }
597
598    #[test]
599    fn parse_conventional_uppercase_scope() {
600        // The semantic-PR workflow allows uppercase scopes like `ARC`, so the
601        // release-generation parser must accept them too.
602        let p = ConventionalParts::parse("fix(ARC): tweak retry policy (#456)");
603        assert_eq!(p.r#type.as_deref(), Some("fix"));
604        assert_eq!(p.scopes, vec!["ARC".to_string()]);
605        assert_eq!(p.description, "tweak retry policy");
606        assert_eq!(p.pr_number, Some(456));
607    }
608
609    #[test]
610    fn parse_conventional_unparsable_fallthrough() {
611        let p = ConventionalParts::parse("Merge branch 'foo'");
612        assert!(p.r#type.is_none());
613        assert_eq!(p.description, "Merge branch 'foo'");
614    }
615
616    #[test]
617    fn bump_type_patch_minor_major() {
618        let last = Version::new(1, 2, 3);
619        assert_eq!(bump_type(&last, &Version::new(1, 2, 4)), Some("patch"));
620        assert_eq!(bump_type(&last, &Version::new(1, 3, 0)), Some("minor"));
621        assert_eq!(bump_type(&last, &Version::new(2, 0, 0)), Some("major"));
622        assert_eq!(bump_type(&last, &Version::new(1, 2, 5)), None);
623        assert_eq!(bump_type(&last, &Version::new(1, 2, 3)), None);
624    }
625
626    #[test]
627    fn bump_type_zero_major() {
628        // For 0.x, "major" bump means 0.(x+1).0
629        let last = Version::new(0, 55, 0);
630        assert_eq!(bump_type(&last, &Version::new(0, 55, 1)), Some("patch"));
631        assert_eq!(bump_type(&last, &Version::new(0, 56, 0)), Some("minor"));
632    }
633
634    #[test]
635    fn read_changelog_fragments_maps_types_and_authors() {
636        let tmp = tempfile::tempdir().unwrap();
637        let dir = tmp.path();
638        fs::write(dir.join("README.md"), "ignored").unwrap();
639        fs::write(
640            dir.join("123_my_change.feature.md"),
641            "Adds a thing.\n\nIssue: https://example/123\n\nauthors: alice bob\n",
642        )
643        .unwrap();
644        fs::write(
645            dir.join("legacy_break.breaking.md"),
646            "Removed legacy thing.\n",
647        )
648        .unwrap();
649        fs::write(dir.join("sec.security.md"), "Patched a CVE.\n").unwrap();
650
651        let entries = read_changelog_fragments(dir).unwrap();
652        assert_eq!(entries.len(), 3);
653
654        // Sorted by filename
655        let by_type: Vec<_> = entries.iter().map(|e| e.cue_type.as_str()).collect();
656        assert_eq!(by_type, vec!["feat", "chore", "fix"]);
657
658        let feat = &entries[0];
659        assert_eq!(
660            feat.contributors,
661            vec!["alice".to_string(), "bob".to_string()]
662        );
663        assert!(feat.description.starts_with("Adds a thing."));
664        assert!(!feat.description.contains("authors:"));
665
666        // No-author entries get empty contributor list.
667        assert!(entries[1].contributors.is_empty());
668    }
669
670    #[test]
671    fn read_changelog_fragments_rejects_unknown_type() {
672        let tmp = tempfile::tempdir().unwrap();
673        fs::write(tmp.path().join("foo.bogus.md"), "x").unwrap();
674        assert!(read_changelog_fragments(tmp.path()).is_err());
675    }
676
677    #[test]
678    fn render_release_cue_matches_known_shape() {
679        let entries = vec![
680            ChangelogEntry {
681                cue_type: "feat".into(),
682                description: "Adds a thing.\nMulti-line.".into(),
683                contributors: vec!["alice".into()],
684            },
685            ChangelogEntry {
686                cue_type: "fix".into(),
687                description: "Fixed it.".into(),
688                contributors: vec![],
689            },
690        ];
691        let commits = vec![Commit {
692            sha: "abc123".into(),
693            author: "Pavlos".into(),
694            date: "2026-05-06 12:00:00 UTC".into(),
695            description: "do stuff".into(),
696            r#type: Some("feat".into()),
697            scopes: vec!["kafka source".into()],
698            breaking_change: false,
699            pr_number: Some(42),
700            files_count: 1,
701            insertions_count: 2,
702            deletions_count: 3,
703        }];
704
705        let out = render_release_cue(&Version::new(0, 99, 0), &entries, &commits);
706
707        assert!(out.starts_with("package metadata\n"));
708        assert!(out.contains("releases: \"0.99.0\":"));
709        assert!(out.contains("\twhats_next: []\n"));
710        assert!(out.contains("\t\t\ttype: \"feat\"\n"));
711        assert!(out.contains("\t\t\t\tAdds a thing.\n"));
712        assert!(out.contains("\t\t\t\tMulti-line.\n"));
713        assert!(out.contains("contributors: [\"alice\"]"));
714        // contributors line must NOT appear for the fix entry
715        assert!(out.contains("\t\t\ttype: \"fix\"\n"));
716        // Commit struct rendered inline.
717        assert!(out.contains("sha: \"abc123\""));
718        assert!(out.contains("scopes: [\"kafka source\"]"));
719        assert!(out.contains("pr_number: 42"));
720        assert!(out.contains("files_count: 1"));
721    }
722
723    #[test]
724    fn commit_validate_requires_scope_for_certain_types() {
725        let mut c = Commit {
726            sha: "x".into(),
727            author: "a".into(),
728            date: "d".into(),
729            description: "no scope".into(),
730            r#type: Some("feat".into()),
731            scopes: vec![],
732            breaking_change: false,
733            pr_number: None,
734            files_count: 0,
735            insertions_count: 0,
736            deletions_count: 0,
737        };
738        assert!(c.validate().is_err());
739        c.scopes = vec!["api".into()];
740        assert!(c.validate().is_ok());
741
742        // chore/docs don't need scopes
743        c.r#type = Some("chore".into());
744        c.scopes = vec![];
745        assert!(c.validate().is_ok());
746    }
747
748    #[test]
749    fn commit_validate_rejects_unknown_type() {
750        let c = Commit {
751            sha: "x".into(),
752            author: "a".into(),
753            date: "d".into(),
754            description: "x".into(),
755            r#type: Some("nope".into()),
756            scopes: vec!["a".into()],
757            breaking_change: false,
758            pr_number: None,
759            files_count: 0,
760            insertions_count: 0,
761            deletions_count: 0,
762        };
763        assert!(c.validate().is_err());
764    }
765
766    #[test]
767    fn commit_validate_rejects_unparsable_subject() {
768        // A non-conventional subject must abort the release path rather than
769        // silently land in the published CUE with type=null.
770        let c = Commit {
771            sha: "x".into(),
772            author: "a".into(),
773            date: "d".into(),
774            description: "Merge branch 'foo'".into(),
775            r#type: None,
776            scopes: Vec::new(),
777            breaking_change: false,
778            pr_number: None,
779            files_count: 0,
780            insertions_count: 0,
781            deletions_count: 0,
782        };
783        let err = c.validate().expect_err("must reject unparsable subject");
784        let msg = format!("{err}");
785        assert!(
786            msg.contains("conventional-commit format"),
787            "error should mention the rule: {msg}"
788        );
789    }
790
791    #[test]
792    fn collect_released_identifiers_extracts_shas_and_pr_numbers() {
793        let tmp = tempfile::tempdir().unwrap();
794        // A trimmed-down CUE file shaped like the real release cues.
795        fs::write(
796            tmp.path().join("0.55.0.cue"),
797            r#"package metadata
798
799releases: "0.55.0": {
800    commits: [
801        {sha: "deadbeefcafe", date: "2026-01-01 00:00:00 UTC", pr_number: 42, type: "fix"},
802        {sha: "abc1234567890abc", pr_number: 99},
803    ]
804}
805"#,
806        )
807        .unwrap();
808        // A non-cue file we should ignore.
809        fs::write(tmp.path().join("README.md"), "not a release").unwrap();
810
811        let ids = collect_released_identifiers(tmp.path()).unwrap();
812        assert!(ids.shas.contains("deadbeefcafe"));
813        assert!(ids.shas.contains("abc1234567890abc"));
814        assert!(ids.pr_numbers.contains(&42));
815        assert!(ids.pr_numbers.contains(&99));
816    }
817
818    #[test]
819    fn collect_released_identifiers_handles_missing_dir() {
820        let ids = collect_released_identifiers(Path::new("/nonexistent")).unwrap();
821        assert!(ids.shas.is_empty());
822        assert!(ids.pr_numbers.is_empty());
823    }
824}