uv-console 0.0.74

This is an internal component crate of uv
Documentation
use console::{Key, Term, measure_text_width, style};
use std::{cmp::Ordering, fmt, iter};

/// Prompt the user for confirmation in the given [`Term`].
///
/// This is a slimmed-down version of `dialoguer::Confirm`, with the post-confirmation report
/// enabled.
pub fn confirm(message: &str, term: &Term, default: bool) -> std::io::Result<bool> {
    confirm_inner(message, None, term, default)
}

/// Prompt the user for confirmation in the given [`Term`], with a hint.
pub fn confirm_with_hint(
    message: &str,
    hint: &str,
    term: &Term,
    default: bool,
) -> std::io::Result<bool> {
    confirm_inner(message, Some(hint), term, default)
}

fn confirm_inner(
    message: &str,
    hint: Option<&str>,
    term: &Term,
    default: bool,
) -> std::io::Result<bool> {
    let prompt = format!(
        "{} {} {} {} {}",
        style("?".to_string()).for_stderr().yellow(),
        style(message).for_stderr().bold(),
        style("[y/n]").for_stderr().black().bright(),
        style("").for_stderr().black().bright(),
        style(if default { "yes" } else { "no" })
            .for_stderr()
            .cyan(),
    );

    term.write_str(&prompt)?;
    if let Some(hint) = hint {
        term.write_str(&format!(
            "\n\n{}{} {hint}",
            style("hint").for_stderr().bold().cyan(),
            style(":").for_stderr().bold()
        ))?;
    }
    term.hide_cursor()?;
    term.flush()?;

    // Match continuously on every keystroke, and do not wait for user to hit the
    // `Enter` key.
    let response = loop {
        let input = term.read_key_raw()?;
        match input {
            Key::Char('y' | 'Y') => break true,
            Key::Char('n' | 'N') => break false,
            Key::Enter => break default,
            Key::CtrlC => {
                let term = Term::stderr();
                term.show_cursor()?;
                term.write_str("\n")?;
                term.flush()?;

                #[expect(clippy::exit, clippy::cast_possible_wrap)]
                std::process::exit(if cfg!(windows) {
                    0xC000_013A_u32 as i32
                } else {
                    130
                });
            }
            _ => {}
        }
    };

    let report = format!(
        "{} {} {} {}",
        style("".to_string()).for_stderr().green(),
        style(message).for_stderr().bold(),
        style("·").for_stderr().black().bright(),
        style(if response { "yes" } else { "no" })
            .for_stderr()
            .cyan(),
    );

    if hint.is_some() {
        term.clear_last_lines(2)?;
        // It's not clear why we need to clear to the end of the screen here, but it fixes lingering
        // display of the hint on `bash` (the issue did not reproduce on `zsh`).
        term.clear_to_end_of_screen()?;
    } else {
        term.clear_line()?;
    }
    term.write_line(&report)?;
    term.show_cursor()?;
    term.flush()?;

    Ok(response)
}

/// Prompt the user for password in the given [`Term`].
///
/// This is a slimmed-down version of `dialoguer::Password`.
pub fn password(prompt: &str, term: &Term) -> std::io::Result<String> {
    term.write_str(prompt)?;
    term.show_cursor()?;
    term.flush()?;

    let input = term.read_secure_line()?;

    term.clear_line()?;

    Ok(input)
}

/// Prompt the user for username in the given [`Term`].
pub fn username(prompt: &str, term: &Term) -> std::io::Result<String> {
    term.write_str(prompt)?;
    term.show_cursor()?;
    term.flush()?;

    let input = term.read_line()?;

    term.clear_line()?;

    Ok(input)
}

/// Prompt the user for input text in the given [`Term`].
///
/// This is a slimmed-down version of `dialoguer::Input`.
#[allow(
    // Suppress Clippy lints triggered by `dialoguer::Input`.
    clippy::cast_possible_truncation,
    clippy::cast_possible_wrap,
    clippy::cast_sign_loss
)]
pub fn input(prompt: &str, term: &Term) -> std::io::Result<String> {
    term.write_str(prompt)?;
    term.show_cursor()?;
    term.flush()?;

    let prompt_len = measure_text_width(prompt);

    let mut chars: Vec<char> = Vec::new();
    let mut position = 0;
    loop {
        match term.read_key()? {
            Key::Backspace if position > 0 => {
                position -= 1;
                chars.remove(position);
                let line_size = term.size().1 as usize;
                // Case we want to delete last char of a line so the cursor is at the beginning of the next line
                if (position + prompt_len).is_multiple_of(line_size - 1) {
                    term.clear_line()?;
                    term.move_cursor_up(1)?;
                    term.move_cursor_right(line_size + 1)?;
                } else {
                    term.clear_chars(1)?;
                }

                let tail: String = chars[position..].iter().collect();

                if !tail.is_empty() {
                    term.write_str(&tail)?;

                    let total = position + prompt_len + tail.chars().count();
                    let total_line = total / line_size;
                    let line_cursor = (position + prompt_len) / line_size;
                    term.move_cursor_up(total_line - line_cursor)?;

                    term.move_cursor_left(line_size)?;
                    term.move_cursor_right((position + prompt_len) % line_size)?;
                }

                term.flush()?;
            }
            Key::Char(chr) if !chr.is_ascii_control() => {
                chars.insert(position, chr);
                position += 1;
                let tail: String = iter::once(&chr).chain(chars[position..].iter()).collect();
                term.write_str(&tail)?;
                term.move_cursor_left(tail.chars().count() - 1)?;
                term.flush()?;
            }
            Key::ArrowLeft if position > 0 => {
                if (position + prompt_len).is_multiple_of(term.size().1 as usize) {
                    term.move_cursor_up(1)?;
                    term.move_cursor_right(term.size().1 as usize)?;
                } else {
                    term.move_cursor_left(1)?;
                }
                position -= 1;
                term.flush()?;
            }
            Key::ArrowRight if position < chars.len() => {
                if (position + prompt_len).is_multiple_of(term.size().1 as usize - 1) {
                    term.move_cursor_down(1)?;
                    term.move_cursor_left(term.size().1 as usize)?;
                } else {
                    term.move_cursor_right(1)?;
                }
                position += 1;
                term.flush()?;
            }
            Key::UnknownEscSeq(seq) if seq == vec!['b'] => {
                let line_size = term.size().1 as usize;
                let nb_space = chars[..position]
                    .iter()
                    .rev()
                    .take_while(|c| c.is_whitespace())
                    .count();
                let find_last_space = chars[..position - nb_space]
                    .iter()
                    .rposition(|c| c.is_whitespace());

                // If we find a space we set the cursor to the next char else we set it to the beginning of the input
                if let Some(mut last_space) = find_last_space {
                    if last_space < position {
                        last_space += 1;
                        let new_line = (prompt_len + last_space) / line_size;
                        let old_line = (prompt_len + position) / line_size;
                        let diff_line = old_line - new_line;
                        if diff_line != 0 {
                            term.move_cursor_up(old_line - new_line)?;
                        }

                        let new_pos_x = (prompt_len + last_space) % line_size;
                        let old_pos_x = (prompt_len + position) % line_size;
                        let diff_pos_x = new_pos_x as i64 - old_pos_x as i64;
                        if diff_pos_x < 0 {
                            term.move_cursor_left(-diff_pos_x as usize)?;
                        } else {
                            term.move_cursor_right((diff_pos_x) as usize)?;
                        }
                        position = last_space;
                    }
                } else {
                    term.move_cursor_left(position)?;
                    position = 0;
                }

                term.flush()?;
            }
            Key::UnknownEscSeq(seq) if seq == vec!['f'] => {
                let line_size = term.size().1 as usize;
                let find_next_space = chars[position..].iter().position(|c| c.is_whitespace());

                // If we find a space we set the cursor to the next char else we set it to the beginning of the input
                if let Some(mut next_space) = find_next_space {
                    let nb_space = chars[position + next_space..]
                        .iter()
                        .take_while(|c| c.is_whitespace())
                        .count();
                    next_space += nb_space;
                    let new_line = (prompt_len + position + next_space) / line_size;
                    let old_line = (prompt_len + position) / line_size;
                    term.move_cursor_down(new_line - old_line)?;

                    let new_pos_x = (prompt_len + position + next_space) % line_size;
                    let old_pos_x = (prompt_len + position) % line_size;
                    let diff_pos_x = new_pos_x as i64 - old_pos_x as i64;
                    if diff_pos_x < 0 {
                        term.move_cursor_left(-diff_pos_x as usize)?;
                    } else {
                        term.move_cursor_right((diff_pos_x) as usize)?;
                    }
                    position += next_space;
                } else {
                    let new_line = (prompt_len + chars.len()) / line_size;
                    let old_line = (prompt_len + position) / line_size;
                    term.move_cursor_down(new_line - old_line)?;

                    let new_pos_x = (prompt_len + chars.len()) % line_size;
                    let old_pos_x = (prompt_len + position) % line_size;
                    let diff_pos_x = new_pos_x as i64 - old_pos_x as i64;
                    match diff_pos_x.cmp(&0) {
                        Ordering::Less => {
                            term.move_cursor_left((-diff_pos_x - 1) as usize)?;
                        }
                        Ordering::Equal => {}
                        Ordering::Greater => {
                            term.move_cursor_right((diff_pos_x) as usize)?;
                        }
                    }
                    position = chars.len();
                }

                term.flush()?;
            }
            Key::Enter => break,
            _ => (),
        }
    }
    let input = chars.iter().collect::<String>();
    term.write_line("")?;

    Ok(input)
}

/// Formats a number of bytes into a human readable IEC-prefixed size (binary units).
#[expect(clippy::cast_precision_loss)]
pub fn human_readable_bytes(bytes: u64) -> impl fmt::Display {
    const UNITS: [&str; 7] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];

    fmt::from_fn(move |formatter| {
        if bytes < 1024 {
            return write!(formatter, "{bytes}B");
        }

        let rounding_margin = formatter
            .precision()
            .and_then(|precision| i32::try_from(precision).ok())
            .map_or(0.0, |precision| 0.5 / 10_f64.powi(precision));
        let mut quantity = bytes as f64;
        let [mut unit, units @ ..] = UNITS;

        for next_unit in units {
            if 1024.0 - quantity > rounding_margin {
                break;
            }
            quantity /= 1024.0;
            unit = next_unit;
        }

        fmt::Display::fmt(&quantity, formatter)?;
        formatter.write_str(unit)
    })
}

#[cfg(test)]
mod tests {
    use super::human_readable_bytes;

    #[test]
    fn human_readable_sizes() {
        const MIB: u64 = 1024_u64.pow(2);
        const PIB: u64 = 1024_u64.pow(5);

        assert_eq!(format!("{}", human_readable_bytes(0)), "0B");
        assert_eq!(format!("{}", human_readable_bytes(1024)), "1KiB");
        assert_eq!(format!("{}", human_readable_bytes(1536)), "1.5KiB");
        assert_eq!(format!("{}", human_readable_bytes(u64::MAX)), "16EiB");
        assert_eq!(
            format!("{}", human_readable_bytes(MIB - 51)),
            "1023.9501953125KiB"
        );

        assert_eq!(format!("{:.2}", human_readable_bytes(1023)), "1023B");
        assert_eq!(format!("{:.0}", human_readable_bytes(MIB - 513)), "1023KiB");
        assert_eq!(format!("{:.0}", human_readable_bytes(MIB - 512)), "1MiB");
        assert_eq!(
            format!("{:.1}", human_readable_bytes(MIB - 52)),
            "1023.9KiB"
        );
        assert_eq!(format!("{:.1}", human_readable_bytes(MIB - 51)), "1.0MiB");
        assert_eq!(
            format!("{:.2}", human_readable_bytes(MIB - 51)),
            "1023.95KiB"
        );

        assert_eq!(
            format!("{:.10}", human_readable_bytes(PIB - 55)),
            "1023.9999999999TiB"
        );
        // Specifically trigger a floating point imprecision corner case.
        assert_eq!(
            format!("{:.1}", human_readable_bytes(PIB * 1024 - PIB / 20 - 1)),
            "1.0EiB"
        );
    }
}