pub(crate) fn release_date_for<'a>(changelog: &'a str, version: &str) -> Option<&'a str> {
let needle = format!("## [{version}]");
let rest = changelog
.lines()
.find_map(|line| line.trim_end().strip_prefix(needle.as_str()))?;
let date = rest
.trim_start_matches([' ', '\u{2014}', '\u{2013}', '-'])
.trim();
is_iso_date(date).then_some(date)
}
pub(crate) fn is_iso_date(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 10
&& b[4] == b'-'
&& b[7] == b'-'
&& [0, 1, 2, 3, 5, 6, 8, 9]
.iter()
.all(|&i| b[i].is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
# Changelog
## [Unreleased]
## [0.35.0] — 2026-08-27
### Fixed
## [0.34.2] — 2026-08-25
";
#[test]
fn finds_the_date_for_the_named_version() {
assert_eq!(release_date_for(SAMPLE, "0.35.0"), Some("2026-08-27"));
assert_eq!(release_date_for(SAMPLE, "0.34.2"), Some("2026-08-25"));
}
#[test]
fn a_version_with_no_heading_yet_has_no_date() {
assert_eq!(release_date_for(SAMPLE, "0.36.0"), None);
}
#[test]
fn the_unreleased_heading_is_not_a_date() {
assert_eq!(release_date_for(SAMPLE, "Unreleased"), None);
}
#[test]
fn a_version_that_is_a_prefix_of_another_does_not_match_it() {
let cl = "## [0.3.50] — 2026-01-02\n";
assert_eq!(release_date_for(cl, "0.3.5"), None);
assert_eq!(release_date_for(cl, "0.3.50"), Some("2026-01-02"));
}
#[test]
fn a_malformed_date_is_rejected_rather_than_rendered() {
for bad in [
"## [1.0.0] — soon\n",
"## [1.0.0] — 2026-8-27\n",
"## [1.0.0] — 26-08-2027\n",
"## [1.0.0]\n",
] {
assert_eq!(release_date_for(bad, "1.0.0"), None, "accepted {bad:?}");
}
}
#[test]
fn a_hyphen_or_plain_space_separator_also_works() {
assert_eq!(
release_date_for("## [1.0.0] - 2026-08-27\n", "1.0.0"),
Some("2026-08-27")
);
assert_eq!(
release_date_for("## [1.0.0] 2026-08-27\n", "1.0.0"),
Some("2026-08-27")
);
}
#[test]
fn iso_date_shape() {
assert!(is_iso_date("2026-08-27"));
assert!(!is_iso_date("2026-08-2"));
assert!(!is_iso_date("2026/08/27"));
assert!(!is_iso_date(""));
}
}