use crate::app::update_check::parse::sanitize_notes;
const CHANGELOG_MD: &str = include_str!("../../../CHANGELOG.md");
pub(crate) fn notes_for_version(version: &str) -> Option<Vec<String>> {
notes_from(CHANGELOG_MD, version)
}
fn notes_from(changelog: &str, version: &str) -> Option<Vec<String>> {
section_for_version(changelog, version).map(|raw| sanitize_notes(&raw))
}
fn section_for_version(changelog: &str, version: &str) -> Option<String> {
let lines: Vec<&str> = changelog.lines().collect();
let start = lines
.iter()
.position(|l| heading_version(l) == Some(version))?
+ 1;
let end = lines[start..]
.iter()
.position(|l| ends_section(l))
.map_or(lines.len(), |i| start + i);
Some(lines[start..end].join("\n"))
}
fn heading_version(line: &str) -> Option<&str> {
let rest = line.trim_start().strip_prefix("## [")?;
let end = rest.find(']')?;
Some(&rest[..end])
}
fn ends_section(line: &str) -> bool {
let line = line.trim_start();
line.starts_with("## ") || is_reference_definition(line)
}
fn is_reference_definition(line: &str) -> bool {
line.starts_with('[') && line.contains("]:")
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
# Changelog
Preamble prose.
## [Unreleased]
### Added
- something in flight
## [0.1.1] - 2026-08-18
### Added
- startup update check
### Fixed
- a bug
## [0.1.0] - 2026-08-17
First public release.
[Unreleased]: https://example.com/compare/v0.1.1...HEAD
[0.1.1]: https://example.com/compare/v0.1.0...v0.1.1
";
#[test]
fn a_section_is_the_lines_under_its_heading() {
let section = section_for_version(SAMPLE, "0.1.1").expect("section");
assert!(section.contains("- startup update check"));
assert!(section.contains("- a bug"));
}
#[test]
fn the_heading_itself_is_not_part_of_the_section() {
let section = section_for_version(SAMPLE, "0.1.1").expect("section");
assert!(
!section.contains("## [0.1.1]"),
"the modal states the version in its own row"
);
}
#[test]
fn a_section_stops_at_the_next_release_heading() {
let section = section_for_version(SAMPLE, "0.1.1").expect("section");
assert!(
!section.contains("First public release."),
"0.1.0's prose belongs to 0.1.0"
);
}
#[test]
fn a_subheading_is_content_not_a_boundary() {
let section = section_for_version(SAMPLE, "0.1.1").expect("section");
assert!(section.contains("### Added"));
assert!(section.contains("### Fixed"));
}
#[test]
fn the_last_section_stops_before_the_link_reference_block() {
let section = section_for_version(SAMPLE, "0.1.0").expect("section");
assert_eq!(section.trim(), "First public release.");
assert!(!section.contains("https://example.com"));
}
#[test]
fn a_version_never_matches_a_longer_one() {
let changelog = "## [0.1.20]\n\n- twenty\n";
assert_eq!(section_for_version(changelog, "0.1.2"), None);
assert!(section_for_version(changelog, "0.1.20").is_some());
}
#[test]
fn a_trailing_date_does_not_affect_the_match() {
assert_eq!(heading_version("## [0.1.1] - 2026-08-18"), Some("0.1.1"));
assert_eq!(heading_version("## [0.1.1]"), Some("0.1.1"));
}
#[test]
fn a_line_that_is_not_a_version_heading_names_no_version() {
assert_eq!(heading_version("### Added"), None);
assert_eq!(heading_version("## Install edamame"), None);
assert_eq!(heading_version("- a list item"), None);
assert_eq!(heading_version("## [unterminated"), None);
}
#[test]
fn an_absent_version_yields_nothing() {
assert_eq!(section_for_version(SAMPLE, "9.9.9"), None);
assert_eq!(notes_from(SAMPLE, "9.9.9"), None);
}
#[test]
fn an_unreleased_section_is_reachable_only_by_that_name() {
assert_eq!(section_for_version(SAMPLE, "0.1.2"), None);
assert!(section_for_version(SAMPLE, "Unreleased").is_some());
}
#[test]
fn notes_are_bounded_by_the_shared_sanitizer() {
let changelog = "## [1.0.0]\n\n- a\u{202e}b\n";
let notes = notes_from(changelog, "1.0.0").expect("notes");
assert_eq!(notes, vec!["- ab".to_owned()]);
}
#[test]
fn the_bundled_changelog_parses_with_the_shipped_headings() {
let notes = notes_for_version("0.1.1").expect("0.1.1 is a released section");
assert!(
notes.iter().any(|l| l.contains("Startup update check")),
"expected 0.1.1's own notes, got {notes:?}"
);
}
}