use std::{
io::IsTerminal,
path::{Path, PathBuf},
};
use crate::paths::{is_within, normalize_path};
const ANSI_RESET: &str = "\u{1b}[0m";
const ANSI_BOLD: &str = "\u{1b}[1m";
const ANSI_BLUE: &str = "\u{1b}[34m";
const ANSI_CYAN: &str = "\u{1b}[36m";
const ANSI_GREEN: &str = "\u{1b}[32m";
const ANSI_MAGENTA: &str = "\u{1b}[35m";
const ANSI_RED: &str = "\u{1b}[31m";
const ANSI_WHITE: &str = "\u{1b}[37m";
const ANSI_YELLOW: &str = "\u{1b}[33m";
const PROGRESS_BUCKETS: u64 = 20;
const PROGRESS_UNKNOWN_STEP: u64 = 4 * 1024 * 1024;
const SLAYER_SIGIL_LINES: &[&str] = &[
r" /\",
r" / \",
r" /\ / /\ \ /\",
r" / \/ / \ \/ \",
r" / /\ / /\ \ /\ \",
r" /_/ /_/ / \ \_\ \_\",
r" / / /\ \ \",
r" /_/ / \ \_\",
r" / /\ \",
r" /_/ \_\",
];
const XYLEX_WORDMARK_LINES: &[&str] = &[
r"__ __ __ __ _ _____ __ __",
r"\ \ / / \ \ / / | | | ____| \ \ / /",
r" \ V / \ V / | | | _| \ V / ",
r" / \ | | | |___ | |___ / \ ",
r"/_/ \_\ |_| |_____| |_____| /_/ \_\",
];
#[derive(Debug, Clone)]
pub struct Console {
repo_root: PathBuf,
ansi_enabled: bool,
}
impl Console {
pub fn new(repo_root: PathBuf) -> Self {
let ansi_enabled = std::io::stdout().is_terminal()
&& std::env::var_os("NO_COLOR").is_none()
&& std::env::var("TERM")
.map(|value| value != "dumb")
.unwrap_or(true);
Self {
repo_root,
ansi_enabled,
}
}
pub fn print_startup_banner(&self) {
let lines = self.render_startup_banner();
if lines.is_empty() {
return;
}
for line in lines {
println!("{line}");
}
println!();
}
pub fn shorten_path(&self, path: impl AsRef<Path>) -> String {
let candidate = path.as_ref();
let absolute = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
self.repo_root.join(candidate)
};
let resolved = normalize_path(&absolute).unwrap_or(absolute);
if is_within(&resolved, &self.repo_root) {
if let Ok(relative) = resolved.strip_prefix(&self.repo_root) {
return format!("./{}", path_to_forward_slashes(relative));
}
}
resolved.display().to_string()
}
pub fn format_path(&self, path: impl AsRef<Path>) -> String {
self.ansi(&self.shorten_path(path), &[ANSI_CYAN])
}
pub fn format_tool_name(&self, name: &str) -> String {
self.ansi(name, &[ANSI_BOLD, ANSI_MAGENTA])
}
pub fn format_label(&self, label: &str) -> String {
self.ansi(label, &[ANSI_BOLD, ANSI_MAGENTA])
}
pub fn log_info(&self, message: impl AsRef<str>) {
self.print_line(
self.ansi("[install]", &[ANSI_BOLD, ANSI_BLUE]),
message.as_ref(),
);
}
pub fn log_dry_run(&self, message: impl AsRef<str>) {
self.print_line(
self.ansi("[dry-run]", &[ANSI_BOLD, ANSI_YELLOW]),
message.as_ref(),
);
}
pub fn log_success(&self, message: impl AsRef<str>) {
self.print_line(
self.ansi("[done]", &[ANSI_BOLD, ANSI_GREEN]),
message.as_ref(),
);
}
pub fn log_error(&self, message: impl AsRef<str>) {
eprintln!(
"{} {}",
self.ansi("[error]", &[ANSI_BOLD, ANSI_RED]),
message.as_ref()
);
}
pub fn ansi(&self, text: &str, codes: &[&str]) -> String {
if !self.ansi_enabled || codes.is_empty() {
return text.to_string();
}
format!("{}{}{}", codes.join(""), text, ANSI_RESET)
}
fn print_line(&self, prefix: String, message: &str) {
println!("{prefix} {message}");
}
fn render_startup_banner(&self) -> Vec<String> {
if !self.ansi_enabled {
return Vec::new();
}
let mut lines = SLAYER_SIGIL_LINES
.iter()
.map(|line| self.ansi(line, &[ANSI_BOLD, ANSI_RED]))
.collect::<Vec<_>>();
lines.push(String::new());
lines.extend(
XYLEX_WORDMARK_LINES
.iter()
.map(|line| self.ansi(line, &[ANSI_BOLD, ANSI_WHITE])),
);
lines
}
}
#[derive(Debug, Clone, Copy)]
pub enum ProgressFormat {
Raw,
Bytes,
FileCount,
}
impl ProgressFormat {
fn render(self, value: u64) -> String {
match self {
Self::Raw => value.to_string(),
Self::Bytes => format_bytes(value),
Self::FileCount => format_file_count(value),
}
}
}
#[derive(Debug)]
pub struct ProgressReporter<'a> {
console: &'a Console,
label: String,
total: u64,
current: u64,
last_bucket: i64,
last_unknown_mark: u64,
format: ProgressFormat,
unknown_step: u64,
}
impl<'a> ProgressReporter<'a> {
pub fn new(
console: &'a Console,
label: impl Into<String>,
total: u64,
format: ProgressFormat,
) -> Self {
Self {
console,
label: label.into(),
total,
current: 0,
last_bucket: -1,
last_unknown_mark: 0,
format,
unknown_step: PROGRESS_UNKNOWN_STEP,
}
}
pub fn update(&mut self, current: u64, force: bool) {
self.current = current;
self.emit(force);
}
pub fn advance(&mut self, amount: u64) {
self.update(self.current.saturating_add(amount), false);
}
pub fn finish(&mut self) {
if self.total > 0 {
if self.current < self.total || self.last_bucket < PROGRESS_BUCKETS as i64 {
self.update(self.total, true);
}
return;
}
self.emit(true);
}
fn emit(&mut self, force: bool) {
if self.total > 0 {
let bucket = self
.current
.saturating_mul(PROGRESS_BUCKETS)
.checked_div(self.total)
.unwrap_or(0)
.min(PROGRESS_BUCKETS);
if !force && bucket as i64 <= self.last_bucket && self.current < self.total {
return;
}
self.last_bucket = bucket as i64;
let percent = if self.total == 0 {
0.0
} else {
(self.current as f64 / self.total as f64) * 100.0
};
let filled = bucket as usize;
let empty = PROGRESS_BUCKETS as usize - filled;
let bar = format!("{}{}", "#".repeat(filled), "-".repeat(empty));
let completed = self.current >= self.total;
let bar_color = if completed { ANSI_GREEN } else { ANSI_CYAN };
self.console.log_info(format!(
"{}: [{}] {} ({} / {})",
self.console.format_label(&self.label),
self.console.ansi(&bar, &[bar_color]),
self.console.ansi(&format!("{percent:5.1}%"), &[bar_color]),
self.format.render(self.current),
self.format.render(self.total),
));
return;
}
if !force && self.current.saturating_sub(self.last_unknown_mark) < self.unknown_step {
return;
}
self.last_unknown_mark = self.current;
self.console.log_info(format!(
"{}: {}",
self.console.format_label(&self.label),
self.format.render(self.current),
));
}
}
pub fn format_bytes(size: u64) -> String {
let mut value = size as f64;
let units = ["B", "KiB", "MiB", "GiB"];
for unit in units {
if value < 1024.0 || unit == *units.last().unwrap() {
if unit == "B" {
return format!("{} {}", value as u64, unit);
}
return format!("{value:.1} {unit}");
}
value /= 1024.0;
}
format!("{size} B")
}
pub fn format_file_count(count: u64) -> String {
if count == 1 {
"1 file".to_string()
} else {
format!("{count} files")
}
}
fn path_to_forward_slashes(path: &Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::{
Console, ANSI_BOLD, ANSI_RED, ANSI_WHITE, SLAYER_SIGIL_LINES, XYLEX_WORDMARK_LINES,
};
#[test]
fn startup_banner_renders_sigil_and_wordmark_when_ansi_is_enabled() {
let console = Console {
repo_root: PathBuf::from("C:/repo"),
ansi_enabled: true,
};
let lines = console.render_startup_banner();
assert_eq!(
lines.len(),
SLAYER_SIGIL_LINES.len() + 1 + XYLEX_WORDMARK_LINES.len()
);
assert!(lines[..SLAYER_SIGIL_LINES.len()]
.iter()
.all(|line| line.starts_with(ANSI_BOLD) && line.contains(ANSI_RED)));
assert!(lines[SLAYER_SIGIL_LINES.len()].is_empty());
assert!(lines[(SLAYER_SIGIL_LINES.len() + 1)..]
.iter()
.all(|line| line.starts_with(ANSI_BOLD) && line.contains(ANSI_WHITE)));
assert!(lines.iter().any(|line| line.contains("/\\")));
assert!(lines.iter().any(|line| line.contains("_____")));
}
#[test]
fn startup_banner_is_suppressed_without_ansi() {
let console = Console {
repo_root: PathBuf::from("C:/repo"),
ansi_enabled: false,
};
assert!(console.render_startup_banner().is_empty());
}
}