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> { 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(¤t_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> { 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> { 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> { // 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> { 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 ` ()` /// for a series aliased by a changelog suite name (Debian's /// 'unstable (sid)'). options: Vec, /// 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, /// 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 { let (_package, _version, current) = parse_changelog_header(changelog_path).ok()?; if crate::distro_info::is_unreleased(¤t) { // 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(¤t).await { Ok(dist) => Some((dist, current.clone())), Err(_) => crate::distro_info::resolve_suite_alias(¤t), }; 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 /// ` ()` 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, Vec, Option) { 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> { 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, Box> { // 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 { 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 { 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, Box> { 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 { 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>, Box> { 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> { 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 { 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> { 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> { // 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 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 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 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 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> = 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 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 ")); // 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 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 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 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 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 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 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 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 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 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"); } } }