Files
pkh/src/changelog.rs
2026-01-09 17:21:07 +01:00

475 lines
14 KiB
Rust

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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let mut file = File::open(path)?;
let mut content = String::new();
file.read_to_string(&mut content)?;
// Find the last maintainer line (format: -- Name <email> 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<Vec<String>, Box<dyn std::error::Error>> {
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<Oid> = 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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
// 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 <m@e.com> 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 <maintainer@maintainer.com> "));
// 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");
}
}
}