Files
pkh/src/debian/changelog.rs
T
vhaudiquet 781ed204c2
CI / build (push) Successful in 2m53s
CI / test (push) Skipped
CI / snap (push) Failing after 9s
clippy: write the changelog body loop as while let (rust 1.98)
2026-09-18 15:43:12 +02:00

439 lines
14 KiB
Rust

//! Debian changelog entry parsing (`debian/changelog`).
use std::path::Path;
use chrono::DateTime;
use regex::Regex;
use super::version::DebianVersion;
/// A parsed `debian/changelog` entry (the most recent one).
#[derive(Debug, Clone)]
pub struct ChangelogEntry {
/// Source package name.
pub source: String,
/// Parsed version.
pub version: DebianVersion,
/// Raw distribution(s) field, e.g. `"unstable"` or `"focal"`.
pub distribution: String,
/// Urgency value, e.g. `"medium"`.
pub urgency: String,
/// True for binNMU-style entries (`binary-only=yes` header parameter).
pub binary_only: bool,
/// Maintainer name from the trailer line.
pub maintainer_name: String,
/// Maintainer email from the trailer line.
pub maintainer_email: String,
/// Verbatim trailer date string (RFC2822-ish).
pub date_raw: String,
/// Trailer date parsed as a Unix timestamp.
pub timestamp: i64,
/// Value for the `.changes` `Changes` field: header line, blank lines
/// converted to `.`, body lines verbatim; without the trailer line.
pub changes_field: String,
/// Bug numbers collected from `(Closes: #NNN)` mentions in the body,
/// sorted numerically and de-duplicated (like dpkg's `find_closes`).
pub closes: Option<String>,
}
/// Parse up to `limit` entries of a Debian changelog file, newest first
/// (`None` parses the whole file).
pub fn parse_changelog_entries(
path: &Path,
limit: Option<usize>,
) -> Result<Vec<ChangelogEntry>, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path).map_err(|e| {
format!(
"failed to read changelog '{}': {}. Make sure you are running \
from the root of a source package.",
path.display(),
e
)
})?;
parse_changelog_entries_from_str(&content, limit)
}
/// Parse the most recent entry of a Debian changelog file.
pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
parse_changelog_entries(path, Some(1)).map(|mut entries| entries.remove(0))
}
/// Parse the most recent changelog entry from its textual content.
pub fn parse_changelog_entry_from_str(
content: &str,
) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
parse_changelog_entries_from_str(content, Some(1)).map(|mut entries| entries.remove(0))
}
/// Parse changelog entries from their textual content, newest first.
///
/// `limit` bounds the number of parsed entries (`None` parses the whole
/// file). Content below the last entry that is not another entry header
/// (e.g. an older changelog kept in a non-Debian format) is ignored.
pub fn parse_changelog_entries_from_str(
content: &str,
limit: Option<usize>,
) -> Result<Vec<ChangelogEntry>, Box<dyn std::error::Error>> {
let origin = "changelog";
let mut lines = content.lines().peekable();
let mut entries = Vec::new();
loop {
if limit.is_some_and(|n| entries.len() >= n) {
break;
}
// Blank separators between entries.
while lines.peek().is_some_and(|l| l.trim().is_empty()) {
lines.next();
}
let Some(next) = lines.peek() else {
break;
};
if !entries.is_empty() && !looks_like_header(next.trim_end()) {
break;
}
entries.push(parse_one_entry(&mut lines, origin)?);
}
Ok(entries)
}
/// Parse one entry: header line, body, maintainer trailer. Parsing stops
/// without consuming the first line that is a trailer terminator, an emacs
/// local-variables block, or the next entry's header — the stream can then
/// be resumed for the following entry.
fn parse_one_entry(
lines: &mut std::iter::Peekable<std::str::Lines<'_>>,
origin: &str,
) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
// --- Header line: `package (version) distributions; urgency=medium[, key=value]`
let header = loop {
match lines.next() {
Some(l) if l.trim().is_empty() => continue,
Some(l) => break l.trim_end(),
None => {
return Err(format!("changelog '{origin}' is empty").into());
}
}
};
let open = header
.find('(')
.ok_or_else(|| format!("invalid changelog header in '{origin}': {header}"))?;
let close = header[open..]
.find(')')
.ok_or_else(|| format!("unbalanced parenthesis in changelog header '{}'", header))?;
let source = header[..open].trim().to_string();
if source.is_empty() || source.contains(' ') {
return Err(format!("invalid source name in changelog header '{}'", header).into());
}
let version = DebianVersion::parse(&header[open + 1..open + close])?;
let after_version = &header[open + close + 1..];
let (distributions_part, params_part) = match after_version.split_once(';') {
Some((d, p)) => (d, p),
None => (after_version, ""),
};
let distribution = distributions_part.trim().to_string();
if distribution.is_empty() {
return Err(format!("missing distribution in changelog header '{}'", header).into());
}
let mut urgency = String::from("unknown");
let mut binary_only = false;
for param in params_part.split(',') {
let param = param.trim();
if let Some(value) = param.strip_prefix("urgency=") {
urgency = value.trim().to_string();
} else if param == "binary-only=yes" || param == "binary-only=yes," {
binary_only = true;
}
}
// --- Body until trailer line ` -- Name <email> Date`
let mut body_lines: Vec<String> = Vec::new();
let mut trailer: Option<String> = None;
while let Some(line) = lines.peek().copied() {
let line = line.trim_end();
if line.starts_with(" -- ") {
trailer = lines.next().map(|l| l.trim_end().to_string());
break;
}
// Stop at an emacs local-variables block or a new entry header
// (both peeked, not consumed).
if line.starts_with("Local variables:") {
break;
}
if !line.trim().is_empty() && looks_like_header(line) && !body_lines.is_empty() {
break;
}
lines.next();
// Blank lines become "." like dpkg does for the Changes field.
if line.trim().is_empty() {
body_lines.push(".".to_string());
} else {
body_lines.push(line.to_string());
}
}
let trailer_line = trailer.ok_or_else(|| {
format!(
"no maintainer trailer found in '{origin}': expected a line of the form \
' -- Name <email> Date'"
)
})?;
// Strip trailing "." lines left over from blank lines before the trailer.
while body_lines.last().map(|l| l == ".").unwrap_or(false) {
body_lines.pop();
}
let trailer_body = trailer_line.strip_prefix(" -- ").unwrap_or(&trailer_line);
let lt = trailer_body
.find('<')
.ok_or_else(|| format!("malformed maintainer trailer '{}'", trailer_line))?;
let gt = trailer_body[lt..]
.find('>')
.map(|i| i + lt)
.ok_or_else(|| format!("malformed maintainer trailer '{}'", trailer_line))?;
let maintainer_name = trailer_body[..lt].trim().to_string();
let maintainer_email = trailer_body[lt + 1..gt].trim().to_string();
let date_raw = trailer_body[gt + 1..].trim().to_string();
let timestamp = DateTime::parse_from_rfc2822(&date_raw)
.map_err(|e| format!("cannot parse changelog date '{date_raw}' in '{origin}': {e}"))?
.timestamp();
// Changes field value (leading `\n` marks it as a pre-wrapped multiline
// field, like dpkg's own representation): header + blank-as-dot + body,
// without the trailer line.
let mut changes_field = String::from("\n");
changes_field.push_str(header);
if !body_lines.is_empty() {
changes_field.push('\n');
changes_field.push_str(&body_lines.join("\n"));
}
let closes = find_closes(&body_lines);
Ok(ChangelogEntry {
source,
version,
distribution,
urgency,
binary_only,
maintainer_name,
maintainer_email,
date_raw,
timestamp,
changes_field,
closes,
})
}
/// Extract bug numbers from `(Closes: #NNN)` mentions in changelog body
/// lines, sorted numerically and de-duplicated (a lenient port of dpkg's
/// `find_closes`).
fn find_closes(body_lines: &[String]) -> Option<String> {
let re = Regex::new(r"(?i)\(closes:\s*([^)]*)\)").ok()?;
let mut numbers: Vec<u64> = Vec::new();
for line in body_lines {
for capture in re.captures_iter(line) {
if let Some(inner) = capture.get(1) {
for token in inner.as_str().split(|c: char| !c.is_ascii_digit()) {
if let Ok(n) = token.parse::<u64>() {
numbers.push(n);
}
}
}
}
}
if numbers.is_empty() {
return None;
}
numbers.sort_unstable();
numbers.dedup();
Some(
numbers
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(" "),
)
}
/// Heuristic check for a changelog entry header line
/// (`name (version) dist; urgency=...`).
fn looks_like_header(line: &str) -> bool {
// Headers are never indented.
if line.starts_with(' ') || line.starts_with('\t') {
return false;
}
match line.find('(') {
Some(open) => {
let name = line[..open].trim();
!name.is_empty() && !name.contains(' ')
}
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn changelog_parsing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("changelog");
let content = "\
pkh-hello (0.1) unstable; urgency=medium
* Initial release.
* Second change line.
-- Pkh Tester <pkh@example.com> Sat, 22 Aug 2026 10:00:00 +0000
";
std::fs::write(&path, content).unwrap();
let entry = parse_changelog_entry(&path).unwrap();
assert_eq!(entry.source, "pkh-hello");
assert_eq!(entry.version.full(), "0.1");
assert_eq!(entry.distribution, "unstable");
assert_eq!(entry.urgency, "medium");
assert!(!entry.binary_only);
assert_eq!(entry.maintainer_name, "Pkh Tester");
assert_eq!(entry.maintainer_email, "pkh@example.com");
assert_eq!(entry.timestamp, 1787392800);
assert_eq!(
entry.changes_field,
"\npkh-hello (0.1) unstable; urgency=medium\n.\n * Initial release.\n * Second change line."
);
}
#[test]
fn changelog_bin_nmu() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("changelog");
let content = "\
pkg (1.0-1+b1) unstable; urgency=medium, binary-only=yes
* Binary-only non-maintainer upload.
-- Builder <b@example.com> Mon, 01 Jan 2024 00:00:00 +0000
";
std::fs::write(&path, content).unwrap();
let entry = parse_changelog_entry(&path).unwrap();
assert!(entry.binary_only);
assert_eq!(entry.version.full(), "1.0-1+b1");
}
const THREE_ENTRIES: &str = "\
pkg (2.0-1) unstable; urgency=low
* New upstream release.
-- Pkh Tester <pkh@example.com> Thu, 01 Jan 2026 00:00:00 +0000
pkg (1.4-2) unstable; urgency=medium
* Revision bump.
-- Pkh Tester <pkh@example.com> Wed, 01 Jan 2025 00:00:00 +0000
pkg (1.4-1) unstable; urgency=medium
* Initial release.
-- Pkh Tester <pkh@example.com> Sat, 01 Mar 2025 00:00:00 +0000
";
#[test]
fn entries_parse_newest_first_with_limits() {
// Whole file.
let all = parse_changelog_entries_from_str(THREE_ENTRIES, None).unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all[0].version.full(), "2.0-1");
assert_eq!(all[1].version.full(), "1.4-2");
assert_eq!(all[2].version.full(), "1.4-1");
// Bounded limits.
assert_eq!(
parse_changelog_entries_from_str(THREE_ENTRIES, Some(1))
.unwrap()
.len(),
1
);
let two = parse_changelog_entries_from_str(THREE_ENTRIES, Some(2)).unwrap();
assert_eq!(two.len(), 2);
assert_eq!(two[0].version.full(), "2.0-1");
assert_eq!(two[1].version.full(), "1.4-2");
// A limit beyond the entry count yields everything.
assert_eq!(
parse_changelog_entries_from_str(THREE_ENTRIES, Some(10))
.unwrap()
.len(),
3
);
// The single-entry helpers agree with a limit of 1.
let one = parse_changelog_entries_from_str(THREE_ENTRIES, Some(1)).unwrap();
let via_helper = parse_changelog_entry_from_str(THREE_ENTRIES).unwrap();
assert_eq!(one[0].version.full(), via_helper.version.full());
assert_eq!(one[0].source, via_helper.source);
}
#[test]
fn entries_ignore_trailing_foreign_content() {
let content = "\
pkg (1.0) unstable; urgency=medium
* Something.
-- Pkh Tester <pkh@example.com> Thu, 01 Jan 2026 00:00:00 +0000
older changelog kept in an ad-hoc format:
version 0.9 - some text, not a Debian entry
version 0.8 - more text
";
let entries = parse_changelog_entries_from_str(content, None).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].version.full(), "1.0");
}
#[test]
fn entries_parse_body_of_later_entries() {
let entries = parse_changelog_entries_from_str(THREE_ENTRIES, Some(2)).unwrap();
// The second entry's body and trailer are fully parsed, not merely
// its header line.
assert_eq!(
entries[1].changes_field,
"\npkg (1.4-2) unstable; urgency=medium\n.\n * Revision bump."
);
assert_eq!(entries[1].maintainer_email, "pkh@example.com");
assert_eq!(entries[1].urgency, "medium");
}
#[test]
fn entries_reject_malformed_later_entry() {
let content = "\
pkg (1.0) unstable; urgency=medium
* Something.
-- Pkh Tester <pkh@example.com> Thu, 01 Jan 2026 00:00:00 +0000
pkg (0.9) unstable; urgency=medium
* No trailer below.
";
assert!(parse_changelog_entries_from_str(content, None).is_err());
// Not parsed when not requested.
assert_eq!(
parse_changelog_entries_from_str(content, Some(1))
.unwrap()
.len(),
1
);
}
}