313 lines
10 KiB
Rust
313 lines
10 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 the most recent entry of a Debian changelog file.
|
|
pub fn parse_changelog_entry(path: &Path) -> Result<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_entry_from_str(&content)
|
|
}
|
|
|
|
/// Parse the most recent changelog entry from its textual content. `origin`
|
|
/// is used in error messages only.
|
|
pub fn parse_changelog_entry_from_str(
|
|
content: &str,
|
|
) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
|
|
let origin = "changelog";
|
|
let mut lines = content.lines().peekable();
|
|
|
|
// --- 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;
|
|
for line in lines {
|
|
let line = line.trim_end();
|
|
if line.starts_with(" -- ") {
|
|
trailer = Some(line.to_string());
|
|
break;
|
|
}
|
|
// Stop at an emacs local-variables block or a new entry header.
|
|
if line.starts_with("Local variables:") {
|
|
break;
|
|
}
|
|
if !line.trim().is_empty() && looks_like_header(line) && !body_lines.is_empty() {
|
|
break;
|
|
}
|
|
// 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(" "),
|
|
)
|
|
}
|
|
|
|
/// Return the version of the *previous* changelog entry (the second header
|
|
/// in the file), or `None` when only one entry exists.
|
|
pub fn parse_previous_version(path: &Path) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
|
let content = std::fs::read_to_string(path)
|
|
.map_err(|e| format!("failed to read changelog '{}': {}", path.display(), e))?;
|
|
parse_previous_version_from_str(&content)
|
|
}
|
|
|
|
/// Return the version of the *previous* changelog entry from the textual
|
|
/// content of a changelog file.
|
|
pub fn parse_previous_version_from_str(
|
|
content: &str,
|
|
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
|
let mut seen_first = false;
|
|
for line in content.lines() {
|
|
let line = line.trim_end();
|
|
if looks_like_header(line) {
|
|
if !seen_first {
|
|
seen_first = true;
|
|
continue;
|
|
}
|
|
let open = line
|
|
.find('(')
|
|
.ok_or_else(|| format!("invalid changelog header: {line}"))?;
|
|
let close = line[open..]
|
|
.find(')')
|
|
.ok_or_else(|| format!("unbalanced parenthesis in changelog header '{line}'"))?;
|
|
return Ok(Some(line[open + 1..open + close].to_string()));
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// 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");
|
|
}
|
|
}
|