439 lines
14 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|