fabro/lib/apps/fabro-cli/src/shared/utilities.rs
2026-07-28 14:14:32 -04:00

262 lines
7.5 KiB
Rust

#![expect(
clippy::disallowed_types,
reason = "sync CLI utilities: blocking std::io::Write is the intended output mechanism"
)]
#![expect(
clippy::disallowed_methods,
reason = "sync CLI utilities: blocking std::io::stdout is the intended output mechanism"
)]
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::Context as _;
use cli_table::Color;
use fabro_types::RunStatus;
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
use fabro_validate::{Diagnostic, Severity};
use indicatif::{ProgressBar, ProgressStyle};
use serde::Serialize;
pub(crate) fn cyan_spinner(message: impl Into<std::borrow::Cow<'static, str>>) -> ProgressBar {
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::with_template("{spinner:.cyan} {msg}")
.expect("hardcoded progress template is always syntactically valid")
.tick_strings(&["", "", "", "", "", "", "", "", "", "", ""]),
);
spinner.set_message(message);
spinner.enable_steady_tick(Duration::from_millis(80));
spinner
}
pub(crate) fn read_workflow_file(path: &Path) -> anyhow::Result<String> {
std::fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))
}
pub(crate) fn print_json_pretty<T>(value: &T) -> anyhow::Result<()>
where
T: Serialize + ?Sized,
{
let stdout = std::io::stdout();
let mut handle = stdout.lock();
serde_json::to_writer_pretty(&mut handle, value)?;
writeln!(handle)?;
Ok(())
}
pub(crate) fn print_diagnostics(diagnostics: &[Diagnostic], styles: &Styles, printer: Printer) {
for d in diagnostics {
print_diagnostic(d, styles, printer);
// The fix is the actionable half of a diagnostic, so it follows every
// severity rather than hiding behind --verbose. Rules that have nothing
// useful to suggest leave it unset.
if let Some(fix) = &d.fix {
fabro_util::printerr!(printer, " {} {fix}", styles.dim.apply_to("fix:"));
}
}
}
fn print_diagnostic(d: &Diagnostic, styles: &Styles, printer: Printer) {
let location = match (&d.node_id, &d.edge) {
(Some(node), _) => format!(" [node: {node}]"),
(_, Some((from, to))) => format!(" [edge: {from} -> {to}]"),
_ => String::new(),
};
let source_prefix = source_prefix(d);
let body = if source_prefix.is_empty() {
format!("{location}: {}", d.message)
} else {
format!(": {source_prefix}{}{location}", d.message)
};
match d.severity {
Severity::Error => fabro_util::printerr!(
printer,
"{}{body} ({})",
styles.red.apply_to("error"),
styles.dim.apply_to(&d.rule),
),
Severity::Warning => fabro_util::printerr!(
printer,
"{}{body} ({})",
styles.yellow.apply_to("warning"),
styles.dim.apply_to(&d.rule),
),
Severity::Info => fabro_util::printerr!(
printer,
"{}",
styles.dim.apply_to(format!("info{body} ({})", d.rule)),
),
}
}
fn source_prefix(diagnostic: &Diagnostic) -> String {
match (
diagnostic.source_path.as_deref(),
diagnostic.line,
diagnostic.column,
) {
(Some(path), Some(line), Some(column)) => {
format!("{}:{line}:{column}: ", display_diagnostic_path(path))
}
(Some(path), Some(line), None) => {
format!("{}:{line}: ", display_diagnostic_path(path))
}
(Some(path), None, _) => format!("{}: ", display_diagnostic_path(path)),
_ => String::new(),
}
}
fn display_diagnostic_path(path: &str) -> String {
let path = Path::new(path);
if let Ok(canonical) = path.canonicalize() {
return relative_path(&canonical);
}
if path.is_absolute() {
relative_path(path)
} else {
path.display().to_string()
}
}
pub(crate) fn relative_path(path: &Path) -> String {
if let Ok(cwd) = std::env::current_dir() {
if let Ok(rel) = path.strip_prefix(&cwd) {
return rel.display().to_string();
}
}
tilde_path(path)
}
pub(crate) fn format_tokens_human(tokens: i64) -> String {
if tokens >= 1_000_000 {
format!("{:.1}m", tokens as f64 / 1_000_000.0)
} else if tokens >= 1000 {
format!("{:.1}k", tokens as f64 / 1000.0)
} else {
tokens.to_string()
}
}
pub(crate) fn format_usd_micros(usd_micros: i64) -> String {
format!("${:.2}", usd_micros as f64 / 1_000_000.0)
}
pub(crate) fn tilde_path(path: &Path) -> String {
if let Some(home) = dirs::home_dir() {
if let Ok(suffix) = path.strip_prefix(&home) {
return format!("~/{}", suffix.display());
}
}
path.display().to_string()
}
pub(crate) fn absolute_or_current(path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else if let Ok(cwd) = std::env::current_dir() {
cwd.join(path)
} else {
path.to_path_buf()
}
}
pub(crate) fn color_if(use_color: bool, color: Color) -> Option<Color> {
if use_color { Some(color) } else { None }
}
pub(crate) fn run_status_kind(status: RunStatus) -> &'static str {
status.kind().into()
}
pub(crate) fn split_run_path(s: &str) -> Option<(&str, &str)> {
if s.starts_with('/') || s.starts_with("./") || s.starts_with("../") {
return None;
}
s.split_once(':')
}
pub(crate) fn format_duration_ms(ms: u64) -> String {
let duration = Duration::from_millis(ms);
let secs = duration.as_secs();
if secs >= 60 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else if duration.as_millis() >= 1000 {
format!("{secs}s")
} else {
format!("{}ms", duration.as_millis())
}
}
/// Format a UTC timestamp as a coarse "N{d,h,m} ago" string relative to `now`.
pub(crate) fn format_age(
dt: chrono::DateTime<chrono::Utc>,
now: chrono::DateTime<chrono::Utc>,
) -> String {
let dur = now.signed_duration_since(dt);
if dur.num_days() > 0 {
format!("{}d ago", dur.num_days())
} else if dur.num_hours() > 0 {
format!("{}h ago", dur.num_hours())
} else {
format!("{}m ago", dur.num_minutes().max(1))
}
}
pub(crate) fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{bytes} B")
}
}
#[cfg(test)]
mod tests {
use super::{format_tokens_human, format_usd_micros};
#[test]
fn format_tokens_human_zero() {
assert_eq!(format_tokens_human(0), "0");
}
#[test]
fn format_tokens_human_small() {
assert_eq!(format_tokens_human(999), "999");
}
#[test]
fn format_tokens_human_thousands() {
assert_eq!(format_tokens_human(1000), "1.0k");
}
#[test]
fn format_tokens_human_mid_thousands() {
assert_eq!(format_tokens_human(15234), "15.2k");
}
#[test]
fn format_tokens_human_millions() {
assert_eq!(format_tokens_human(1_000_000), "1.0m");
}
#[test]
fn format_tokens_human_mid_millions() {
assert_eq!(format_tokens_human(3_456_789), "3.5m");
}
#[test]
fn format_usd_micros_two_decimals() {
assert_eq!(format_usd_micros(570_000), "$0.57");
}
}