chlog: add series selector
This commit is contained in:
@@ -7,6 +7,7 @@ authors = ["vhaudiquet"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.5.51", features = ["cargo"] }
|
clap = { version = "4.5.51", features = ["cargo"] }
|
||||||
cmd_lib = "2.0.0"
|
cmd_lib = "2.0.0"
|
||||||
|
crossterm = "0.28"
|
||||||
flate2 = "1.1.5"
|
flate2 = "1.1.5"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|||||||
+3
-1
@@ -10,6 +10,7 @@ pub fn generate_entry(
|
|||||||
changelog_file: &str,
|
changelog_file: &str,
|
||||||
cwd: Option<&Path>,
|
cwd: Option<&Path>,
|
||||||
user_version: Option<&str>,
|
user_version: Option<&str>,
|
||||||
|
target_series: Option<&str>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let changelog_path = if let Some(path) = cwd {
|
let changelog_path = if let Some(path) = cwd {
|
||||||
path.join(changelog_file)
|
path.join(changelog_file)
|
||||||
@@ -43,6 +44,7 @@ pub fn generate_entry(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (maintainer_name, maintainer_email) = get_maintainer_info()?;
|
let (maintainer_name, maintainer_email) = get_maintainer_info()?;
|
||||||
|
let series = target_series.unwrap_or(&series).to_string();
|
||||||
let new_entry = format_entry(
|
let new_entry = format_entry(
|
||||||
&package,
|
&package,
|
||||||
&new_version,
|
&new_version,
|
||||||
@@ -426,7 +428,7 @@ mod tests {
|
|||||||
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
|
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
|
||||||
std::env::set_var("DEBEMAIL", "maintainer@maintainer.com");
|
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 {
|
unsafe {
|
||||||
std::env::remove_var("DEBFULLNAME");
|
std::env::remove_var("DEBFULLNAME");
|
||||||
std::env::remove_var("DEBEMAIL");
|
std::env::remove_var("DEBEMAIL");
|
||||||
|
|||||||
+43
-1
@@ -150,8 +150,50 @@ fn main() {
|
|||||||
Some(("chlog", sub_matches)) => {
|
Some(("chlog", sub_matches)) => {
|
||||||
let cwd = current_dir_or_exit();
|
let cwd = current_dir_or_exit();
|
||||||
let version = sub_matches.get_one::<String>("version").map(|s| s.as_str());
|
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(¤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);
|
error!("{}", e);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
|
use crossterm::{
|
||||||
|
cursor, event, execute,
|
||||||
|
style::{self, Color, Print, SetForegroundColor},
|
||||||
|
terminal,
|
||||||
|
};
|
||||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
|
use std::io::{self, Write};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
pub fn create_progress_bar(
|
pub fn create_progress_bar(
|
||||||
@@ -39,3 +45,177 @@ pub fn create_progress_bar(
|
|||||||
|
|
||||||
(pb, callback)
|
(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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user