//! 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 the most recent entry of a Debian changelog file. pub fn parse_changelog_entry(path: &Path) -> Result> { 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> { 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 Date` let mut body_lines: Vec = Vec::new(); let mut trailer: Option = 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 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(" "), ) } /// 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, Box> { 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, Box> { 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 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"); } }