knott 0.1.18

Fast Rust package manager helper for Arch Linux repos and the AUR
Documentation
use aisling::{Loader, LoaderConfig, LoaderKind, LoaderProgress};
use std::env;
use std::io::{self, IsTerminal, Write};

const LOADER_WIDTH: usize = 56;
const RESET: &str = "\x1b[0m";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Stream {
    Stdout,
    Stderr,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ColorMode {
    Auto,
    Always,
    Never,
}

#[derive(Debug, Clone, Copy)]
enum Style {
    Status,
    Warning,
    Error,
    Field,
    Prompt,
}

impl ColorMode {
    fn as_str(self) -> &'static str {
        match self {
            Self::Auto => "auto",
            Self::Always => "always",
            Self::Never => "never",
        }
    }
}

impl Style {
    fn code(self) -> &'static str {
        match self {
            Self::Status => "\x1b[1;34m",
            Self::Warning => "\x1b[1;33m",
            Self::Error => "\x1b[1;31m",
            Self::Field => "\x1b[1;36m",
            Self::Prompt => "\x1b[1m",
        }
    }
}

pub fn text(input: &str) -> String {
    let mut output = String::with_capacity(input.len());
    let mut chars = input.chars().peekable();

    while let Some(ch) = chars.next() {
        if ch == '\x1b' {
            skip_escape(&mut chars);
            continue;
        }

        match ch {
            '\n' | '\t' => output.push(ch),
            '\r' => {}
            ch if ch.is_control() => {}
            ch => output.push(ch),
        }
    }

    output
}

pub fn inline(input: &str) -> String {
    let sanitized = text(input);
    let mut output = String::with_capacity(sanitized.len());
    let mut last_was_space = false;

    for ch in sanitized.chars() {
        if ch.is_whitespace() {
            if !last_was_space {
                output.push(' ');
                last_was_space = true;
            }
        } else {
            output.push(ch);
            last_was_space = false;
        }
    }

    output.trim().to_string()
}

pub fn block(input: &str) {
    print!("{}", text(input));
}

pub fn line(input: impl AsRef<str>) {
    println!("{}", text(input.as_ref()));
}

pub fn inline_line(input: impl AsRef<str>) {
    println!("{}", inline(input.as_ref()));
}

pub fn status(label: &str, value: impl AsRef<str>) {
    let marker = paint(Stream::Stdout, Style::Status, "::");
    let label = paint(Stream::Stdout, Style::Status, &inline(label));
    println!("{marker} {label}: {}", inline(value.as_ref()));
}

pub fn warning(message: impl AsRef<str>) {
    let prefix = paint(Stream::Stderr, Style::Warning, "warning:");
    eprintln!("{prefix} {}", inline(message.as_ref()));
}

pub fn error(message: impl AsRef<str>) {
    let prefix = paint(Stream::Stderr, Style::Error, "error:");
    eprintln!("{prefix} {}", inline(message.as_ref()));
}

pub fn field(label: &str, value: impl AsRef<str>) {
    let label = format!("{:<16}", inline(label));
    let label = paint(Stream::Stdout, Style::Field, &label);
    println!("{label}: {}", inline(value.as_ref()));
}

pub fn join_inline<I, S>(values: I, separator: &str) -> String
where
    I: IntoIterator<Item = S>,
    S: AsRef<str>,
{
    let mut output = String::new();
    for value in values {
        if !output.is_empty() {
            output.push_str(separator);
        }
        output.push_str(&inline(value.as_ref()));
    }
    output
}

pub fn prompt(prompt: &str) -> io::Result<()> {
    print!("{}", paint(Stream::Stdout, Style::Prompt, &inline(prompt)));
    io::stdout().flush()
}

pub(crate) fn color_diagnostics() -> String {
    let mode = configured_color_mode();
    format!(
        "mode={} stdout={} stderr={}",
        mode.as_str(),
        color_allowed(Stream::Stdout),
        color_allowed(Stream::Stderr)
    )
}

fn skip_escape(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
    match chars.next() {
        Some('[') => skip_csi(chars),
        Some(']') | Some('P') | Some('^') | Some('_') | Some('X') => skip_string_escape(chars),
        Some(_) | None => {}
    }
}

fn skip_csi(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
    for ch in chars.by_ref() {
        if ('@'..='~').contains(&ch) {
            break;
        }
    }
}

fn skip_string_escape(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
    while let Some(ch) = chars.next() {
        if ch == '\x07' {
            break;
        }

        if ch == '\x1b' && chars.next() == Some('\\') {
            break;
        }
    }
}

pub struct Progress {
    loader: Option<Loader>,
    current: u64,
    total: u64,
    tick: usize,
}

impl Progress {
    pub fn new(label: &str, total: usize, requested: bool) -> Self {
        let enabled = requested && total > 1 && progress_allowed() && stderr_is_terminal();
        let loader = enabled.then(|| {
            Loader::with_config(
                LoaderKind::Tqdm,
                LoaderConfig::default()
                    .with_size(LOADER_WIDTH, 1)
                    .with_label(inline(label))
                    .with_unit("pkg")
                    .with_fraction(true)
                    .without_color(),
            )
        });

        Self {
            loader,
            current: 0,
            total: total as u64,
            tick: 0,
        }
    }

    pub fn advance(&mut self, amount: usize) {
        if self.loader.is_none() {
            return;
        }

        self.current = self.current.saturating_add(amount as u64).min(self.total);
        self.render();
    }

    pub fn finish(&mut self) {
        if self.loader.is_none() {
            return;
        }

        self.current = self.total;
        self.render();
        let _ = writeln!(io::stderr());
        self.loader = None;
    }

    fn render(&mut self) {
        let Some(loader) = &self.loader else {
            return;
        };
        let progress = LoaderProgress::from_counts(self.current, self.total.max(1));
        let line = loader.line(self.tick, progress);
        let _ = write!(io::stderr(), "\r{line}\x1b[K");
        let _ = io::stderr().flush();
        self.tick = self.tick.wrapping_add(1);
    }
}

impl Drop for Progress {
    fn drop(&mut self) {
        if self.loader.is_some() {
            let _ = writeln!(io::stderr());
        }
    }
}

fn progress_allowed() -> bool {
    env::var("KNOTT_PROGRESS").map_or(true, |value| {
        let value = value.trim();
        !(value == "0" || value.eq_ignore_ascii_case("false") || value.eq_ignore_ascii_case("off"))
    })
}

fn stderr_is_terminal() -> bool {
    io::stderr().is_terminal()
}

fn stdout_is_terminal() -> bool {
    io::stdout().is_terminal()
}

fn paint(stream: Stream, style: Style, input: &str) -> String {
    paint_if(color_allowed(stream), style, input)
}

fn paint_if(enabled: bool, style: Style, input: &str) -> String {
    if !enabled || input.is_empty() {
        return input.to_string();
    }

    format!("{}{}{}", style.code(), input, RESET)
}

fn color_allowed(stream: Stream) -> bool {
    let mode = configured_color_mode();
    let terminal = match stream {
        Stream::Stdout => stdout_is_terminal(),
        Stream::Stderr => stderr_is_terminal(),
    };
    let no_color = env::var_os("NO_COLOR").is_some();
    let dumb_term = env::var("TERM").is_ok_and(|value| value == "dumb");

    color_enabled(mode, no_color, dumb_term, terminal)
}

fn configured_color_mode() -> ColorMode {
    let value = env::var("KNOTT_COLOR").ok();
    parse_color_mode(value.as_deref())
}

fn parse_color_mode(value: Option<&str>) -> ColorMode {
    match value.map(str::trim) {
        Some(value) if is_always_color_value(value) => ColorMode::Always,
        Some(value) if is_never_color_value(value) => ColorMode::Never,
        _ => ColorMode::Auto,
    }
}

fn is_always_color_value(value: &str) -> bool {
    value == "1"
        || ["always", "true", "yes", "on"]
            .iter()
            .any(|candidate| value.eq_ignore_ascii_case(candidate))
}

fn is_never_color_value(value: &str) -> bool {
    value == "0"
        || ["never", "false", "no", "off"]
            .iter()
            .any(|candidate| value.eq_ignore_ascii_case(candidate))
}

fn color_enabled(mode: ColorMode, no_color: bool, dumb_term: bool, terminal: bool) -> bool {
    match mode {
        ColorMode::Always => true,
        ColorMode::Never => false,
        ColorMode::Auto => terminal && !no_color && !dumb_term,
    }
}

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

    #[test]
    fn strips_terminal_control_sequences() {
        let input = "pkg\x1b[31m-red\x1b[0m\x07\rname\nnext\x1b]0;title\x07line";
        assert_eq!(text(input), "pkg-redname\nnextline");
    }

    #[test]
    fn inline_collapses_multiline_control_text() {
        let input = "  hello\n\t\x1b[2Jworld\r\x08  ";
        assert_eq!(inline(input), "hello world");
    }

    #[test]
    fn joins_inline_values_with_sanitization() {
        let values = ["foo", "\x1b[31mbar\x1b[0m", "baz\nqux"];
        assert_eq!(join_inline(values, " "), "foo bar baz qux");
    }

    #[test]
    fn parses_color_modes() {
        assert_eq!(parse_color_mode(None), ColorMode::Auto);
        assert_eq!(parse_color_mode(Some("always")), ColorMode::Always);
        assert_eq!(parse_color_mode(Some("1")), ColorMode::Always);
        assert_eq!(parse_color_mode(Some("never")), ColorMode::Never);
        assert_eq!(parse_color_mode(Some("0")), ColorMode::Never);
        assert_eq!(parse_color_mode(Some("auto")), ColorMode::Auto);
        assert_eq!(parse_color_mode(Some("unexpected")), ColorMode::Auto);
    }

    #[test]
    fn auto_color_requires_terminal_without_no_color_or_dumb_term() {
        assert!(color_enabled(ColorMode::Auto, false, false, true));
        assert!(!color_enabled(ColorMode::Auto, true, false, true));
        assert!(!color_enabled(ColorMode::Auto, false, true, true));
        assert!(!color_enabled(ColorMode::Auto, false, false, false));
        assert!(color_enabled(ColorMode::Always, true, true, false));
        assert!(!color_enabled(ColorMode::Never, false, false, true));
    }

    #[test]
    fn paint_adds_only_knott_owned_escape_sequences() {
        let sanitized = inline("\x1b[31merror\x1b[0m\nmessage");
        assert_eq!(sanitized, "error message");
        assert_eq!(
            paint_if(true, Style::Error, &sanitized),
            "\x1b[1;31merror message\x1b[0m"
        );
        assert_eq!(paint_if(false, Style::Error, &sanitized), "error message");
    }
}