chlog: add series selector
CI / build (push) Successful in 15m3s
CI / snap (push) Successful in 2m6s

This commit is contained in:
2026-08-11 14:42:23 +02:00
parent e3d855ca66
commit d3a07bc80d
4 changed files with 227 additions and 2 deletions
+1
View File
@@ -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"
+3 -1
View File
@@ -10,6 +10,7 @@ pub fn generate_entry(
changelog_file: &str,
cwd: Option<&Path>,
user_version: Option<&str>,
target_series: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
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");
+43 -1
View File
@@ -150,8 +150,50 @@ fn main() {
Some(("chlog", sub_matches)) => {
let cwd = current_dir_or_exit();
let version = sub_matches.get_one::<String>("version").map(|s| s.as_str());
let cli_series = sub_matches.get_one::<String>("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(&current_series).await?;
pkh::distro_info::get_ordered_series_name(&dist).await
}) {
Ok(series_list) => {
match ui::select_series(&series_list, &current_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);
}
+180
View File
@@ -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<String, Box<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<usize> = 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)
}