chlog,cli,docs: offer suite-aliased series in the chlog selector

The chlog series selector only appeared when the changelog's current
distribution resolved to a known series; a Debian package targeting
'unstable' (or 'stable', 'testing', ...) fell through to keeping the
current series, silently, with no menu.

Resolve the changelog distribution through the suite aliases first
(unstable identifies the same series as sid, which resolves to the
Debian series list). The selector offers an aliased series as
'<suite> (<series>)' — 'unstable (sid)' — preselected, but selects
the suite name: what a changelog distribution field expects, instead
of the codename. Every other label and free-typed input selects
itself, unchanged.

Reflect the selector in the README roadmap checklist.
This commit is contained in:
2026-09-21 11:52:39 +02:00
parent 8c6f6f4028
commit 02e1f739c3
3 changed files with 191 additions and 21 deletions
+186 -20
View File
@@ -294,9 +294,16 @@ pub enum SeriesCandidates {
/// cannot be made (cancelled, no interactive user) `fallback` — the
/// changelog's current series — is used instead.
Choose {
/// Series names to offer.
/// Selector labels: each series name, or `<suite> (<series>)`
/// for a series aliased by a changelog suite name (Debian's
/// 'unstable (sid)').
options: Vec<String>,
/// Preselected series.
/// 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,
@@ -312,8 +319,10 @@ pub enum SeriesCandidates {
/// 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).
/// 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()?;
@@ -321,10 +330,13 @@ pub async fn series_candidates(changelog_path: &Path) -> Option<SeriesCandidates
// 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);
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 {
@@ -334,6 +346,7 @@ pub async fn series_candidates(changelog_path: &Path) -> Option<SeriesCandidates
};
Some(SeriesCandidates::Choose {
options,
values,
default,
fallback: current,
})
@@ -341,22 +354,81 @@ pub async fn series_candidates(changelog_path: &Path) -> Option<SeriesCandidates
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 {
// Even an empty list goes through the selector: its
// fallback prints and takes the default, like it always has
Ok(options) => Some(SeriesCandidates::Choose {
options,
default: current.clone(),
fallback: current,
}),
Err(_) => Some(SeriesCandidates::Keep(current)),
},
Err(_) => Some(SeriesCandidates::Keep(current)),
// 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>> {
@@ -639,12 +711,18 @@ mod tests {
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:?}"),
@@ -677,17 +755,105 @@ mod tests {
match series_candidates(&path).await {
Some(SeriesCandidates::Choose {
options,
values,
default,
fallback,
}) => {
assert_eq!(options, vendor_series);
assert_eq!(default, *current);
// 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() {
+4 -1
View File
@@ -445,10 +445,13 @@ fn main() {
match rt.block_on(pkh::changelog::series_candidates(&changelog_path)) {
Some(pkh::changelog::SeriesCandidates::Choose {
options,
values,
default,
fallback,
}) => match pkh::ui::select_series(&options, &default) {
Ok(selected) => Some(selected),
Ok(selected) => {
Some(pkh::changelog::selected_series(&options, &values, selected))
}
Err(e) => {
error!(
"Series selection failed: {}. Using current series '{}' instead.",