use chrono::Local; use git2::{Oid, Repository, Sort}; use regex::Regex; use std::fs::File; use std::io::{self, BufRead, Read, Write}; use std::path::Path; /// Automatically generate a changelog entry from a commit history and previous changelog pub fn generate_entry( changelog_file: &str, cwd: Option<&Path>, user_version: Option<&str>, ) -> Result<(), Box> { let changelog_path = if let Some(path) = cwd { path.join(changelog_file) } else { Path::new(changelog_file).to_path_buf() }; // Parse existing changelog to get current (old) version let (package, old_version, series) = parse_changelog_header(&changelog_path)?; println!("Found package: {}, version: {}", package, old_version); // Open git repo, and find commits since last version tag let repo_path = if let Some(path) = cwd { path.to_path_buf() } else { std::env::current_dir()? }; let commits = match Repository::open(&repo_path) { Ok(repo) => get_commits_since_version(&repo, &old_version)?, // If there is no git repo (e.g. package downloaded from archive), // we just generate an empty list of changes Err(_e) => Vec::new(), }; // Compute new version if needed, or use user-supplied one let new_version = if let Some(version) = user_version { version.to_string() } else { // TODO: Pass these flags from CLI compute_new_version(&old_version, false, false, false) }; let (maintainer_name, maintainer_email) = get_maintainer_info()?; let new_entry = format_entry( &package, &new_version, &series, &commits, &maintainer_name, &maintainer_email, ); prepend_to_file(&changelog_path, &new_entry)?; println!("Added new changelog entry to {}", changelog_path.display()); Ok(()) } /// Compute the next (most probable) version number of a package, from old version and /// conditions on changes (is ubuntu upload, is a no change rebuild, is a non-maintainer upload) fn compute_new_version( old_version: &str, is_ubuntu: bool, is_rebuild: bool, is_nmu: bool, ) -> String { if is_ubuntu { return increment_suffix(old_version, "ubuntu"); } if is_rebuild { return increment_suffix(old_version, "build"); } if is_nmu { if !old_version.contains('-') { return increment_suffix(old_version, "+nmu"); } else { return increment_suffix(old_version, "."); } } increment_suffix(old_version, "") } /// Increment a version number by 1, for a given suffix fn increment_suffix(version: &str, suffix: &str) -> String { // If suffix is empty, we just look for trailing digits // If suffix is not empty, we look for suffix followed by digits let pattern = if suffix.is_empty() { r"(\d+)$".to_string() } else { format!(r"{}(\d+)$", regex::escape(suffix)) }; let re = Regex::new(&pattern).unwrap(); if let Some(caps) = re.captures(version) { let num_str = caps.get(1).unwrap().as_str(); let num: u32 = num_str.parse().unwrap(); let range = caps.get(1).unwrap().range(); let mut new_ver = version.to_string(); new_ver.replace_range(range, &(num + 1).to_string()); return new_ver; } // If pattern not found, append suffix + "1" // But if suffix is empty, we default to appending "-1" (standard Debian revision start) if suffix.is_empty() { format!("{}-1", version) } else { format!("{}{}{}", version, suffix, 1) } } /// Parse a changelog file first entry header /// Returns (package, version, series) tuple from the last modification entry pub fn parse_changelog_header( path: &Path, ) -> Result<(String, String, String), Box> { let file = File::open(path)?; let mut reader = io::BufReader::new(file); let mut first_line = String::new(); reader.read_line(&mut first_line)?; // Format: package (version) series; urgency=urgency let re = Regex::new(r"^(\S+) \(([^)]+)\) (.*); .*")?; if let Some(caps) = re.captures(&first_line) { let package = caps.get(1).map_or("", |m| m.as_str()).to_string(); let version = caps.get(2).map_or("", |m| m.as_str()).to_string(); let series = caps.get(3).map_or("", |m| m.as_str()).to_string(); Ok((package, version, series)) } else { Err(format!("Invalid changelog header format in {}", path.display()).into()) } } /// Parse a changelog file footer to extract maintainer information /// Returns (name, email) tuple from the last modification entry pub fn parse_changelog_footer(path: &Path) -> Result<(String, String), Box> { let mut file = File::open(path)?; let mut content = String::new(); file.read_to_string(&mut content)?; // Find the last maintainer line (format: -- Name Date) let re = Regex::new(r"--\s*([^<]+?)\s*<([^>]+)>\s*")?; if let Some(first_match) = re.captures_iter(&content).next() { let name = first_match .get(1) .map_or("", |m| m.as_str()) .trim() .to_string(); let email = first_match .get(2) .map_or("", |m| m.as_str()) .trim() .to_string(); Ok((name, email)) } else { Err(format!("No maintainer information found in {}", path.display()).into()) } } /* * Obtain all commit messages as a list since a tagged version in a git repository */ fn get_commits_since_version( repo: &Repository, version: &str, ) -> Result, Box> { let mut revwalk = repo.revwalk()?; revwalk.set_sorting(Sort::TIME)?; // We are looking for debian version numbers // Depending on the repo, they could be tagged with the following formats let tag_names = vec![ format!("debian/{}", version), format!("debian/v{}", version), format!("v{}", version), version.to_string(), ]; // Look for the different tag formats in the git repository let mut tag_id: Option = None; for tag_name in tag_names { if let Ok(r) = repo.revparse_single(&tag_name) { // If we found either a commit or a tag matching the name, // we have found our version if let Some(commit) = r.as_commit() { tag_id = Some(commit.id()); break; } else if let Some(tag) = r.as_tag() { tag_id = Some(tag.target_id()); break; } } } if let Some(tid) = tag_id { revwalk.push_head()?; revwalk.hide(tid)?; } else { // Tag not found... // We return an empty list. // TODO: Can we do better? return Ok(Vec::new()); } // Add all commit messages from that tagged version to head in the list let mut commits = Vec::new(); for id in revwalk { let id = id?; let commit = repo.find_commit(id)?; let message = commit.message().unwrap_or("").trim(); let summary = message.lines().next().unwrap_or("").to_string(); if !summary.is_empty() { commits.push(summary); } } Ok(commits) } /* * Create a changelog entry from information, i.e. format that information * into a changelog entry */ fn format_entry( package: &str, version: &str, series: &str, changes: &[String], maintainer_name: &str, maintainer_email: &str, ) -> String { let mut entry = String::new(); // Header: package, version and distribution series entry.push_str(&format!( "{} ({}) {}; urgency=medium\n\n", package, version, series )); // Changes for change in changes { entry.push_str(&format!(" * {}\n", change)); } if changes.is_empty() { entry.push_str(" * \n"); } // Footer: date, maintainer let date = Local::now().format("%a, %d %b %Y %H:%M:%S %z").to_string(); entry.push_str(&format!( "\n -- {} <{}> {}\n\n", maintainer_name, maintainer_email, date )); entry } /* * Add content to the beginning of a file */ fn prepend_to_file(path: &Path, content: &str) -> Result<(), Box> { let mut file = File::open(path)?; let mut existing_content = String::new(); file.read_to_string(&mut existing_content)?; let mut file = File::create(path)?; file.write_all(content.as_bytes())?; file.write_all(existing_content.as_bytes())?; Ok(()) } fn get_maintainer_info() -> Result<(String, String), Box> { // From environment variables if let (Ok(name), Ok(email)) = (std::env::var("DEBFULLNAME"), std::env::var("DEBEMAIL")) { return Ok((name, email)); } // From git config let config = git2::Config::open_default()?; let name = config.get_string("user.name")?; let email = config.get_string("user.email")?; Ok((name, email)) } #[cfg(test)] mod tests { use super::*; use std::process::Command; use tempfile::TempDir; fn setup_repo(dir: &Path) { Command::new("git") .arg("init") .current_dir(dir) .output() .unwrap(); Command::new("git") .arg("config") .arg("user.email") .arg("you@example.com") .current_dir(dir) .output() .unwrap(); Command::new("git") .arg("config") .arg("user.name") .arg("Your Name") .current_dir(dir) .output() .unwrap(); } fn commit(dir: &Path, message: &str) { Command::new("git") .arg("commit") .arg("--allow-empty") .arg("-m") .arg(message) .current_dir(dir) .output() .unwrap(); } fn tag(dir: &Path, name: &str) { Command::new("git") .arg("tag") .arg(name) .current_dir(dir) .output() .unwrap(); } #[test] fn test_generate_entry() { let temp_dir = TempDir::new().unwrap(); let repo_dir = temp_dir.path(); setup_repo(repo_dir); // Create initial changelog let changelog_path = repo_dir.join("debian/changelog"); std::fs::create_dir_all(repo_dir.join("debian")).unwrap(); let initial_content = "mypackage (0.1.0-1) unstable; urgency=medium\n\n * Initial release\n\n -- Maintainer Wed, 01 Jan 2020 00:00:00 +0000\n"; std::fs::write(&changelog_path, initial_content).unwrap(); // Commit and tag Command::new("git") .arg("add") .arg(".") .current_dir(repo_dir) .output() .unwrap(); commit(repo_dir, "Initial commit"); tag(repo_dir, "debian/0.1.0-1"); // New commits commit(repo_dir, "Fix bug A"); commit(repo_dir, "Add feature B"); // Generate entry unsafe { std::env::set_var("DEBFULLNAME", "Maintainer Maintainer"); std::env::set_var("DEBEMAIL", "maintainer@maintainer.com"); } generate_entry("debian/changelog", Some(repo_dir), None).unwrap(); unsafe { std::env::remove_var("DEBFULLNAME"); std::env::remove_var("DEBEMAIL"); } // Verify content let content = std::fs::read_to_string(&changelog_path).unwrap(); println!("{}", content); assert!(content.contains("mypackage (0.1.0-2) unstable; urgency=medium")); assert!(content.contains("* Fix bug A")); assert!(content.contains("* Add feature B")); assert!(content.contains(" -- Maintainer Maintainer ")); // Should still contain old content assert!(content.contains("mypackage (0.1.0-1) unstable; urgency=medium")); } #[test] fn test_compute_new_version() { // Debian upload assert_eq!( compute_new_version("15.2.0-8", false, false, false), "15.2.0-9" ); assert_eq!( compute_new_version("15.2.0-9", false, false, false), "15.2.0-10" ); // Ubuntu upload assert_eq!( compute_new_version("15.2.0-9", true, false, false), "15.2.0-9ubuntu1" ); assert_eq!( compute_new_version("15.2.0-9ubuntu1", true, false, false), "15.2.0-9ubuntu2" ); // No change rebuild assert_eq!( compute_new_version("15.2.0-9", false, true, false), "15.2.0-9build1" ); assert_eq!( compute_new_version("15.2.0-9build1", false, true, false), "15.2.0-9build2" ); // Rebuild of Ubuntu version assert_eq!( compute_new_version("15.2.0-9ubuntu1", false, true, false), "15.2.0-9ubuntu1build1" ); // NMU // Native assert_eq!(compute_new_version("1.0", false, false, true), "1.0+nmu1"); assert_eq!( compute_new_version("1.0+nmu1", false, false, true), "1.0+nmu2" ); // Non-native assert_eq!(compute_new_version("1.0-1", false, false, true), "1.0-1.1"); assert_eq!( compute_new_version("1.0-1.1", false, false, true), "1.0-1.2" ); // NMU of NMU? assert_eq!( compute_new_version("1.0-1.2", false, false, true), "1.0-1.3" ); // Native package uploads assert_eq!(compute_new_version("1.0", false, false, false), "1.1"); assert_eq!(compute_new_version("1.0.5", false, false, false), "1.0.6"); assert_eq!( compute_new_version("20241126", false, false, false), "20241127" ); } #[test] fn test_get_maintainer_info() { // Test with env vars unsafe { std::env::set_var("DEBFULLNAME", "Env Name"); std::env::set_var("DEBEMAIL", "env@example.com"); } let (name, email) = get_maintainer_info().unwrap(); assert_eq!(name, "Env Name"); assert_eq!(email, "env@example.com"); unsafe { std::env::remove_var("DEBFULLNAME"); std::env::remove_var("DEBEMAIL"); } } }