use std::fmt;
use chrono::{DateTime, Utc};
use console::style;
#[derive(Clone, Copy)]
pub enum Icon {
Success, Failure, Pending, Unset, Pruned, Warning, Merge, }
impl Icon {
pub fn glyph(self) -> &'static str {
match self {
Self::Success => "✔",
Self::Failure => "✗",
Self::Pending => "●",
Self::Unset => "○",
Self::Pruned => "✂",
Self::Warning => "!",
Self::Merge => "↻",
}
}
fn default_color(self) -> SectionColor {
match self {
Self::Success => SectionColor::Green,
Self::Failure => SectionColor::Red,
Self::Pending | Self::Pruned | Self::Warning | Self::Merge => SectionColor::Yellow,
Self::Unset => SectionColor::Dim,
}
}
pub fn color(self, color: SectionColor) -> String {
apply_color(self.glyph(), color)
}
}
impl fmt::Display for Icon {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.color(self.default_color()))
}
}
pub fn visible_width(text: &str) -> usize {
console::strip_ansi_codes(text).chars().count()
}
pub fn format_version_label(version: u64, timestamp: Option<&str>) -> String {
match timestamp {
Some(ts) => format!("v{} ({})", version, format_relative_time(ts)),
None => format!("v{version}"),
}
}
pub fn format_count_summary(counts: &[(&str, usize)]) -> String {
counts
.iter()
.filter(|(_, n)| *n > 0)
.map(|(label, n)| format!("{n} {label}"))
.collect::<Vec<_>>()
.join(", ")
}
pub fn format_deploy_summary(
keys: usize,
deployed: usize,
failed: usize,
unset: usize,
pruned: usize,
) -> String {
let keys_str = style(format!("{keys} keys")).bold();
let targets_str = style(format!("{deployed} targets")).bold();
let base = format!("deployed {keys_str} to {targets_str}");
let suffix = format_count_summary(&[("failed", failed), ("unset", unset), ("pruned", pruned)]);
if suffix.is_empty() {
base
} else {
format!("{base}, {suffix}")
}
}
#[derive(Clone, Copy)]
pub enum SectionColor {
Red,
Yellow,
Green,
Dim,
}
fn apply_color(text: &str, color: SectionColor) -> String {
match color {
SectionColor::Red => style(text).red().to_string(),
SectionColor::Yellow => style(text).yellow().to_string(),
SectionColor::Green => style(text).green().to_string(),
SectionColor::Dim => style(text).dim().to_string(),
}
}
pub fn section_header(icon: impl fmt::Display, label: &str, color: SectionColor) -> String {
let styled = match color {
SectionColor::Red => style(label).red().bold(),
SectionColor::Yellow => style(label).yellow().bold(),
SectionColor::Green => style(label).green().bold(),
SectionColor::Dim => style(label).dim().bold(),
};
format!(" {icon} {styled}")
}
pub fn section_entry(left: &str, right: &str, width: usize) -> String {
let pad = width.saturating_sub(left.len());
format!(" {}{} {}", style(left).dim(), " ".repeat(pad), right)
}
pub const SPINNER_FRAMES: &[char] = &[
'\u{280B}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283C}', '\u{2834}', '\u{2826}', '\u{2827}',
'\u{2807}', '\u{280F}',
];
pub const SPINNER_INTERVAL: std::time::Duration = std::time::Duration::from_millis(80);
pub struct EskTheme;
impl cliclack::Theme for EskTheme {
fn input_style(&self, state: &cliclack::ThemeState) -> console::Style {
match state {
cliclack::ThemeState::Cancel => console::Style::new().dim().strikethrough(),
_ => console::Style::new(),
}
}
fn format_log(&self, text: &str, symbol: &str) -> String {
self.format_log_with_spacing(text, symbol, true)
}
}
pub fn format_relative_time(ts: &str) -> String {
let Ok(dt) = DateTime::parse_from_rfc3339(ts) else {
return ts.to_string();
};
let delta = Utc::now().signed_duration_since(dt.with_timezone(&Utc));
if delta.num_seconds() < 60 {
"just now".to_string()
} else if delta.num_minutes() < 60 {
format!("{}m ago", delta.num_minutes())
} else if delta.num_hours() < 24 {
format!("{}h ago", delta.num_hours())
} else if delta.num_days() < 30 {
format!("{}d ago", delta.num_days())
} else {
dt.format("%Y-%m-%d %H:%M").to_string()
}
}
pub fn format_store_outro(
version: u64,
env_versions: &[(String, u64)],
filtered_env: Option<&str>,
) -> String {
let display_version = match filtered_env {
Some(env) => env_versions
.iter()
.find(|(e, _)| e == env)
.map_or(version, |(_, v)| *v),
None => version,
};
match filtered_env {
Some(env) => format!("Store version: {display_version} ({env})"),
None if env_versions.is_empty() => {
format!("Store version: {display_version}")
}
None => {
let parts: Vec<String> = env_versions
.iter()
.map(|(e, v)| format!("{e}: v{v}"))
.collect();
format!("Store version: {} ({})", display_version, parts.join(", "))
}
}
}
pub const TRUNCATE_LIMIT: usize = 5;
pub fn truncation_footer(total: usize, shown: usize) -> Option<String> {
if total <= shown {
return None;
}
let remaining = total - shown;
Some(format!(
" {}",
style(format!("...and {remaining} more (--all to show)")).dim()
))
}
pub fn format_dashboard_line(label: &str, value: &str, width: usize) -> String {
let label_len = visible_width(label);
let value_len = visible_width(value);
if label_len + value_len + 2 >= width {
return format!("{label} {value}");
}
let dots = ".".repeat(width - label_len - value_len - 2);
format!("{} {} {}", label, style(dots).dim(), value)
}
pub fn format_aligned_line(label: &str, value: &str, label_col: usize) -> String {
let label_len = visible_width(label);
if label_len + 2 >= label_col {
return format!("{label} {value}");
}
let dots = ".".repeat(label_col - label_len - 2);
format!("{} {} {}", label, style(dots).dim(), value)
}