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