//! 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, } /// 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, ) -> Result, Box> { 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> { 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> { 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, ) -> Result, Box> { 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>, origin: &str, ) -> Result> { // --- 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 Date` let mut body_lines: Vec = Vec::new(); let mut trailer: Option = 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 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 { let re = Regex::new(r"(?i)\(closes:\s*([^)]*)\)").ok()?; let mut numbers: Vec = 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::() { numbers.push(n); } } } } } if numbers.is_empty() { return None; } numbers.sort_unstable(); numbers.dedup(); Some( numbers .iter() .map(u64::to_string) .collect::>() .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 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 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 Thu, 01 Jan 2026 00:00:00 +0000 pkg (1.4-2) unstable; urgency=medium * Revision bump. -- Pkh Tester Wed, 01 Jan 2025 00:00:00 +0000 pkg (1.4-1) unstable; urgency=medium * Initial release. -- Pkh Tester 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 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 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 ); } }