Files
pkh/src/changelog.rs
T
vhaudiquet cc5bbd2297 chlog: drop a trailing buildN before appending ubuntu1
An Ubuntu upload of a package sitting at X-2build1 produced
X-2build1ubuntu1: the blind append misrepresents the lineage and,
sorting below the proper X-2ubuntu1, could never supersede it. A real
change on top of a rebuild replaces the marker instead, so the
trailing buildN is now stripped before the ubuntu counter is appended
or incremented: X-2build1 becomes X-2ubuntu1, X-2ubuntu1build1
becomes X-2ubuntu2.
2026-09-22 13:00:40 +02:00

1480 lines
55 KiB
Rust

use chrono::Local;
use git2::{Oid, Repository, Sort};
use regex::Regex;
use std::fs::File;
use std::io::{Read, Write};
use std::path::Path;
/// Outcome of a successful [`generate_entry`] call: everything the CLI
/// renders for the user, and everything a library consumer needs to chain
/// further steps.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeneratedEntry {
/// Source package name from the previous changelog entry.
pub package: String,
/// Version the changelog carried before the new entry was prepended.
pub previous_version: String,
/// Version of the freshly added entry.
pub new_version: String,
/// Distribution series the new entry targets.
pub series: String,
/// The changelog file that was updated.
pub path: std::path::PathBuf,
}
/// The kind of upload a generated changelog entry describes: it selects how
/// the new entry's version is derived from the previous one. Ignored when an
/// explicit version is given.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum EntryKind {
/// A regular upload: the version numbering follows the vendor of the
/// target series — the Debian convention (`1.0-1` becomes `1.0-2`) for
/// Debian series, the Ubuntu one for Ubuntu series (`1.0-1` becomes
/// `1.0-1ubuntu1`, an already-Ubuntu `1.0-1ubuntu1` becomes
/// `1.0-1ubuntu2`). Unresolvable series (UNRELEASED, unknown) number
/// the Debian way.
#[default]
Normal,
/// A non-maintainer upload: `1.0-1` becomes `1.0-1.1` (native `1.0`
/// becomes `1.0+nmu1`)
Nmu,
/// A no-change rebuild: `1.0-1` becomes `1.0-1build1`
Rebuild,
/// An Ubuntu upload regardless of the target series: `1.0-1` becomes
/// `1.0-1ubuntu1`. Library-only: the CLI has no flag selecting it,
/// and targeting an Ubuntu series already picks this numbering.
Ubuntu,
/// A backport: numbered after the vendor of the target series —
/// Debian's backports scheme (`1.0-1` becomes `1.0-1~bpo12+1`) or
/// Ubuntu's per-release SRU scheme (`3.1-1ubuntu2` becomes
/// `3.1-1ubuntu2~24.04.1`). The release number is derived from the
/// target series, which must therefore be a numbered release (e.g.
/// not sid).
Backport,
}
/// Automatically generate a changelog entry from a commit history and previous changelog
pub async fn generate_entry(
changelog_file: &str,
cwd: Option<&Path>,
user_version: Option<&str>,
target_series: Option<&str>,
kind: EntryKind,
) -> Result<GeneratedEntry, 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, current_series) = parse_changelog_header(&changelog_path)?;
// 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) => {
let repo_changelog = changelog_in_repo(&repo, &changelog_path);
get_commits_since_version(&repo, &old_version, repo_changelog.as_deref())?
}
// If there is no git repo (e.g. package downloaded from archive),
// we just generate an empty list of changes
Err(_e) => Vec::new(),
};
// The series the new entry targets: needed before the version is
// computed, because a backport version carries the target release number
let series = target_series.unwrap_or(&current_series).to_string();
// Compute new version if needed, or use user-supplied one
let new_version = if let Some(version) = user_version {
version.to_string()
} else {
match kind {
EntryKind::Normal => {
let bump = normal_bump_for_series(&series).await;
compute_new_version(&old_version, bump)?
}
EntryKind::Nmu => compute_new_version(&old_version, Bump::Nmu)?,
EntryKind::Rebuild => compute_new_version(&old_version, Bump::Rebuild)?,
EntryKind::Ubuntu => compute_new_version(&old_version, Bump::Ubuntu)?,
EntryKind::Backport => {
let suffix = backport_suffix_for_series(&series).await?;
compute_new_version(&old_version, Bump::Backport(suffix))?
}
}
};
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)?;
Ok(GeneratedEntry {
package,
previous_version: old_version,
new_version,
series,
path: changelog_path,
})
}
/// How the new version is derived from the previous one: the conditions
/// [`compute_new_version`] acts on. The suffixes follow the usual
/// Debian/Ubuntu numbering conventions.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Bump {
/// Regular upload: increment the trailing number
Normal,
/// Ubuntu upload: `1.0-9` becomes `1.0-9ubuntu1`; a trailing
/// no-change-rebuild marker is dropped first (`1.0-9build1` becomes
/// `1.0-9ubuntu1`)
Ubuntu,
/// Non-maintainer upload: `1.0-1` becomes `1.0-1.1`, native `1.0`
/// becomes `1.0+nmu1`
Nmu,
/// No-change rebuild: `1.0-1` becomes `1.0-1build1`
Rebuild,
/// Backport to the release whose version suffix is carried
/// (`"~bpo12+"` for Debian bookworm, `"~24.04."` for Ubuntu noble):
/// `1.0-1` becomes `1.0-1~bpo12+1`, `3.1-1ubuntu2` becomes
/// `3.1-1ubuntu2~24.04.1`
Backport(String),
}
/// Compute the next (most probable) version number of a package, from the
/// old version and the kind of upload the entry describes
fn compute_new_version(
old_version: &str,
bump: Bump,
) -> Result<String, Box<dyn std::error::Error>> {
match bump {
Bump::Ubuntu => increment_suffix(strip_build_suffix(old_version), "ubuntu"),
Bump::Rebuild => increment_suffix(old_version, "build"),
Bump::Nmu => {
if old_version.contains('-') {
increment_suffix(old_version, ".")
} else {
increment_suffix(old_version, "+nmu")
}
}
// A re-backport of the same release reuses its suffix counter,
// incrementing the trailing number: increment_suffix appends the
// suffix with a fresh 1 when the version carries no such suffix
// yet (including when it is a backport of another release, whose
// counter is left untouched)
Bump::Backport(suffix) => increment_suffix(old_version, &suffix),
Bump::Normal => increment_suffix(old_version, ""),
}
}
/// The version an Ubuntu upload is numbered from: a trailing
/// no-change-rebuild marker is dropped, because a real change on top of a
/// rebuild replaces the marker rather than appending to it — `X-2build1`
/// becomes `X-2ubuntu1`, where an appended `X-2build1ubuntu1` would
/// misrepresent the lineage and sort below `X-2ubuntu1`
fn strip_build_suffix(version: &str) -> &str {
let stem = version.trim_end_matches(|c: char| c.is_ascii_digit());
match stem.strip_suffix("build") {
Some(base) if stem.len() < version.len() => &version[..base.len()],
_ => version,
}
}
/// The version bump a regular ([`EntryKind::Normal`]) upload gets, derived
/// from the vendor of the target series: Ubuntu series number their uploads
/// the Ubuntu way (`1.0-1` becomes `1.0-1ubuntu1`), everything else —
/// Debian series, but also UNRELEASED and series no distro-info data knows —
/// numbers the Debian way (`1.0-1` becomes `1.0-2`).
async fn normal_bump_for_series(series: &str) -> Bump {
match crate::distro_info::get_dist_from_series(series).await {
Ok(dist) if dist == "ubuntu" => Bump::Ubuntu,
_ => Bump::Normal,
}
}
/// The version suffix that backport versions are numbered with, derived
/// from the vendor of the target series: Debian backports follow the
/// backports.debian.org scheme (`~bpo12+1`), Ubuntu backports the
/// per-release SRU scheme of the Ubuntu version-strings documentation
/// (`3.1-1ubuntu2~22.04.1` for a development version backported to 22.04).
/// A backport suite name (`bookworm-backports`) is accepted too. Errors
/// when the series has no usable release number (Debian sid or
/// experimental, unknown series): the numbering cannot be derived for it.
async fn backport_suffix_for_series(series: &str) -> Result<String, Box<dyn std::error::Error>> {
let base = series
.strip_suffix("-backports-sloppy")
.or_else(|| series.strip_suffix("-backports"))
.unwrap_or(series);
let unnumbered = || {
format!(
"Could not determine the release number of series '{series}', \
needed to number the backport version (Debian ~bpo12+1, \
Ubuntu ~24.04.1). Target a released Debian or Ubuntu series \
or pass the version explicitly with --version."
)
};
let Some((dist, number)) = crate::distro_info::get_series_release_number(base).await? else {
return Err(unnumbered().into());
};
match dist.as_str() {
// backports.debian.org numbers with the plain integer release number
"debian" if !number.is_empty() && number.chars().all(|c| c.is_ascii_digit()) => {
Ok(format!("~bpo{}+", number))
}
// Ubuntu numbers per release as YY.MM; the trailing `.N` counts
// the per-release SRU uploads
"ubuntu"
if number.split('.').count() == 2
&& number
.split('.')
.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit())) =>
{
Ok(format!("~{}.", number))
}
_ => Err(unnumbered().into()),
}
}
/// Increment a version number by 1, for a given suffix
fn increment_suffix(version: &str, suffix: &str) -> Result<String, Box<dyn std::error::Error>> {
// 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();
// Parse as u64 so that large trailing numbers (e.g. date-based
// versions like '1.0-20250123123456', which do not fit in a u32)
// still increment normally
let num: u64 = num_str.parse().map_err(|_| {
format!(
"Cannot increment version '{version}': trailing number '{num_str}' \
is too large to be incremented. Specify a version explicitly instead."
)
})?;
let range = caps.get(1).unwrap().range();
let new_num = num.checked_add(1).ok_or_else(|| {
format!(
"Cannot increment version '{version}': trailing number {num} \
is too large to be incremented. Specify a version explicitly instead."
)
})?;
let mut new_ver = version.to_string();
new_ver.replace_range(range, &new_num.to_string());
return Ok(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() {
Ok(format!("{}-1", version))
} else {
Ok(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 entry = crate::debian::parse_changelog_entry(path)?;
Ok((entry.source, entry.version.full(), entry.distribution))
}
/// What a new changelog entry may target as series, derived from the
/// current changelog ([`series_candidates`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SeriesCandidates {
/// Offer `options` with `default` preselected; when the selection
/// cannot be made (cancelled, no interactive user) `fallback` — the
/// changelog's current series — is used instead.
Choose {
/// Selector labels: each series name, or `<suite> (<series>)`
/// for a series aliased by a changelog suite name (Debian's
/// 'unstable (sid)').
options: Vec<String>,
/// Changelog distribution each label of `options` maps to,
/// parallel to it: an aliased label selects its suite name —
/// what a changelog distribution field expects — while every
/// other label selects itself.
values: Vec<String>,
/// Preselected label.
default: String,
/// Series to fall back to when nothing can be selected.
fallback: String,
},
/// Nothing to choose: the series list was unavailable, keep the
/// current series.
Keep(String),
}
/// Derive the candidate series for a new changelog entry from the changelog
/// at `changelog_path`.
///
/// An UNRELEASED entry offers itself as a pinned first option (selecting it
/// keeps the changelog unreleased) on top of the current vendor's series
/// list, defaulting to the development series; any other series resolves
/// through the series list of its own distribution. A changelog suite name
/// (Debian's 'unstable') identifies the same series as its alias's codename
/// ('sid') and resolves to that dist's list. `None` when the changelog
/// cannot be parsed (no default to derive at all).
pub async fn series_candidates(changelog_path: &Path) -> Option<SeriesCandidates> {
let (_package, _version, current) = parse_changelog_header(changelog_path).ok()?;
if crate::distro_info::is_unreleased(&current) {
// Vendors keep original casing ("Ubuntu"), while the series data
// keys are lowercase
let dist = crate::build::env::current_vendor().to_lowercase();
match crate::distro_info::get_ordered_series_name(&dist).await {
Ok(series_list) => {
let (labels, series_values, _) = selector_options(&dist, &series_list, "");
let mut options = vec![crate::distro_info::UNRELEASED.to_string()];
let mut values = vec![crate::distro_info::UNRELEASED.to_string()];
options.extend(labels);
values.extend(series_values);
// Default to the development series (the first real entry),
// not to the pinned UNRELEASED entry itself
let default = if options.len() > 1 {
options[1].clone()
} else {
current.clone()
};
Some(SeriesCandidates::Choose {
options,
values,
default,
fallback: current,
})
}
Err(_) => Some(SeriesCandidates::Keep(current)),
}
} else {
// The changelog may target a suite name instead of a series
// codename: Debian conventionally writes 'unstable' where the
// series data carries 'sid'. The two identify the same series:
// the alias resolves to its codename's dist for the lookup.
let resolved = match crate::distro_info::get_dist_from_series(&current).await {
Ok(dist) => Some((dist, current.clone())),
Err(_) => crate::distro_info::resolve_suite_alias(&current),
};
match resolved {
Some((dist, canonical)) => {
match crate::distro_info::get_ordered_series_name(&dist).await {
// Even an empty list goes through the selector: its
// fallback prints and takes the default, like it always has
Ok(series_list) => {
let (options, values, default) =
selector_options(&dist, &series_list, &canonical);
Some(SeriesCandidates::Choose {
options,
values,
// A stale alias whose codename left the series
// list offers the raw name instead
default: default.unwrap_or_else(|| canonical.clone()),
fallback: current,
})
}
Err(_) => Some(SeriesCandidates::Keep(current)),
}
}
None => Some(SeriesCandidates::Keep(current)),
}
}
}
/// The selector entries for a dist's series list: (labels, changelog
/// targets, label of `current`'s entry). A series aliased by a
/// changelog suite name (Debian's 'unstable' for 'sid') is offered as
/// `<suite> (<series>)` but targets the suite name — what a changelog
/// distribution field expects — while every other series is offered,
/// and targeted, under its own name. `current` may be a name that is
/// not in the list at all (e.g. UNRELEASED), in which case no default
/// is returned.
fn selector_options(
dist: &str,
series: &[String],
current: &str,
) -> (Vec<String>, Vec<String>, Option<String>) {
let mut labels = Vec::with_capacity(series.len());
let mut values = Vec::with_capacity(series.len());
let mut default = None;
for s in series {
let (label, value) = match crate::distro_info::series_suite_alias(dist, s) {
Some(suite) => (format!("{suite} ({s})"), suite),
None => (s.clone(), s.clone()),
};
if s == current {
default = Some(label.clone());
}
labels.push(label);
values.push(value);
}
(labels, values, default)
}
/// The changelog distribution a selected series-selector label maps to
/// ([`SeriesCandidates::Choose`]): an aliased entry ('unstable (sid)')
/// targets its suite name, any other label targets itself, and a
/// free-typed series the selector does not offer is its own target.
pub fn selected_series(options: &[String], values: &[String], selected: String) -> String {
options
.iter()
.position(|o| *o == selected)
.and_then(|idx| values.get(idx).cloned())
.unwrap_or(selected)
}
/// 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 entry = crate::debian::parse_changelog_entry(path)?;
Ok((entry.maintainer_name, entry.maintainer_email))
}
/*
* Obtain all commit messages as a list since the previous release, to
* format into a changelog entry
*/
fn get_commits_since_version(
repo: &Repository,
version: &str,
changelog_path: Option<&Path>,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
// Preferred boundary: the tag of the current (previous) version
if let Some(tag_id) = find_version_tag(repo, version) {
return commit_summaries_since(repo, tag_id);
}
// No tag: fall back to the last commit that modified the changelog
// itself, whose commit introduces the previous entry. Its changes
// since then are the new entry's content — so entries stay correct in
// repositories that commit their changelogs without tagging them.
let Some(rel_path) = changelog_path else {
return Ok(Vec::new());
};
let Some(boundary) = last_commit_touching(repo, rel_path)? else {
// The changelog was never committed; nothing to walk from
return Ok(Vec::new());
};
// Prefer a tag of the version the committed changelog carries: it may
// point past the changelog commit itself (e.g. a later release commit)
let boundary_id = version_at_commit(repo, &boundary, rel_path)
.and_then(|committed_version| find_version_tag(repo, &committed_version))
.unwrap_or_else(|| boundary.id());
commit_summaries_since(repo, boundary_id)
}
/// The commit names a Debian version may be tagged under in a repository
fn version_tag_names(version: &str) -> Vec<String> {
vec![
format!("debian/{}", version),
format!("debian/v{}", version),
format!("v{}", version),
version.to_string(),
]
}
/// Find the commit a version tag points at, probing the usual tag formats
fn find_version_tag(repo: &Repository, version: &str) -> Option<Oid> {
for tag_name in version_tag_names(version) {
if let Ok(r) = repo.revparse_single(&tag_name) {
// A matching name resolves either to a commit (lightweight
// tag) or to a tag object (annotated tag)
if let Some(commit) = r.as_commit() {
return Some(commit.id());
} else if let Some(tag) = r.as_tag() {
return Some(tag.target_id());
}
}
}
None
}
/// The commit summaries from HEAD down to (excluding) `boundary`
fn commit_summaries_since(
repo: &Repository,
boundary: Oid,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(Sort::TIME)?;
revwalk.push_head()?;
revwalk.hide(boundary)?;
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)
}
/// The changelog path relative to the repository root, used to walk the
/// history of the file. `None` when the changelog lives outside the
/// repository working directory.
fn changelog_in_repo(repo: &Repository, changelog_path: &Path) -> Option<std::path::PathBuf> {
let workdir = repo.workdir()?.canonicalize().ok()?;
let absolute = if changelog_path.is_absolute() {
changelog_path.to_path_buf()
} else {
std::env::current_dir().ok()?.join(changelog_path)
};
let absolute = absolute.canonicalize().ok()?;
absolute
.strip_prefix(&workdir)
.ok()
.map(|p| p.to_path_buf())
}
/// The last commit that modified the file at `rel_path`, newest first
fn last_commit_touching<'a>(
repo: &'a Repository,
rel_path: &Path,
) -> Result<Option<git2::Commit<'a>>, Box<dyn std::error::Error>> {
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(Sort::TIME)?;
if repo.head().is_err() {
// Unborn HEAD: the repository has no commits yet
return Ok(None);
}
revwalk.push_head()?;
for id in revwalk {
let commit = repo.find_commit(id?)?;
if commit_touches_path(repo, &commit, rel_path)? {
return Ok(Some(commit));
}
}
Ok(None)
}
/// Whether `commit` modified the file at `rel_path` relative to its first
/// parent (a root commit counts when it adds the file)
fn commit_touches_path(
repo: &Repository,
commit: &git2::Commit<'_>,
rel_path: &Path,
) -> Result<bool, Box<dyn std::error::Error>> {
let new_tree = commit.tree()?;
let parent_tree = if commit.parent_count() > 0 {
Some(commit.parent(0)?.tree()?)
} else {
None
};
let mut opts = git2::DiffOptions::new();
opts.pathspec(rel_path.to_string_lossy().to_string());
let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&new_tree), Some(&mut opts))?;
Ok(diff.deltas().len() > 0)
}
/// The version recorded by the changelog content as of `commit`
fn version_at_commit(
repo: &Repository,
commit: &git2::Commit<'_>,
rel_path: &Path,
) -> Option<String> {
let blob = commit
.tree()
.ok()?
.get_path(rel_path)
.ok()
.and_then(|entry| repo.find_blob(entry.id()).ok())?;
let content = std::str::from_utf8(blob.content()).ok()?;
let entry = crate::debian::parse_changelog_entry_from_str(content).ok()?;
Some(entry.version.full())
}
/*
* 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(())
}
/// Discover the maintainer identity for changelog entries and package
/// scaffolding: `$DEBFULLNAME`/`$DEBEMAIL` when both are set, else the git
/// configuration (`user.name`/`user.email`).
///
/// Returns `(name, email)`, with a pointed error telling the user how to
/// configure the missing piece.
pub 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().map_err(|e| {
format!(
"Could not determine maintainer information. \
Neither $DEBFULLNAME/$DEBEMAIL nor git configuration is available: {}. \
Set the DEBFULLNAME and DEBEMAIL environment variables, \
or configure git with `git config --global user.name` and `git config --global user.email`.",
e
)
})?;
let name = config.get_string("user.name").map_err(|e| {
format!(
"Could not find git 'user.name' configuration: {}. \
Set it with `git config --global user.name \"Your Name\"` \
or define the DEBFULLNAME environment variable.",
e
)
})?;
let email = config.get_string("user.email").map_err(|e| {
format!(
"Could not find git 'user.email' configuration: {}. \
Set it with `git config --global user.email \"you@example.com\"` \
or define the DEBEMAIL environment variable.",
e
)
})?;
Ok((name, email))
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
/// An UNRELEASED changelog offers UNRELEASED pinned first, the
/// development series as the default, and itself as the fallback.
#[tokio::test]
async fn series_candidates_unreleased_pins_entry_and_defaults_to_dev() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("changelog");
std::fs::write(
&path,
"hello (1.0-1) UNRELEASED; urgency=medium\n\n * Something.\n\n \
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000\n",
)
.unwrap();
match series_candidates(&path).await {
Some(SeriesCandidates::Choose {
options,
values,
default,
fallback,
}) => {
assert_eq!(options[0], "UNRELEASED");
assert_eq!(values[0], "UNRELEASED");
assert!(options.len() > 1, "the vendor series list is offered");
assert_eq!(default, options[1]);
assert_eq!(
selected_series(&options, &values, default.clone()),
values[1]
);
assert_eq!(fallback, "UNRELEASED");
}
other => panic!("expected Choose, got {other:?}"),
}
}
/// A released changelog offers its distribution's series with the
/// current one preselected. Uses a series of the host vendor so the
/// test only relies on the local distro-info data.
#[tokio::test]
async fn series_candidates_released_defaults_to_current_series() {
let dist = crate::build::env::current_vendor().to_lowercase();
let vendor_series = crate::distro_info::get_ordered_series_name(&dist)
.await
.expect("the host vendor's series data resolves");
// Any released series of the vendor works; the changelog names it.
let current = vendor_series.last().expect("non-empty series list");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("changelog");
std::fs::write(
&path,
format!(
"hello (1.0-1) {current}; urgency=medium\n\n * Something.\n\n \
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000\n"
),
)
.unwrap();
match series_candidates(&path).await {
Some(SeriesCandidates::Choose {
options,
values,
default,
fallback,
}) => {
// The current series is preselected through its label, and
// selecting it targets the name the changelog already carries
let idx = values
.iter()
.position(|v| v == current)
.expect("the current series is offered");
assert_eq!(default, options[idx]);
assert_eq!(
selected_series(&options, &values, options[idx].clone()),
*current
);
assert_eq!(fallback, *current);
}
other => panic!("expected Choose, got {other:?}"),
}
}
/// A changelog targeting Debian's 'unstable' suite — the conventional
/// Debian development distribution, which the series data knows as the
/// codename 'sid' — resolves to the Debian series list: the selector
/// offers the aliased entry as 'unstable (sid)', preselected, and
/// selecting it targets 'unstable' itself.
#[tokio::test]
async fn series_candidates_suite_alias_matches_unstable_and_sid() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("changelog");
std::fs::write(
&path,
"hello (1.0-1) unstable; urgency=medium\n\n * Something.\n\n \
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000\n",
)
.unwrap();
match series_candidates(&path).await {
Some(SeriesCandidates::Choose {
options,
values,
default,
fallback,
}) => {
let idx = options
.iter()
.position(|o| o == "unstable (sid)")
.expect("sid is offered as its suite alias");
assert_eq!(values[idx], "unstable");
assert_eq!(default, "unstable (sid)");
assert_eq!(fallback, "unstable");
// Selecting the aliased entry targets the suite name
assert_eq!(
selected_series(&options, &values, options[idx].clone()),
"unstable"
);
// A free-typed series the selector does not offer is its own
// target
assert_eq!(
selected_series(&options, &values, "trixie".to_string()),
"trixie"
);
}
other => panic!("expected Choose, got {other:?}"),
}
}
/// A changelog naming the codename ('sid') resolves to the same
/// selector entry as the suite alias ('unstable'): the two identify
/// the same series.
#[tokio::test]
async fn series_candidates_sid_defaults_to_the_suite_alias_label() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("changelog");
std::fs::write(
&path,
"hello (1.0-1) sid; urgency=medium\n\n * Something.\n\n \
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000\n",
)
.unwrap();
match series_candidates(&path).await {
Some(SeriesCandidates::Choose {
options,
values,
default,
fallback,
}) => {
assert_eq!(default, "unstable (sid)");
assert_eq!(
selected_series(&options, &values, default.clone()),
"unstable"
);
assert_eq!(fallback, "sid");
}
other => panic!("expected Choose, got {other:?}"),
}
}
/// Without a parsable changelog there is no candidate at all.
#[tokio::test]
async fn series_candidates_none_without_changelog() {
let dir = tempfile::tempdir().unwrap();
assert!(
series_candidates(&dir.path().join("changelog"))
.await
.is_none()
);
}
/// Serializes the tests that mutate the process-global DEBFULLNAME /
/// DEBEMAIL variables: run in parallel, they otherwise race each
/// other's identity reads and assertions.
static IDENTITY_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
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();
}
#[tokio::test]
async 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
let _identity = IDENTITY_LOCK.lock().await;
unsafe {
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
std::env::set_var("DEBEMAIL", "maintainer@maintainer.com");
}
generate_entry(
"debian/changelog",
Some(repo_dir),
None,
None,
EntryKind::Normal,
)
.await
.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"));
}
/// Without any version tag, the history of the changelog itself
/// delimits the entry: the commits since the changelog was last
/// committed are the changes, and the changelog commit is not one.
#[tokio::test]
async fn test_generate_entry_without_tags() {
let temp_dir = TempDir::new().unwrap();
let repo_dir = temp_dir.path();
setup_repo(repo_dir);
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 the changelog, but do not tag it
Command::new("git")
.arg("add")
.arg(".")
.current_dir(repo_dir)
.output()
.unwrap();
commit(repo_dir, "Initial commit");
commit(repo_dir, "Fix bug A");
commit(repo_dir, "Add feature B");
let _identity = IDENTITY_LOCK.lock().await;
unsafe {
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
std::env::set_var("DEBEMAIL", "maintainer@maintainer.com");
}
generate_entry(
"debian/changelog",
Some(repo_dir),
None,
None,
EntryKind::Normal,
)
.await
.unwrap();
unsafe {
std::env::remove_var("DEBFULLNAME");
std::env::remove_var("DEBEMAIL");
}
let content = std::fs::read_to_string(&changelog_path).unwrap();
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("* Initial commit"));
}
/// A changelog that was never committed has no revision to walk the
/// history from: the entry is generated without commit summaries.
#[tokio::test]
async fn test_generate_entry_uncommitted_changelog() {
let temp_dir = TempDir::new().unwrap();
let repo_dir = temp_dir.path();
setup_repo(repo_dir);
let changelog_path = repo_dir.join("debian/changelog");
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
std::fs::write(&changelog_path, "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").unwrap();
commit(repo_dir, "Some change");
let _identity = IDENTITY_LOCK.lock().await;
unsafe {
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
std::env::set_var("DEBEMAIL", "maintainer@maintainer.com");
}
generate_entry(
"debian/changelog",
Some(repo_dir),
None,
None,
EntryKind::Normal,
)
.await
.unwrap();
unsafe {
std::env::remove_var("DEBFULLNAME");
std::env::remove_var("DEBEMAIL");
}
let content = std::fs::read_to_string(&changelog_path).unwrap();
assert!(content.contains("mypackage (0.1.0-2) unstable; urgency=medium"));
assert!(!content.contains("* Some change"));
}
/// An uncommitted newer entry on top of the changelog (e.g. UNRELEASED
/// from a previous run) does not hide the previous version: the tag of
/// the last *committed* version is still used as the boundary.
#[tokio::test]
async fn test_generate_entry_tag_from_committed_version() {
let temp_dir = TempDir::new().unwrap();
let repo_dir = temp_dir.path();
setup_repo(repo_dir);
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();
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");
commit(repo_dir, "Fix bug A");
commit(repo_dir, "Add feature B");
// Leave an uncommitted newer entry on top
let mut unreleased = String::from(
"mypackage (0.1.0-2) UNRELEASED; urgency=medium\n\n * WIP.\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n",
);
unreleased.push_str(initial_content);
std::fs::write(&changelog_path, unreleased).unwrap();
let _identity = IDENTITY_LOCK.lock().await;
unsafe {
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
std::env::set_var("DEBEMAIL", "maintainer@maintainer.com");
}
generate_entry(
"debian/changelog",
Some(repo_dir),
None,
None,
EntryKind::Normal,
)
.await
.unwrap();
unsafe {
std::env::remove_var("DEBFULLNAME");
std::env::remove_var("DEBEMAIL");
}
let content = std::fs::read_to_string(&changelog_path).unwrap();
assert!(content.contains("mypackage (0.1.0-3) UNRELEASED; urgency=medium"));
assert!(content.contains("* Fix bug A"));
assert!(content.contains("* Add feature B"));
}
#[test]
fn test_compute_new_version() {
// Debian upload
assert_eq!(
compute_new_version("15.2.0-8", Bump::Normal).unwrap(),
"15.2.0-9"
);
assert_eq!(
compute_new_version("15.2.0-9", Bump::Normal).unwrap(),
"15.2.0-10"
);
// Ubuntu upload
assert_eq!(
compute_new_version("15.2.0-9", Bump::Ubuntu).unwrap(),
"15.2.0-9ubuntu1"
);
assert_eq!(
compute_new_version("15.2.0-9ubuntu1", Bump::Ubuntu).unwrap(),
"15.2.0-9ubuntu2"
);
// Ubuntu upload on top of a rebuild drops the buildN marker:
// appending would give 15.2.0-9build1ubuntu1, which sorts below
// the proper 15.2.0-9ubuntu1
assert_eq!(
compute_new_version("15.2.0-9build1", Bump::Ubuntu).unwrap(),
"15.2.0-9ubuntu1"
);
assert_eq!(
compute_new_version("15.2.0-9ubuntu1build1", Bump::Ubuntu).unwrap(),
"15.2.0-9ubuntu2"
);
// Native packages
assert_eq!(
compute_new_version("15.2.0build1", Bump::Ubuntu).unwrap(),
"15.2.0ubuntu1"
);
// No change rebuild
assert_eq!(
compute_new_version("15.2.0-9", Bump::Rebuild).unwrap(),
"15.2.0-9build1"
);
assert_eq!(
compute_new_version("15.2.0-9build1", Bump::Rebuild).unwrap(),
"15.2.0-9build2"
);
// Rebuild of Ubuntu version
assert_eq!(
compute_new_version("15.2.0-9ubuntu1", Bump::Rebuild).unwrap(),
"15.2.0-9ubuntu1build1"
);
// NMU
// Native
assert_eq!(compute_new_version("1.0", Bump::Nmu).unwrap(), "1.0+nmu1");
assert_eq!(
compute_new_version("1.0+nmu1", Bump::Nmu).unwrap(),
"1.0+nmu2"
);
// Non-native
assert_eq!(compute_new_version("1.0-1", Bump::Nmu).unwrap(), "1.0-1.1");
assert_eq!(
compute_new_version("1.0-1.1", Bump::Nmu).unwrap(),
"1.0-1.2"
);
// NMU of NMU?
assert_eq!(
compute_new_version("1.0-1.2", Bump::Nmu).unwrap(),
"1.0-1.3"
);
// Backport, Debian scheme
assert_eq!(
compute_new_version("1.0-1", Bump::Backport("~bpo12+".to_string())).unwrap(),
"1.0-1~bpo12+1"
);
// Re-backporting the same source reuses the counter
assert_eq!(
compute_new_version("1.0-1~bpo12+1", Bump::Backport("~bpo12+".to_string())).unwrap(),
"1.0-1~bpo12+2"
);
// Native packages backport too
assert_eq!(
compute_new_version("1.0", Bump::Backport("~bpo12+".to_string())).unwrap(),
"1.0~bpo12+1"
);
// A version carrying another release's counter gains a fresh one
assert_eq!(
compute_new_version("1.0-1~bpo11+1", Bump::Backport("~bpo12+".to_string())).unwrap(),
"1.0-1~bpo11+1~bpo12+1"
);
// Backport, Ubuntu scheme (Ubuntu version-strings documentation)
assert_eq!(
compute_new_version("3.1-1ubuntu2", Bump::Backport("~22.04.".to_string())).unwrap(),
"3.1-1ubuntu2~22.04.1"
);
// Subsequent per-release SRU uploads increment the counter
assert_eq!(
compute_new_version(
"3.1-1ubuntu2~22.04.1",
Bump::Backport("~22.04.".to_string())
)
.unwrap(),
"3.1-1ubuntu2~22.04.2"
);
// Native packages
assert_eq!(
compute_new_version("3.1", Bump::Backport("~22.04.".to_string())).unwrap(),
"3.1~22.04.1"
);
// Native package uploads
assert_eq!(compute_new_version("1.0", Bump::Normal).unwrap(), "1.1");
assert_eq!(compute_new_version("1.0.5", Bump::Normal).unwrap(), "1.0.6");
assert_eq!(
compute_new_version("20241126", Bump::Normal).unwrap(),
"20241127"
);
}
#[test]
fn test_compute_new_version_large_trailing_number() {
// Date-based versions with a trailing number larger than u32::MAX
// must increment normally (they fit in a u64)
assert_eq!(
compute_new_version("1.0-20250123123456", Bump::Normal).unwrap(),
"1.0-20250123123457"
);
// A number that does not even fit in a u64 yields a clear error
// instead of panicking
let err = compute_new_version("1.0-99999999999999999999999999", Bump::Normal);
assert!(err.is_err());
// u64::MAX itself cannot be incremented
let err = compute_new_version("1.0-18446744073709551615", Bump::Normal);
assert!(err.is_err());
}
/// A backport entry is numbered after the Debian release number of the
/// target series (`~bpo12+1` for bookworm). Relies on the host
/// distro-info data mapping bookworm to 12 (see distro_info tests).
#[tokio::test]
async fn test_generate_entry_backport_numbering() {
let temp_dir = TempDir::new().unwrap();
let repo_dir = temp_dir.path();
let changelog_path = repo_dir.join("debian/changelog");
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
std::fs::write(
&changelog_path,
"mypackage (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",
)
.unwrap();
let _identity = IDENTITY_LOCK.lock().await;
unsafe {
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
std::env::set_var("DEBEMAIL", "maintainer@maintainer.com");
}
let entry = generate_entry(
"debian/changelog",
Some(repo_dir),
None,
Some("bookworm"),
EntryKind::Backport,
)
.await
.unwrap();
unsafe {
std::env::remove_var("DEBFULLNAME");
std::env::remove_var("DEBEMAIL");
}
assert_eq!(entry.new_version, "1.0-1~bpo12+1");
assert_eq!(entry.series, "bookworm");
let content = std::fs::read_to_string(&changelog_path).unwrap();
assert!(content.contains("mypackage (1.0-1~bpo12+1) bookworm; urgency=medium"));
}
/// Backport numbering needs a usable release number: Debian's rolling
/// series (sid) carry none and are rejected before anything is written,
/// pointing at --series/--version instead.
#[tokio::test]
async fn test_generate_entry_backport_requires_numbered_series() {
let temp_dir = TempDir::new().unwrap();
let repo_dir = temp_dir.path();
let changelog_path = repo_dir.join("debian/changelog");
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
std::fs::write(
&changelog_path,
"mypackage (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",
)
.unwrap();
let result = generate_entry(
"debian/changelog",
Some(repo_dir),
None,
Some("sid"),
EntryKind::Backport,
)
.await;
assert!(result.is_err());
// Nothing was written: the changelog is untouched
let content = std::fs::read_to_string(&changelog_path).unwrap();
assert!(content.starts_with("mypackage (1.0-1) unstable"));
}
/// Ubuntu backports follow the per-release SRU scheme of the Ubuntu
/// version-strings documentation: the development release's version
/// with ~YY.MM.1 appended, independently of what the target release
/// carries. Relies on the host distro-info data mapping noble to
/// 24.04 (see the distro_info tests).
#[tokio::test]
async fn test_generate_entry_backport_ubuntu_numbering() {
let temp_dir = TempDir::new().unwrap();
let repo_dir = temp_dir.path();
setup_repo(repo_dir);
let changelog_path = repo_dir.join("debian/changelog");
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
std::fs::write(
&changelog_path,
"mypackage (3.1-1ubuntu2) questing; urgency=medium\n\n * Initial release\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n",
)
.unwrap();
let entry = generate_entry(
"debian/changelog",
Some(repo_dir),
None,
Some("noble"),
EntryKind::Backport,
)
.await
.unwrap();
assert_eq!(entry.new_version, "3.1-1ubuntu2~24.04.1");
assert_eq!(entry.series, "noble");
let content = std::fs::read_to_string(&changelog_path).unwrap();
assert!(content.contains("mypackage (3.1-1ubuntu2~24.04.1) noble; urgency=medium"));
}
/// A regular upload is numbered after the vendor of its target series:
/// a Debian-style version uploaded to an Ubuntu series gains the
/// ubuntu1 suffix, and re-bumping the now-Ubuntu changelog increments
/// that counter instead of the revision. Relies on the host distro-info
/// data listing noble (see the distro_info tests). The git repo
/// provides the maintainer identity: DEBFULLNAME/DEBEMAIL are
/// process-global and other tests mutate them in parallel.
#[tokio::test]
async fn test_generate_entry_ubuntu_series_numbering() {
let temp_dir = TempDir::new().unwrap();
let repo_dir = temp_dir.path();
setup_repo(repo_dir);
let changelog_path = repo_dir.join("debian/changelog");
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
std::fs::write(
&changelog_path,
"mypackage (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",
)
.unwrap();
let entry = generate_entry(
"debian/changelog",
Some(repo_dir),
None,
Some("noble"),
EntryKind::Normal,
)
.await
.unwrap();
assert_eq!(entry.new_version, "1.0-1ubuntu1");
assert_eq!(entry.series, "noble");
// Re-bumping the now-Ubuntu changelog increments the counter
let entry = generate_entry(
"debian/changelog",
Some(repo_dir),
None,
Some("noble"),
EntryKind::Normal,
)
.await
.unwrap();
assert_eq!(entry.new_version, "1.0-1ubuntu2");
}
/// An Ubuntu upload of a package whose changelog carries a rebuild
/// version drops the buildN marker: 1.0-1build1 numbers the next entry
/// 1.0-1ubuntu1 (1.0-1build1ubuntu1 would sort below 1.0-1ubuntu1).
/// Relies on the host distro-info data listing noble (see the
/// distro_info tests). The git repo provides the maintainer identity:
/// DEBFULLNAME/DEBEMAIL are process-global and other tests mutate them
/// in parallel.
#[tokio::test]
async fn test_generate_entry_ubuntu_series_numbering_after_rebuild() {
let temp_dir = TempDir::new().unwrap();
let repo_dir = temp_dir.path();
setup_repo(repo_dir);
let changelog_path = repo_dir.join("debian/changelog");
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
std::fs::write(
&changelog_path,
"mypackage (1.0-1build1) noble; urgency=medium\n\n * Initial release\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n",
)
.unwrap();
let entry = generate_entry(
"debian/changelog",
Some(repo_dir),
None,
Some("noble"),
EntryKind::Normal,
)
.await
.unwrap();
assert_eq!(entry.new_version, "1.0-1ubuntu1");
}
#[test]
fn test_get_maintainer_info() {
let _identity = IDENTITY_LOCK.blocking_lock();
// 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");
}
}
}