mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-14 23:22:51 +00:00
262 lines
7.5 KiB
Rust
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");
|
|
}
|
|
}
|