From 02e1f739c34826144b680485f70d19653ee7c655 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Mon, 21 Sep 2026 11:52:39 +0200 Subject: [PATCH] chlog,cli,docs: offer suite-aliased series in the chlog selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ' ()' — '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. --- README.md | 1 + src/changelog.rs | 206 ++++++++++++++++++++++++++++++++++++++++++----- src/main.rs | 5 +- 3 files changed, 191 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index c7a0e79..1936205 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ Missing features: - [ ] `pkh chlog` - [x] Auto-generate changelog entry - [x] Extra flags: backport, non-maintainer upload, no change rebuild, ... + - [x] Select the target series, matching changelog suite names with their series (unstable ≡ sid) - [ ] Commit changelog entry - [x] `pkh new` - [x] Scaffold a new Debian source package (interactive, multiple languages) diff --git a/src/changelog.rs b/src/changelog.rs index 1d96b1b..10578a5 100644 --- a/src/changelog.rs +++ b/src/changelog.rs @@ -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 ` ()` + /// for a series aliased by a changelog suite name (Debian's + /// 'unstable (sid)'). options: Vec, - /// 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, + /// 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 { let (_package, _version, current) = parse_changelog_header(changelog_path).ok()?; @@ -321,10 +330,13 @@ pub async fn series_candidates(changelog_path: &Path) -> Option { - 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 Option Some(SeriesCandidates::Keep(current)), } } else { - match crate::distro_info::get_dist_from_series(¤t).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(¤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> { @@ -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 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() { diff --git a/src/main.rs b/src/main.rs index 359e4cb..7aabb16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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.",