use owo_colors::OwoColorize;
use std::io::{IsTerminal, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
pub struct AppContext {
pub json: bool,
pub yes: bool,
pub no_input: bool,
}
static CTX: OnceLock<AppContext> = OnceLock::new();
static DEFAULT_CTX: AppContext = AppContext {
json: false,
yes: false,
no_input: false,
};
pub fn init_ctx(c: AppContext) {
let _ = CTX.set(c);
}
pub fn ctx() -> &'static AppContext {
CTX.get().unwrap_or(&DEFAULT_CTX)
}
pub fn make_ctx(json: bool, yes: bool, no_input: bool) -> AppContext {
let stdin_is_tty = std::io::stdin().is_terminal();
AppContext {
json,
yes,
no_input: no_input || !stdin_is_tty,
}
}
pub struct Spinner {
running: Arc<AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl Spinner {
fn new_animated() -> Self {
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
let handle = std::thread::spawn(move || {
let frames = ["Loading", "Loading. ", "Loading.. ", "Loading..."];
let mut i = 0;
while r.load(Ordering::Relaxed) {
eprint!("\r{}", frames[i % frames.len()]);
let _ = std::io::stderr().flush();
std::thread::sleep(std::time::Duration::from_millis(150));
i += 1;
}
eprint!("\r \r");
let _ = std::io::stderr().flush();
});
Spinner {
running,
handle: Some(handle),
}
}
fn noop() -> Self {
Spinner {
running: Arc::new(AtomicBool::new(false)),
handle: None,
}
}
pub fn finish_and_clear(&self) {
self.running.store(false, Ordering::Relaxed);
}
}
impl Drop for Spinner {
fn drop(&mut self) {
self.running.store(false, Ordering::Relaxed);
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
pub fn new_spinner() -> Spinner {
if ctx().json || ctx().no_input || !std::io::stderr().is_terminal() {
return Spinner::noop();
}
Spinner::new_animated()
}
pub fn sparkline(values: &[f64]) -> String {
const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
if values.is_empty() {
return String::new();
}
let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
let range = (max - min).max(1e-10);
values
.iter()
.map(|v| {
let idx = (((v - min) / range) * 7.0).round() as usize;
BLOCKS[idx.min(7)]
})
.collect()
}
pub fn colored_job_status(status: &str) -> String {
match status {
"succeeded" => status.green().to_string(),
"failed" | "timed_out" => status.red().to_string(),
"in_progress" => status.yellow().to_string(),
"canceled" => status.dimmed().to_string(),
"open" => status.cyan().to_string(),
_ => status.to_string(),
}
}
pub fn format_datetime(ts: &str) -> String {
if ts.len() >= 16 && ts.as_bytes().get(10) == Some(&b'T') {
let year = &ts[..4];
let month = &ts[5..7];
let day = &ts[8..10];
let time = &ts[11..16];
return format!("{}/{}/{} {}", day, month, year, time);
}
ts.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_utc_timestamp() {
assert_eq!(format_datetime("2024-02-25T10:30:00Z"), "25/02/2024 10:30");
}
#[test]
fn formats_timestamp_with_positive_offset() {
assert_eq!(
format_datetime("2024-02-25T12:30:00+02:00"),
"25/02/2024 12:30"
);
}
#[test]
fn formats_timestamp_with_negative_offset() {
assert_eq!(
format_datetime("2024-02-25T08:00:00-05:00"),
"25/02/2024 08:00"
);
}
#[test]
fn invalid_string_returns_original() {
assert_eq!(format_datetime("not-a-timestamp"), "not-a-timestamp");
}
#[test]
fn empty_string_returns_empty() {
assert_eq!(format_datetime(""), "");
}
#[test]
fn date_only_returns_original() {
assert_eq!(format_datetime("2024-02-25"), "2024-02-25");
}
#[test]
fn milliseconds_are_dropped_in_output() {
assert_eq!(
format_datetime("2024-02-25T10:30:45.123Z"),
"25/02/2024 10:30"
);
}
}
use crate::error::{extract_cli_error, SCHEMA_VERSION};
use serde::Serialize;
pub fn print_success(msg: &str) {
if ctx().json {
let env = serde_json::json!({
"schema_version": SCHEMA_VERSION,
"ok": true,
"message": msg,
});
println!("{}", env);
} else {
println!("{} {}", "✓".green().bold(), msg);
}
}
#[allow(dead_code)]
pub fn print_success_with<T: Serialize>(msg: &str, data: &T) {
if ctx().json {
let env = serde_json::json!({
"schema_version": SCHEMA_VERSION,
"ok": true,
"message": msg,
"data": data,
});
println!("{}", env);
} else {
println!("{} {}", "✓".green().bold(), msg);
}
}
#[allow(dead_code)]
pub fn print_result<T: Serialize>(data: &T) {
if ctx().json {
let env = serde_json::json!({
"schema_version": SCHEMA_VERSION,
"data": data,
});
println!("{}", env);
} else {
match serde_json::to_string_pretty(data) {
Ok(s) => println!("{}", s),
Err(e) => eprintln!("error: failed to serialize result: {e}"),
}
}
}
pub fn print_table<T>(rows: Vec<T>)
where
T: tabled::Tabled + Serialize,
{
if ctx().json {
let env = serde_json::json!({
"schema_version": SCHEMA_VERSION,
"data": rows,
});
println!("{}", env);
} else {
println!("{}", tabled::Table::new(rows));
}
}
#[allow(dead_code)]
pub fn print_info(msg: &str) {
if ctx().json {
return;
}
println!("{} {}", "→".cyan(), msg);
}
#[allow(dead_code)]
pub fn print_warning(msg: &str) {
if ctx().json {
return;
}
eprintln!("{} {}", "warn:".yellow().bold(), msg);
}
pub fn print_error(err: &(dyn std::error::Error + 'static)) {
if ctx().json {
let cli_err = extract_cli_error(err);
let env = serde_json::json!({
"schema_version": SCHEMA_VERSION,
"ok": false,
"error": cli_err,
});
eprintln!("{}", env);
return;
}
eprintln!("{} {}", "error:".red().bold(), err);
let mut source = err.source();
while let Some(cause) = source {
eprintln!(" {} {}", "→".dimmed(), cause.to_string().dimmed());
source = cause.source();
}
}
use tabled::Tabled;
#[derive(Tabled, Serialize)]
pub struct WorkspaceRow {
#[tabled(rename = "Name")]
pub name: String,
#[tabled(rename = "ID")]
pub id: String,
}
#[derive(Tabled, Serialize)]
pub struct ProjectRow {
#[tabled(rename = "Name")]
pub name: String,
#[tabled(rename = "Environment")]
pub environment: String,
#[tabled(rename = "ID")]
pub id: String,
}
#[derive(Tabled, Serialize)]
pub struct JobRow {
#[tabled(rename = "Type")]
pub job_type: String,
#[tabled(rename = "Ref")]
pub deploy_ref: String,
#[tabled(rename = "Status")]
pub status: String,
#[tabled(rename = "Created")]
pub created_at: String,
}
#[derive(Tabled, Serialize)]
pub struct ValidationRow {
#[tabled(rename = "Field")]
pub field: String,
#[tabled(rename = "Status")]
pub status: String,
#[tabled(rename = "Message")]
pub message: String,
}