lift main.rs business logic into the library

The chlog target-series resolution becomes changelog::series_candidates
(UNRELEASED pinning, development-series default and fallbacks modeled
by SeriesCandidates), PPA references get package_info::split_ppa
(shared by pull and deb, now also rejecting empty parts), and SSH
endpoints get context::ContextConfig::from_endpoint — so a library
consumer can resolve series, validate PPAs and build context
configurations without reimplementing the CLI's rules. All three carry
unit tests; main.rs keeps only parsing of flags and error handling.
This commit is contained in:
2026-09-18 20:51:36 +02:00
parent 0421a91e01
commit c2dae4f3f9
5 changed files with 325 additions and 120 deletions
+148
View File
@@ -139,6 +139,78 @@ pub fn parse_changelog_header(
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 {
/// Series names to offer.
options: Vec<String>,
/// Preselected series.
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. `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();
let mut options = vec![crate::distro_info::UNRELEASED.to_string()];
match crate::distro_info::get_ordered_series_name(&dist).await {
Ok(series_list) => {
options.extend(series_list);
// 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,
default,
fallback: current,
})
}
Err(_) => Some(SeriesCandidates::Keep(current)),
}
} else {
match crate::distro_info::get_dist_from_series(&current).await {
Ok(dist) => {
match crate::distro_info::get_ordered_series_name(&dist).await {
Ok(options) if !options.is_empty() => Some(SeriesCandidates::Choose {
options,
default: current.clone(),
fallback: current,
}),
// An empty list offers nothing to choose from
_ => Some(SeriesCandidates::Keep(current)),
}
}
Err(_) => Some(SeriesCandidates::Keep(current)),
}
}
}
/// 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>> {
@@ -306,6 +378,82 @@ mod tests {
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,
default,
fallback,
}) => {
assert_eq!(options[0], "UNRELEASED");
assert!(options.len() > 1, "the vendor series list is offered");
assert_eq!(default, options[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,
default,
fallback,
}) => {
assert_eq!(options, vendor_series);
assert_eq!(default, *current);
assert_eq!(fallback, *current);
}
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()
);
}
fn setup_repo(dir: &Path) {
Command::new("git")
.arg("init")