From d3a07bc80d960d8543d6c50a6b32c789f6d33928 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Tue, 11 Aug 2026 14:42:23 +0200 Subject: [PATCH] chlog: add series selector --- Cargo.toml | 1 + src/changelog.rs | 4 +- src/main.rs | 44 +++++++++++- src/ui.rs | 180 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1076d43..a50d764 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ authors = ["vhaudiquet"] [dependencies] clap = { version = "4.5.51", features = ["cargo"] } cmd_lib = "2.0.0" +crossterm = "0.28" flate2 = "1.1.5" serde = { version = "1.0.228", features = ["derive"] } libc = "0.2" diff --git a/src/changelog.rs b/src/changelog.rs index 7dfbe2f..2e3cc47 100644 --- a/src/changelog.rs +++ b/src/changelog.rs @@ -10,6 +10,7 @@ pub fn generate_entry( changelog_file: &str, cwd: Option<&Path>, user_version: Option<&str>, + target_series: Option<&str>, ) -> Result<(), Box> { let changelog_path = if let Some(path) = cwd { path.join(changelog_file) @@ -43,6 +44,7 @@ pub fn generate_entry( }; let (maintainer_name, maintainer_email) = get_maintainer_info()?; + let series = target_series.unwrap_or(&series).to_string(); let new_entry = format_entry( &package, &new_version, @@ -426,7 +428,7 @@ mod tests { std::env::set_var("DEBFULLNAME", "Maintainer Maintainer"); std::env::set_var("DEBEMAIL", "maintainer@maintainer.com"); } - generate_entry("debian/changelog", Some(repo_dir), None).unwrap(); + generate_entry("debian/changelog", Some(repo_dir), None, None).unwrap(); unsafe { std::env::remove_var("DEBFULLNAME"); std::env::remove_var("DEBEMAIL"); diff --git a/src/main.rs b/src/main.rs index e5ae3ca..6e893d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -150,8 +150,50 @@ fn main() { Some(("chlog", sub_matches)) => { let cwd = current_dir_or_exit(); let version = sub_matches.get_one::("version").map(|s| s.as_str()); + let cli_series = sub_matches.get_one::("series").map(|s| s.as_str()); - if let Err(e) = generate_entry("debian/changelog", Some(&cwd), version) { + // Determine target series: CLI flag > interactive selector > current changelog series + let target_series = if let Some(s) = cli_series { + Some(s.to_string()) + } else { + // Parse current changelog to determine the default series + let changelog_path = cwd.join("debian/changelog"); + match pkh::changelog::parse_changelog_header(&changelog_path) { + Ok((_pkg, _ver, current_series)) => { + // Try to get the list of available series for this distribution + match rt.block_on(async { + let dist = + pkh::distro_info::get_dist_from_series(¤t_series).await?; + pkh::distro_info::get_ordered_series_name(&dist).await + }) { + Ok(series_list) => { + match ui::select_series(&series_list, ¤t_series) { + Ok(selected) => Some(selected), + Err(e) => { + error!( + "Series selection failed: {}. Using current series '{}' instead.", + e, current_series + ); + Some(current_series) + } + } + } + Err(_) => { + // Could not fetch series list, use current series as default + Some(current_series) + } + } + } + Err(_) => None, + } + }; + + if let Err(e) = generate_entry( + "debian/changelog", + Some(&cwd), + version, + target_series.as_deref(), + ) { error!("{}", e); std::process::exit(1); } diff --git a/src/ui.rs b/src/ui.rs index 80b8188..fb89a9d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,4 +1,10 @@ +use crossterm::{ + cursor, event, execute, + style::{self, Color, Print, SetForegroundColor}, + terminal, +}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use std::io::{self, Write}; use std::time::Duration; pub fn create_progress_bar( @@ -39,3 +45,177 @@ pub fn create_progress_bar( (pb, callback) } + +const SELECT_PROMPT: &str = "> Select series: "; + +/// Interactive one-line series selector with arrow key navigation and direct typing. +/// +/// The user can: +/// - Navigate with ↑/↓ arrow keys to cycle through options +/// - Type directly to enter a custom release name +/// - Press Tab to cycle through options matching the current input prefix +/// - Press Enter to confirm, Escape to use the default +/// +/// Returns the selected series name, or an error if cancelled (Ctrl+C). +pub fn select_series( + options: &[String], + default: &str, +) -> Result> { + if options.is_empty() { + println!("{}{} (default)", SELECT_PROMPT, default); + return Ok(default.to_string()); + } + + // Try to enter raw mode — if not possible (e.g. piped input), fall back to default + if terminal::enable_raw_mode().is_err() { + println!("{}{} (default)", SELECT_PROMPT, default); + return Ok(default.to_string()); + } + + let result = select_series_inner(options, default); + + // Always restore the terminal + let _ = terminal::disable_raw_mode(); + + result +} + +fn select_series_inner( + options: &[String], + default: &str, +) -> Result> { + let default_idx = options.iter().position(|o| o == default).unwrap_or(0); + let mut selected_idx = default_idx; + let mut input = default.to_string(); + // Whether we are in "navigation mode" (last action was arrow/tab selecting an option) + // vs "typing mode" (last action was typing a character) + let mut navigating = true; + + let prompt_len = SELECT_PROMPT.len(); + + let mut stdout = io::stdout(); + + // Draw the prompt line, clearing any previous content + let draw = |stdout: &mut io::Stdout, input: &str| -> Result<(), Box> { + execute!( + stdout, + cursor::MoveToColumn(0), + Print(SELECT_PROMPT), + SetForegroundColor(Color::Cyan), + Print(input), + style::ResetColor, + terminal::Clear(terminal::ClearType::UntilNewLine), + )?; + stdout.flush()?; + Ok(()) + }; + + draw(&mut stdout, &input)?; + + loop { + if event::poll(Duration::from_millis(200))? + && let event::Event::Key(event::KeyEvent { + code, modifiers, .. + }) = event::read()? + { + match code { + event::KeyCode::Up => { + navigating = true; + if selected_idx > 0 { + selected_idx -= 1; + } else { + selected_idx = options.len() - 1; + } + input = options[selected_idx].clone(); + draw(&mut stdout, &input)?; + } + event::KeyCode::Down => { + navigating = true; + if selected_idx < options.len() - 1 { + selected_idx += 1; + } else { + selected_idx = 0; + } + input = options[selected_idx].clone(); + draw(&mut stdout, &input)?; + } + event::KeyCode::Enter => { + break; + } + event::KeyCode::Esc => { + input = default.to_string(); + break; + } + event::KeyCode::Char(c) => { + if modifiers.contains(event::KeyModifiers::CONTROL) && c == 'c' { + // Move to a new line before returning so the terminal isn't messed up + execute!(stdout, Print("\r\n"))?; + return Err("Cancelled".into()); + } + navigating = false; + input.push(c); + // Auto-select if the input exactly matches an option + if let Some(idx) = options.iter().position(|o| o == &input) { + selected_idx = idx; + navigating = true; + } + draw(&mut stdout, &input)?; + } + event::KeyCode::Backspace => { + if !input.is_empty() { + input.pop(); + navigating = false; + if let Some(idx) = options.iter().position(|o| o == &input) { + selected_idx = idx; + navigating = true; + } + // Need to redraw — move cursor to end of prompt + input + execute!( + stdout, + cursor::MoveToColumn(prompt_len as u16), + Print(&input), + terminal::Clear(terminal::ClearType::UntilNewLine), + )?; + stdout.flush()?; + } + } + event::KeyCode::Tab => { + // Cycle through options that start with the current input + let matches: Vec = options + .iter() + .enumerate() + .filter(|(_, o)| o.starts_with(&input)) + .map(|(i, _)| i) + .collect(); + if !matches.is_empty() { + let next = if navigating { + matches + .iter() + .find(|&&i| i > selected_idx) + .or_else(|| matches.first()) + } else { + matches.first() + }; + if let Some(&idx) = next { + selected_idx = idx; + input = options[idx].clone(); + navigating = true; + } + draw(&mut stdout, &input)?; + } + } + _ => {} + } + } + } + + // Move to a new line so subsequent output doesn't overwrite the prompt + execute!( + stdout, + terminal::Clear(terminal::ClearType::UntilNewLine), + Print("\r\n"), + )?; + stdout.flush()?; + + Ok(input) +}