upstream-rs 1.16.2

Fetch package updates directly from the source.
Documentation
use console::{StyledObject, style};
use indicatif::HumanBytes;
use std::fmt;
use std::io::{self, IsTerminal, Write};
use std::sync::atomic::{AtomicBool, Ordering};

static ASSUME_YES: AtomicBool = AtomicBool::new(false);
use crate::services::packaging::disk_impact::{
    ByteEstimate, DiskImpact, SignedByteEstimate, SizeConfidence,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
    Ok,
    Warn,
    Fail,
    Plan,
    Skip,
}

pub fn title(text: impl fmt::Display) -> StyledObject<String> {
    style(text.to_string()).cyan().bold()
}

pub fn section(text: impl fmt::Display) -> StyledObject<String> {
    style(text.to_string()).bold()
}

pub fn meta(text: impl fmt::Display) -> StyledObject<String> {
    style(text.to_string()).dim()
}

pub fn success(text: impl fmt::Display) -> StyledObject<String> {
    style(text.to_string()).green()
}

pub fn warning(text: impl fmt::Display) -> StyledObject<String> {
    style(text.to_string()).yellow()
}

pub fn failure(text: impl fmt::Display) -> StyledObject<String> {
    style(text.to_string()).red()
}

pub fn kv(label: &str, value: impl fmt::Display) {
    println!("  {:<13} {}", meta(format!("{label}:")), value);
}

pub fn action_note(text: impl fmt::Display) {
    println!("  {}", meta(text));
}

pub fn set_assume_yes(value: bool) {
    ASSUME_YES.store(value, Ordering::Relaxed);
}

pub fn assume_yes() -> bool {
    ASSUME_YES.load(Ordering::Relaxed)
}

pub fn confirm(prompt: impl fmt::Display) -> anyhow::Result<bool> {
    if assume_yes() {
        return Ok(true);
    }

    if !io::stdin().is_terminal() {
        anyhow::bail!(
            "Confirmation required for non-interactive input. Re-run with --yes to continue."
        );
    }

    print!("{} [y/N]: ", prompt);
    io::stdout().flush()?;

    let mut input = String::new();
    io::stdin().read_line(&mut input)?;
    Ok(matches!(
        input.trim().to_ascii_lowercase().as_str(),
        "y" | "yes"
    ))
}

pub fn confirm_or_cancel(prompt: impl fmt::Display) -> anyhow::Result<()> {
    if confirm(prompt)? {
        return Ok(());
    }
    anyhow::bail!("Cancelled")
}

pub fn divider(width: usize) -> String {
    "-".repeat(width)
}

pub fn truncate_end(value: &str, max: usize) -> String {
    let char_count = value.chars().count();
    if char_count <= max {
        return value.to_string();
    }
    if max <= 3 {
        return ".".repeat(max);
    }

    let mut out = String::new();
    for ch in value.chars().take(max - 3) {
        out.push(ch);
    }
    out.push_str("...");
    out
}

pub fn truncate_middle(value: &str, max: usize) -> String {
    let char_count = value.chars().count();
    if char_count <= max {
        return value.to_string();
    }
    if max <= 3 {
        return ".".repeat(max);
    }

    let keep = max - 3;
    let prefix_len = keep / 2;
    let suffix_len = keep - prefix_len;
    let prefix: String = value.chars().take(prefix_len).collect();
    let suffix: String = value
        .chars()
        .rev()
        .take(suffix_len)
        .collect::<Vec<_>>()
        .into_iter()
        .rev()
        .collect();
    format!("{prefix}...{suffix}")
}

pub fn is_sensitive_key(key: &str) -> bool {
    let key = key.to_ascii_lowercase();
    key.contains("api_token")
        || key.contains("token")
        || key.contains("secret")
        || key.contains("password")
}

pub fn redact_secret(value: &str) -> String {
    if value.is_empty() {
        return "(empty)".to_string();
    }
    if value.chars().count() <= 8 {
        return "********".to_string();
    }

    let prefix: String = value.chars().take(4).collect();
    let suffix: String = value
        .chars()
        .rev()
        .take(4)
        .collect::<Vec<_>>()
        .into_iter()
        .rev()
        .collect();
    format!("{prefix}...{suffix}")
}

pub fn status_label(status: Status) -> StyledObject<&'static str> {
    match status {
        Status::Ok => style("[ok]").green(),
        Status::Warn => style("[warn]").yellow(),
        Status::Fail => style("[fail]").red(),
        Status::Plan => style("[plan]").yellow(),
        Status::Skip => style("[skip]").dim(),
    }
}

pub fn status_cell(status: Status) -> StyledObject<String> {
    let label = match status {
        Status::Ok => "[ok]",
        Status::Warn => "[warn]",
        Status::Fail => "[fail]",
        Status::Plan => "[plan]",
        Status::Skip => "[skip]",
    };
    let padded = format!("{label:<8}");
    match status {
        Status::Ok => style(padded).green(),
        Status::Warn => style(padded).yellow(),
        Status::Fail => style(padded).red(),
        Status::Plan => style(padded).yellow(),
        Status::Skip => style(padded).dim(),
    }
}

pub fn status_line(status: Status, subject: impl fmt::Display, detail: impl fmt::Display) {
    println!(
        "{} {:<28} {}",
        status_cell(status),
        subject.to_string(),
        detail
    );
}

pub fn summary_line(status: Status, detail: impl fmt::Display) {
    println!("{} {}", status_cell(status), detail);
}

pub fn print_disk_impact(impact: &DiskImpact) {
    println!("{}", section("Disk impact:"));
    if !matches!(impact.download.bytes, Some(0)) {
        println!(
            "  {} {}",
            meta("Download:"),
            format_unsigned(impact.download)
        );
    }
    println!(
        "  {} {}",
        meta("Net disk change:"),
        format_signed(impact.net)
    );
}

pub fn print_local_disk_impact(impact: &DiskImpact) {
    println!("{}", section("Disk impact:"));
    println!("  {} {}", meta("Disk change:"), format_signed(impact.net));
}

fn format_unsigned(value: ByteEstimate) -> String {
    match value.bytes {
        Some(bytes) => format!(
            "{}{}",
            HumanBytes(bytes),
            confidence_suffix(value.confidence)
        ),
        None => "unknown".to_string(),
    }
}

fn format_signed(value: SignedByteEstimate) -> String {
    match value.bytes {
        Some(0) => format!("no change{}", confidence_suffix(value.confidence)),
        Some(bytes) if bytes > 0 => format!(
            "{} of additional disk space will be used{}",
            HumanBytes(bytes as u64),
            confidence_suffix(value.confidence)
        ),
        Some(bytes) => format!(
            "{} of disk space will be freed{}",
            HumanBytes(bytes.unsigned_abs() as u64),
            confidence_suffix(value.confidence)
        ),
        None => "unknown".to_string(),
    }
}

fn confidence_suffix(confidence: SizeConfidence) -> &'static str {
    match confidence {
        SizeConfidence::Exact => "",
        SizeConfidence::Estimated => " (estimated)",
        SizeConfidence::Unknown => "",
    }
}

#[cfg(test)]
mod tests {
    use super::{
        Status, assume_yes, is_sensitive_key, redact_secret, set_assume_yes, status_cell,
        status_label, truncate_end, truncate_middle,
    };

    #[test]
    fn status_labels_are_stable_without_color() {
        assert_eq!(status_label(Status::Ok).to_string(), "[ok]");
        assert_eq!(status_label(Status::Warn).to_string(), "[warn]");
        assert_eq!(status_label(Status::Fail).to_string(), "[fail]");
        assert_eq!(status_label(Status::Plan).to_string(), "[plan]");
        assert_eq!(status_label(Status::Skip).to_string(), "[skip]");
    }

    #[test]
    fn status_cells_are_padded_before_styling() {
        assert_eq!(status_cell(Status::Ok).to_string(), "[ok]    ");
        assert_eq!(status_cell(Status::Plan).to_string(), "[plan]  ");
    }

    #[test]
    fn truncation_helpers_are_stable() {
        assert_eq!(truncate_end("abcdefghijklmnopqrstuvwxyz", 10), "abcdefg...");
        assert_eq!(
            truncate_middle("abcdefghijklmnopqrstuvwxyz", 10),
            "abc...wxyz"
        );
        assert_eq!(truncate_end("abc", 10), "abc");
        assert_eq!(truncate_middle("abc", 10), "abc");
    }

    #[test]
    fn sensitive_values_are_detected_and_redacted() {
        assert!(is_sensitive_key("github.api_token"));
        assert!(is_sensitive_key("auth.password"));
        assert!(!is_sensitive_key("github.enabled"));
        assert_eq!(
            redact_secret("ghp_abcdefghijklmnopqrstuvwxyz"),
            "ghp_...wxyz"
        );
        assert_eq!(redact_secret("short"), "********");
    }

    #[test]
    fn assume_yes_flag_is_shared() {
        set_assume_yes(false);
        assert!(!assume_yes());
        set_assume_yes(true);
        assert!(assume_yes());
        set_assume_yes(false);
    }
}