pub mod history;
use crate::update::version::Version;
const CHANGELOG: &str = include_str!("../../CHANGELOG.md");
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Release {
pub version: String,
pub date: Option<String>,
pub body: String,
}
pub fn releases() -> Vec<Release> {
parse(CHANGELOG)
}
pub fn parse(text: &str) -> Vec<Release> {
let mut releases: Vec<Release> = Vec::new();
for line in text.lines() {
match heading(line) {
Some((version, date)) => releases.push(Release {
version,
date,
body: String::new(),
}),
None => {
if let Some(current) = releases.last_mut() {
current.body.push_str(line);
current.body.push('\n');
}
}
}
}
for release in &mut releases {
release.body = release.body.trim().to_string()
}
releases
}
fn heading(line: &str) -> Option<(String, Option<String>)> {
let rest = line.strip_prefix("## ")?.trim().strip_prefix('v')?;
let (number, tail) = match rest.find(char::is_whitespace) {
Some(at) => rest.split_at(at),
None => (rest, ""),
};
Version::parse(number)?;
let date = tail
.trim_start_matches(|c: char| c.is_whitespace() || c == '—' || c == '-' || c == '·')
.trim();
Some((
number.to_string(),
(!date.is_empty()).then(|| date.to_string()),
))
}
pub fn only(releases: Vec<Release>, wanted: &str) -> Option<Release> {
let wanted = wanted.trim().trim_start_matches('v');
releases
.into_iter()
.find(|release| release.version == wanted)
}
pub fn since(releases: Vec<Release>, floor: &str) -> Vec<Release> {
let Some(floor) = Version::parse(floor.trim().trim_start_matches('v')) else {
return Vec::new();
};
releases
.into_iter()
.filter(|release| Version::parse(&release.version).is_some_and(|version| version > floor))
.collect()
}
pub fn running(releases: Vec<Release>, current: &str) -> Option<Release> {
let current = current.trim().trim_start_matches('v');
match releases
.iter()
.position(|release| release.version == current)
{
Some(at) => releases.into_iter().nth(at),
None => releases.into_iter().next(),
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "# Changelog\n\nPreamble nobody asked for.\n\n## Unreleased\n\n### Something in flight\n\n## v0.18.0 — 2026-08-13\n\nThe canvas, from the terminal.\n\n### A sub-heading\n\nMore.\n\n## v0.17.0 — 2026-08-05\n\nWhat the agent is being asked.\n";
#[test]
fn it_should_read_every_published_version() {
assert_eq!(parse(SAMPLE).len(), 2);
}
#[test]
fn it_should_read_the_newest_version_first() {
assert_eq!(parse(SAMPLE)[0].version, "0.18.0");
}
#[test]
fn it_should_read_the_date_out_of_the_heading() {
assert_eq!(parse(SAMPLE)[0].date.as_deref(), Some("2026-08-13"));
}
#[test]
fn it_should_leave_the_unreleased_section_out() {
assert!(!parse(SAMPLE)
.iter()
.any(|entry| entry.version == "Unreleased"));
}
#[test]
fn it_should_keep_a_sub_heading_inside_the_entry_it_belongs_to() {
assert!(parse(SAMPLE)[0].body.contains("### A sub-heading"));
}
#[test]
fn it_should_leave_the_preamble_out_of_every_entry() {
assert!(!parse(SAMPLE)
.iter()
.any(|entry| entry.body.contains("Preamble")));
}
#[test]
fn it_should_read_a_heading_that_carries_no_date() {
assert_eq!(parse("## v1.0.0\n\nShipped.\n")[0].date, None);
}
#[test]
fn it_should_ignore_a_heading_whose_number_is_not_one() {
assert!(parse("## vNext\n\nSoon.\n").is_empty());
}
#[test]
fn it_should_find_one_version_by_its_number() {
assert_eq!(
only(parse(SAMPLE), "0.17.0").map(|entry| entry.version),
Some("0.17.0".to_string())
);
}
#[test]
fn it_should_find_a_version_written_with_its_v() {
assert!(only(parse(SAMPLE), "v0.17.0").is_some());
}
#[test]
fn it_should_list_what_came_after_a_version() {
assert_eq!(since(parse(SAMPLE), "0.17.0").len(), 1);
}
#[test]
fn it_should_leave_out_the_version_somebody_was_already_running() {
assert!(!since(parse(SAMPLE), "0.17.0")
.iter()
.any(|entry| entry.version == "0.17.0"));
}
#[test]
fn it_should_list_nothing_after_a_version_that_is_not_one() {
assert!(since(parse(SAMPLE), "las últimas").is_empty());
}
#[test]
fn it_should_answer_with_the_entry_of_the_running_binary() {
assert_eq!(
running(parse(SAMPLE), "0.17.0").map(|entry| entry.version),
Some("0.17.0".to_string())
);
}
#[test]
fn it_should_fall_back_to_the_newest_entry_it_knows() {
assert_eq!(
running(parse(SAMPLE), "9.9.9").map(|entry| entry.version),
Some("0.18.0".to_string())
);
}
#[test]
fn it_should_carry_its_own_changelog() {
assert!(!releases().is_empty());
}
#[test]
fn it_should_not_describe_a_version_newer_than_this_binary() {
let newest = Version::parse(&releases()[0].version).expect("the headings carry versions");
let mine = Version::parse(env!("CARGO_PKG_VERSION")).expect("the manifest carries one");
assert!(newest <= mine);
}
}