knott 0.1.16

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, Write};

const LOADER_WIDTH: usize = 56;

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>) {
    println!(":: {}: {}", inline(label), inline(value.as_ref()));
}

pub fn warning(message: impl AsRef<str>) {
    eprintln!("warning: {}", inline(message.as_ref()));
}

pub fn error(message: impl AsRef<str>) {
    eprintln!("error: {}", inline(message.as_ref()));
}

pub fn field(label: &str, value: impl AsRef<str>) {
    println!("{:<16}: {}", inline(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!("{}", inline(prompt));
    io::stdout().flush()
}

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"))
    })
}

#[cfg(unix)]
fn stderr_is_terminal() -> bool {
    unsafe { libc::isatty(libc::STDERR_FILENO) == 1 }
}

#[cfg(not(unix))]
fn stderr_is_terminal() -> bool {
    false
}

#[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");
    }
}