use std::fmt::Display;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use anyhow::{bail, Context, Result};
use console::{style, Emoji, Term};
use indicatif::{ProgressBar, ProgressStyle};
const MARGIN: &str = " ";
static TICK: Emoji<'_, '_> = Emoji("✔", "+");
static CROSS: Emoji<'_, '_> = Emoji("✖", "x");
static BANG: Emoji<'_, '_> = Emoji("▲", "!");
static DOT: Emoji<'_, '_> = Emoji("·", "-");
static ARROW: Emoji<'_, '_> = Emoji("→", "->");
static VERBOSE: AtomicBool = AtomicBool::new(false);
static PLAIN: AtomicBool = AtomicBool::new(false);
static FRESH: AtomicBool = AtomicBool::new(false);
pub fn init(no_color: bool, verbose: bool, plain: bool) {
set_plain(plain);
set_colors(!no_color && !plain);
VERBOSE.store(verbose, Ordering::Relaxed);
}
pub fn set_plain(plain: bool) {
PLAIN.store(plain, Ordering::Relaxed);
}
pub fn plain() -> bool {
PLAIN.load(Ordering::Relaxed)
}
fn indent() -> &'static str {
if plain() {
""
} else {
" "
}
}
fn glyphed(painted: String, message: impl Display) -> String {
if plain() {
format!("{message}")
} else {
format!("{MARGIN}{painted} {message}")
}
}
pub fn verbose() -> bool {
VERBOSE.load(Ordering::Relaxed)
}
fn attended() -> bool {
console::user_attended() && !verbose()
}
const LOGO: [&str; 7] = [
" ██ ██",
" ██ ██",
"██████ ████ ██ ████ ██████ ██████ ██ ████ ██",
"██ ██ ██ ██ ████ ██ ████ ██ ████ ██",
"██ ██ ██ ██ ██ ██ ████████ ██████ ██",
"██████ ████ ██ ████ ██████ ██ ████ ██",
"██",
];
const DOT_ROW: usize = 5;
const WORDMARK_DOT: &str = "██";
pub fn banner() -> String {
let mut out = String::new();
for (row, line) in LOGO.iter().enumerate() {
let word = style(line).bold();
if row == DOT_ROW {
out.push_str(&format!(
"{MARGIN}{word} {}\n",
style(WORDMARK_DOT).green().bold()
));
} else {
out.push_str(&format!("{MARGIN}{word}\n"));
}
}
out.push_str(&format!(
"{MARGIN}{}\n",
style(format!(
"the module toolchain · v{}",
env!("CARGO_PKG_VERSION")
))
.dim()
));
out
}
pub fn legal() -> String {
format!(
"{MARGIN}{}\n{MARGIN}{}",
style(format!(
"{} · Copyright 2026 Syntax Labs",
env!("CARGO_PKG_LICENSE")
))
.dim(),
style(format!(
"{} · {}",
env!("CARGO_PKG_HOMEPAGE"),
env!("CARGO_PKG_REPOSITORY")
))
.dim()
)
}
pub fn long_version() -> String {
let rows = [
("license", env!("CARGO_PKG_LICENSE")),
("copyright", "Copyright 2026 Syntax Labs"),
("homepage", env!("CARGO_PKG_HOMEPAGE")),
("source", env!("CARGO_PKG_REPOSITORY")),
];
let mut out = format!("{}\n\n{}\n", env!("CARGO_PKG_VERSION"), banner());
for (label, value) in rows {
out.push_str(&format!(
"{MARGIN} {} {value}\n",
style(format!("{label:<11}")).dim()
));
}
out.push_str(&format!(
"\n{MARGIN}{}\n{MARGIN}{}",
style("This product includes software developed at Syntax Labs.").dim(),
style("Distributed on an \"AS IS\" basis, without warranties or conditions of any kind.")
.dim()
));
out
}
pub fn set_colors(enabled: bool) {
if !enabled {
console::set_colors_enabled(false);
console::set_colors_enabled_stderr(false);
}
}
pub fn blank() {
if !plain() {
println!();
}
}
fn line(text: String) {
FRESH.store(false, Ordering::Relaxed);
println!("{text}");
}
fn eline(text: String) {
FRESH.store(false, Ordering::Relaxed);
eprintln!("{text}");
}
pub fn header(command: &str, purpose: &str) {
if plain() {
return;
}
blank();
println!(
"{MARGIN}{} {}",
style(command).bold(),
style(format!("v{}", env!("CARGO_PKG_VERSION"))).dim()
);
println!("{MARGIN}{}", style(purpose).dim());
blank();
FRESH.store(true, Ordering::Relaxed);
}
const COLUMN_CAP: usize = 32;
const COLUMN_FLOOR: usize = 16;
pub fn section(title: &str) {
if plain() {
line(title.to_string());
return;
}
if !FRESH.swap(false, Ordering::Relaxed) {
blank();
}
line(format!("{MARGIN}{}", style(title).dim()));
}
pub fn list(title: &str, rows: &[(&str, &str)]) {
section(title);
match column_width(rows) {
Some(width) => {
for (name, purpose) in rows {
let padding = " ".repeat(width - name.chars().count());
line(format!(
"{}{}{padding} {}",
indent(),
style(name).cyan(),
style(purpose).dim()
));
}
}
None => {
for (name, purpose) in rows {
line(format!("{}{}", indent(), style(name).cyan()));
line(format!("{} {}", indent(), style(purpose).dim()));
}
}
}
}
fn column_width(rows: &[(&str, &str)]) -> Option<usize> {
let widest = rows.iter().map(|(name, _)| name.chars().count()).max()?;
(widest <= COLUMN_CAP).then(|| widest.max(COLUMN_FLOOR))
}
pub fn success(message: impl Display) {
line(glyphed(style(TICK).green().bold().to_string(), message));
}
pub fn skipped(message: impl Display) {
line(glyphed(style(DOT).dim().to_string(), style(message).dim()));
}
pub fn failure(message: impl Display) {
if plain() {
eline(format!("error: {message}"));
return;
}
eline(format!("{MARGIN}{} {message}", style(CROSS).red().bold()));
}
pub fn warn(message: impl Display) {
line(glyphed(style(BANG).yellow().bold().to_string(), message));
}
pub fn result(message: impl Display) {
line(glyphed(style(ARROW).cyan().to_string(), message));
}
pub fn advice(message: impl Display) {
if plain() {
return;
}
detail(message);
}
pub fn detail(message: impl Display) {
line(format!("{}{}", indent(), style(message).dim()));
}
pub fn wrote(kind: &str, where_: impl Display) {
if plain() {
line(format!("{kind:<10} {where_}"));
return;
}
line(format!(
"{MARGIN}{} {} {}",
style(TICK).green().bold(),
style(format!("{kind:<10}")),
style(where_).dim()
));
}
pub fn field(label: &str, value: impl Display) {
line(format!(
"{}{} {value}",
indent(),
style(format!("{label:<11}")).dim()
));
}
pub fn next(steps: &[(&str, &str)]) {
if plain() {
return;
}
list("next", steps);
}
pub fn rule(label: &str) {
let width = Term::stdout().size().1.clamp(20, 100) as usize;
let filler = width.saturating_sub(label.chars().count() + MARGIN.len() + 4);
let dash = if Term::stdout().is_term() { '─' } else { '-' };
let rule = dash.to_string().repeat(filler);
line(format!(
"{MARGIN}{} {}",
style(format!("{dash}{dash} {label}")).dim(),
style(rule).dim()
));
}
pub fn bytes(count: u64) -> String {
const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
let mut size = count as f64;
let mut unit = 0;
while size >= 1024.0 && unit < UNITS.len() - 1 {
size /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{count} B")
} else {
format!("{size:.1} {}", UNITS[unit])
}
}
pub fn code_block(code: &str) {
let width = code.chars().count() + 4;
let (tl, tr, bl, br, h, v) = if Term::stdout().is_term() {
('┌', '┐', '└', '┘', '─', '│')
} else {
('+', '+', '+', '+', '-', '|')
};
let rule = h.to_string().repeat(width);
line(format!(
"{MARGIN}{}",
style(format!("{tl}{rule}{tr}")).dim()
));
line(format!(
"{MARGIN}{} {} {}",
style(v).dim(),
style(code).bold().cyan(),
style(v).dim()
));
line(format!(
"{MARGIN}{}",
style(format!("{bl}{rule}{br}")).dim()
));
}
pub fn report(failure: &anyhow::Error) {
if plain() {
eline(format!("error: {failure}"));
for cause in failure.chain().skip(1) {
eline(format!("caused by: {cause}"));
}
return;
}
eline(format!("{MARGIN}{} {failure}", style(CROSS).red().bold()));
for cause in failure.chain().skip(1) {
eline(format!(
"{MARGIN} {} {}",
style("caused by").dim(),
style(cause).dim()
));
}
blank();
}
pub struct Step {
bar: ProgressBar,
started: Instant,
}
pub fn step(label: impl Into<String>) -> Step {
let label = label.into();
let bar = if attended() {
let bar = ProgressBar::new_spinner();
bar.set_style(
ProgressStyle::with_template("{prefix}{spinner:.cyan} {msg}")
.expect("gabarit d'indicateur")
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "),
);
bar.set_prefix(MARGIN);
bar.enable_steady_tick(Duration::from_millis(80));
bar
} else {
ProgressBar::hidden()
};
bar.set_message(label);
Step {
bar,
started: Instant::now(),
}
}
impl Step {
pub fn say(&self, message: impl Into<String>) {
self.bar.set_message(message.into());
}
pub fn done(&self, message: impl Display) {
let elapsed = self.close();
line(format!(
"{MARGIN}{} {message} {}",
style(TICK).green().bold(),
style(elapsed).dim()
));
}
pub fn skip(&self, message: impl Display) {
self.close();
skipped(message);
}
pub fn abandon(&self) {
self.close();
}
fn close(&self) -> String {
self.bar.finish_and_clear();
elapsed(self.started.elapsed())
}
}
pub fn command(label: &str, cmd: &mut Command) -> Result<()> {
let step = step(label.to_owned());
if verbose() {
let status = cmd.status().with_context(|| format!("run {label}"))?;
if !status.success() {
step.abandon();
bail!("{label} failed");
}
step.done(label);
return Ok(());
}
let output = cmd.output().with_context(|| format!("run {label}"))?;
if !output.status.success() {
step.abandon();
emit_captured(&output.stderr);
emit_captured(&output.stdout);
bail!("{label} failed");
}
step.done(label);
Ok(())
}
pub(crate) fn emit_captured(bytes: &[u8]) {
let text = String::from_utf8_lossy(bytes);
let text = text.trim_end();
if text.is_empty() {
return;
}
for raw in text.lines() {
eline(format!("{}{raw}", indent()));
}
blank();
}
pub fn elapsed(duration: Duration) -> String {
let seconds = duration.as_secs_f64();
if seconds < 1.0 {
format!("{}ms", duration.as_millis())
} else if seconds < 60.0 {
format!("{seconds:.1}s")
} else {
format!(
"{}m {:02}s",
duration.as_secs() / 60,
duration.as_secs() % 60
)
}
}
pub fn countdown(remaining: Duration) -> String {
let seconds = remaining.as_secs();
format!("{}:{:02}", seconds / 60, seconds % 60)
}
pub fn open_browser(url: &str) -> bool {
open::that_detached(url).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_output_drops_what_only_an_eye_reads() {
set_plain(true);
assert_eq!(indent(), "");
assert_eq!(glyphed("*".to_string(), "done"), "done");
set_plain(false);
assert_eq!(indent(), " ");
assert_eq!(glyphed("*".to_string(), "done"), format!("{MARGIN}* done"));
}
#[test]
fn a_short_duration_reads_in_milliseconds() {
assert_eq!(elapsed(Duration::from_millis(420)), "420ms");
}
#[test]
fn a_build_reads_in_seconds() {
assert_eq!(elapsed(Duration::from_millis(12_400)), "12.4s");
}
#[test]
fn a_long_build_reads_in_minutes() {
assert_eq!(elapsed(Duration::from_secs(124)), "2m 04s");
}
#[test]
fn the_version_screen_carries_what_protects_the_project() {
let screen = long_version();
assert!(screen.contains("Apache-2.0"));
assert!(screen.contains("Syntax Labs"));
assert!(screen.contains("AS IS"));
assert!(screen.contains(env!("CARGO_PKG_REPOSITORY")));
}
#[test]
fn the_help_footer_names_the_licence_and_the_source() {
let footer = legal();
assert!(footer.contains("Apache-2.0"));
assert!(footer.contains("Copyright 2026 Syntax Labs"));
assert!(footer.contains(env!("CARGO_PKG_HOMEPAGE")));
}
#[test]
fn the_wordmark_carries_its_dot() {
assert!(DOT_ROW < LOGO.len());
assert!(banner().contains("the module toolchain"));
assert_eq!(LOGO.len(), 7);
}
#[test]
fn a_column_settles_on_the_widest_name() {
assert_eq!(column_width(&[("a", "x"), ("bb", "y")]), Some(COLUMN_FLOOR));
assert_eq!(
column_width(&[("portaki dev --watch", "x")]),
Some("portaki dev --watch".len())
);
}
#[test]
fn an_overlong_name_gives_up_the_column() {
assert_eq!(
column_width(&[("cargo doc --workspace --no-deps --open", "x")]),
None
);
}
#[test]
fn an_empty_list_has_no_column() {
assert_eq!(column_width(&[]), None);
}
#[test]
fn bytes_stay_readable_at_every_scale() {
assert_eq!(bytes(512), "512 B");
assert_eq!(bytes(2048), "2.0 KB");
assert_eq!(bytes(5 * 1024 * 1024), "5.0 MB");
}
#[test]
fn a_countdown_always_pads_the_seconds() {
assert_eq!(countdown(Duration::from_secs(598)), "9:58");
assert_eq!(countdown(Duration::from_secs(61)), "1:01");
assert_eq!(countdown(Duration::from_secs(9)), "0:09");
}
}