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
20const TYPES_REQUIRING_SCOPES: &[&str] = &["feat", "enhancement", "fix"];
22
23const ALLOWED_TYPES: &[&str] = &[
25 "chore",
26 "docs",
27 "feat",
28 "fix",
29 "enhancement",
30 "perf",
31 "revert",
32];
33
34pub(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 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_changelog_fragments(&changelog_dir)?;
89
90 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
99struct ReleasedIdentifiers {
103 shas: std::collections::HashSet<String>,
104 pr_numbers: std::collections::HashSet<u64>,
105}
106
107fn 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
144pub(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
170fn 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#[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 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 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
316fn 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
329fn 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#[derive(Debug)]
416struct ChangelogEntry {
417 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
505fn 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 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 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 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 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 assert!(out.contains("\t\t\ttype: \"fix\"\n"));
716 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 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 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 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 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}