feat(cli): add GlyphMode icon system, Theme extensions, and progress API
Phase 1 of UX skin overhaul: foundation layer that all subsequent phases build upon. Icons: 3-tier glyph system (Nerd Font / Unicode / ASCII) with auto-detection from TERM_PROGRAM, LORE_ICONS env, or --icons flag. 16 semantic icon methods on Icons struct (success, warning, error, issue states, MR states, note, search, user, sync, waiting). Theme: 4 new semantic styles — muted (#6b7280), highlight (#fbbf24), timing (#94a3b8), state_draft (#6b7280). Progress: stage_spinner_v2 with icon prefix, nested_progress with bounded bar/throughput/ETA, finish_stage for static completion lines, format_elapsed for compact duration strings. Utilities: format_relative_time_compact (3h, 2d, 1w, 3mo), format_labels_bare (comma-separated without brackets). CLI: --icons global flag, GLOBAL_FLAGS registry updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ const GLOBAL_FLAGS: &[&str] = &[
|
||||
"--robot",
|
||||
"--json",
|
||||
"--color",
|
||||
"--icons",
|
||||
"--quiet",
|
||||
"--no-quiet",
|
||||
"--verbose",
|
||||
|
||||
@@ -44,6 +44,10 @@ pub struct Cli {
|
||||
#[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto", help = "Color output: auto (default), always, or never")]
|
||||
pub color: String,
|
||||
|
||||
/// Icon set: nerd (Nerd Fonts), unicode, or ascii
|
||||
#[arg(long, global = true, value_parser = ["nerd", "unicode", "ascii"], help = "Icon set: nerd (Nerd Fonts), unicode, or ascii")]
|
||||
pub icons: Option<String>,
|
||||
|
||||
/// Suppress non-essential output
|
||||
#[arg(
|
||||
short = 'q',
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||
use std::io::Write;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
use crate::cli::render::Icons;
|
||||
|
||||
static MULTI: LazyLock<MultiProgress> = LazyLock::new(MultiProgress::new);
|
||||
|
||||
pub fn multi() -> &'static MultiProgress {
|
||||
@@ -29,6 +32,71 @@ pub fn stage_spinner(stage: u8, total: u8, msg: &str, robot_mode: bool) -> Progr
|
||||
pb
|
||||
}
|
||||
|
||||
/// Stage spinner with icon prefix and elapsed time on the right.
|
||||
///
|
||||
/// Template: `{spinner:.cyan} {prefix} {wide_msg} {elapsed_style:.dim}`
|
||||
pub fn stage_spinner_v2(icon: &str, label: &str, msg: &str, robot_mode: bool) -> ProgressBar {
|
||||
if robot_mode {
|
||||
return ProgressBar::hidden();
|
||||
}
|
||||
let pb = multi().add(ProgressBar::new_spinner());
|
||||
pb.set_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.template(" {spinner:.cyan} {prefix} {wide_msg}")
|
||||
.expect("valid template"),
|
||||
);
|
||||
pb.enable_steady_tick(Duration::from_millis(60));
|
||||
pb.set_prefix(format!("{icon} {label}"));
|
||||
pb.set_message(msg.to_string());
|
||||
pb
|
||||
}
|
||||
|
||||
/// Nested progress bar with count, throughput, and ETA.
|
||||
///
|
||||
/// Template: ` {spinner:.dim} {msg} {bar:30.cyan/dark_gray} {pos}/{len} {per_sec:.dim} {eta:.dim}`
|
||||
pub fn nested_progress(msg: &str, len: u64, robot_mode: bool) -> ProgressBar {
|
||||
if robot_mode {
|
||||
return ProgressBar::hidden();
|
||||
}
|
||||
let pb = multi().add(ProgressBar::new(len));
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(
|
||||
" {spinner:.dim} {msg} {bar:30.cyan/dark_gray} {pos}/{len} {per_sec:.dim} {eta:.dim}",
|
||||
)
|
||||
.expect("valid template")
|
||||
.progress_chars(Icons::progress_chars()),
|
||||
);
|
||||
pb.enable_steady_tick(Duration::from_millis(60));
|
||||
pb.set_message(msg.to_string());
|
||||
pb
|
||||
}
|
||||
|
||||
/// Replace a spinner with a static completion line showing icon, label, summary, and elapsed.
|
||||
///
|
||||
/// Output: ` ✓ Label summary elapsed`
|
||||
pub fn finish_stage(pb: &ProgressBar, icon: &str, label: &str, summary: &str, elapsed: Duration) {
|
||||
let elapsed_str = format_elapsed(elapsed);
|
||||
let line = format!(" {icon} {label:<12}{summary:>40} {elapsed_str:>8}",);
|
||||
pb.set_style(ProgressStyle::with_template("{msg}").expect("valid template"));
|
||||
pb.finish_with_message(line);
|
||||
}
|
||||
|
||||
/// Format a Duration as a compact human string (e.g. "1.2s", "42ms", "1m 5s").
|
||||
fn format_elapsed(d: Duration) -> String {
|
||||
let ms = d.as_millis();
|
||||
if ms < 1000 {
|
||||
format!("{ms}ms")
|
||||
} else if ms < 60_000 {
|
||||
format!("{:.1}s", ms as f64 / 1000.0)
|
||||
} else {
|
||||
let secs = d.as_secs();
|
||||
let m = secs / 60;
|
||||
let s = secs % 60;
|
||||
format!("{m}m {s}s")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SuspendingWriter;
|
||||
|
||||
@@ -116,9 +184,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn stage_spinner_human_mode_sets_properties() {
|
||||
// In non-TTY test environments, MultiProgress may report bars as
|
||||
// hidden. Verify the human-mode code path by checking that prefix
|
||||
// and message are configured (robot-mode returns a bare hidden bar).
|
||||
let pb = stage_spinner(1, 3, "Testing...", false);
|
||||
assert_eq!(pb.prefix(), "[1/3]");
|
||||
assert_eq!(pb.message(), "Testing...");
|
||||
@@ -138,4 +203,52 @@ mod tests {
|
||||
assert_eq!(pb.message(), "Seeding timeline...");
|
||||
pb.finish_and_clear();
|
||||
}
|
||||
|
||||
// ── New progress API tests ──
|
||||
|
||||
#[test]
|
||||
fn stage_spinner_v2_robot_mode_returns_hidden() {
|
||||
let pb = stage_spinner_v2("\u{2714}", "Issues", "fetching...", true);
|
||||
assert!(pb.is_hidden());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_spinner_v2_human_mode_sets_properties() {
|
||||
let pb = stage_spinner_v2("\u{2714}", "Issues", "fetching...", false);
|
||||
assert!(pb.prefix().contains("Issues"));
|
||||
assert_eq!(pb.message(), "fetching...");
|
||||
pb.finish_and_clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_progress_robot_mode_returns_hidden() {
|
||||
let pb = nested_progress("Embedding...", 100, true);
|
||||
assert!(pb.is_hidden());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_progress_human_mode_sets_length() {
|
||||
let pb = nested_progress("Embedding...", 100, false);
|
||||
assert_eq!(pb.length(), Some(100));
|
||||
assert_eq!(pb.message(), "Embedding...");
|
||||
pb.finish_and_clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_elapsed_sub_second() {
|
||||
assert_eq!(format_elapsed(Duration::from_millis(42)), "42ms");
|
||||
assert_eq!(format_elapsed(Duration::from_millis(999)), "999ms");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_elapsed_seconds() {
|
||||
assert_eq!(format_elapsed(Duration::from_millis(1200)), "1.2s");
|
||||
assert_eq!(format_elapsed(Duration::from_millis(5000)), "5.0s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_elapsed_minutes() {
|
||||
assert_eq!(format_elapsed(Duration::from_secs(65)), "1m 5s");
|
||||
assert_eq!(format_elapsed(Duration::from_secs(120)), "2m 0s");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,17 +16,234 @@ pub enum ColorMode {
|
||||
Never,
|
||||
}
|
||||
|
||||
// ─── Glyph Mode ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Icon tier: Nerd Font glyphs, Unicode symbols, or plain ASCII.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GlyphMode {
|
||||
Nerd,
|
||||
Unicode,
|
||||
Ascii,
|
||||
}
|
||||
|
||||
impl GlyphMode {
|
||||
/// Detect glyph mode from CLI flag, env, or terminal heuristics.
|
||||
///
|
||||
/// Precedence:
|
||||
/// 1. Explicit `--icons` CLI value (passed as `cli_flag`)
|
||||
/// 2. `LORE_ICONS` environment variable
|
||||
/// 3. Auto-detect: Nerd if `$TERM_PROGRAM` matches known Nerd-capable terminals
|
||||
/// or `$NERD_FONTS=1`; otherwise Unicode
|
||||
/// 4. Force ASCII if `force_ascii` is true (robot mode or `--color never`)
|
||||
pub fn detect(cli_flag: Option<&str>, force_ascii: bool) -> Self {
|
||||
if force_ascii {
|
||||
return Self::Ascii;
|
||||
}
|
||||
|
||||
// 1. CLI flag
|
||||
if let Some(flag) = cli_flag {
|
||||
return Self::from_str_lossy(flag);
|
||||
}
|
||||
|
||||
// 2. Env var
|
||||
if let Ok(val) = std::env::var("LORE_ICONS") {
|
||||
return Self::from_str_lossy(&val);
|
||||
}
|
||||
|
||||
// 3. Auto-detect
|
||||
if Self::detect_nerd_capable() {
|
||||
Self::Nerd
|
||||
} else {
|
||||
Self::Unicode
|
||||
}
|
||||
}
|
||||
|
||||
fn from_str_lossy(s: &str) -> Self {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"nerd" => Self::Nerd,
|
||||
"unicode" => Self::Unicode,
|
||||
"ascii" => Self::Ascii,
|
||||
_ => Self::Unicode,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_nerd_capable() -> bool {
|
||||
if std::env::var("NERD_FONTS")
|
||||
.ok()
|
||||
.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
std::env::var("TERM_PROGRAM").ok().is_some_and(|tp| {
|
||||
matches!(
|
||||
tp.as_str(),
|
||||
"WezTerm" | "kitty" | "Alacritty" | "iTerm2.app" | "iTerm.app"
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Icons ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Glyph catalog returning the right icon for the active `GlyphMode`.
|
||||
pub struct Icons;
|
||||
|
||||
impl Icons {
|
||||
fn mode() -> GlyphMode {
|
||||
RENDERER.get().map_or(GlyphMode::Unicode, |r| r.glyphs)
|
||||
}
|
||||
|
||||
// ── Status indicators ──
|
||||
|
||||
pub fn success() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f058}", // nf-fa-check_circle
|
||||
GlyphMode::Unicode => "\u{2714}", // heavy check mark
|
||||
GlyphMode::Ascii => "[ok]",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn warning() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f421}", // nf-oct-alert
|
||||
GlyphMode::Unicode => "\u{26a0}", // warning sign
|
||||
GlyphMode::Ascii => "[!]",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f467}", // nf-oct-x_circle
|
||||
GlyphMode::Unicode => "\u{2716}", // heavy multiplication x
|
||||
GlyphMode::Ascii => "[X]",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f449}", // nf-oct-info
|
||||
GlyphMode::Unicode => "\u{2139}", // information source
|
||||
GlyphMode::Ascii => "[i]",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entity state ──
|
||||
|
||||
pub fn issue_opened() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f41b}", // nf-oct-issue_opened
|
||||
GlyphMode::Unicode => "\u{25cb}", // white circle
|
||||
GlyphMode::Ascii => "( )",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn issue_closed() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f41d}", // nf-oct-issue_closed
|
||||
GlyphMode::Unicode => "\u{25cf}", // black circle
|
||||
GlyphMode::Ascii => "(x)",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mr_opened() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f407}", // nf-oct-git_pull_request
|
||||
GlyphMode::Unicode => "\u{21c4}", // rightwards arrow over leftwards
|
||||
GlyphMode::Ascii => "<->",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mr_merged() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f402}", // nf-oct-git_merge
|
||||
GlyphMode::Unicode => "\u{2714}", // heavy check mark
|
||||
GlyphMode::Ascii => "[M]",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mr_closed() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f430}", // nf-oct-git_pull_request_closed
|
||||
GlyphMode::Unicode => "\u{2716}", // heavy multiplication x
|
||||
GlyphMode::Ascii => "[X]",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mr_draft() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f040}", // nf-fa-pencil
|
||||
GlyphMode::Unicode => "\u{270e}", // lower right pencil
|
||||
GlyphMode::Ascii => "[D]",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Miscellaneous ──
|
||||
|
||||
pub fn note() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f3ed}", // nf-oct-comment
|
||||
GlyphMode::Unicode => "\u{25b8}", // black right-pointing small triangle
|
||||
GlyphMode::Ascii => ">",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f422}", // nf-oct-search
|
||||
GlyphMode::Unicode => "\u{1f50d}", // left-pointing magnifying glass
|
||||
GlyphMode::Ascii => "?",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f415}", // nf-oct-person
|
||||
GlyphMode::Unicode => "@",
|
||||
GlyphMode::Ascii => "@",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd => "\u{f46a}", // nf-oct-sync
|
||||
GlyphMode::Unicode => "\u{21bb}", // clockwise open circle arrow
|
||||
GlyphMode::Ascii => "<>",
|
||||
}
|
||||
}
|
||||
|
||||
/// Waiting stage indicator (dimmed dot).
|
||||
pub fn waiting() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd | GlyphMode::Unicode => "\u{00b7}", // middle dot
|
||||
GlyphMode::Ascii => ".",
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress bar characters: (filled, head, empty).
|
||||
pub fn progress_chars() -> &'static str {
|
||||
match Self::mode() {
|
||||
GlyphMode::Nerd | GlyphMode::Unicode => "\u{2501}\u{2578} ",
|
||||
GlyphMode::Ascii => "=> ",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Renderer ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Global renderer singleton, initialized once in `main.rs`.
|
||||
static RENDERER: OnceLock<LoreRenderer> = OnceLock::new();
|
||||
|
||||
pub struct LoreRenderer {
|
||||
/// Resolved at init time so we don't re-check TTY + NO_COLOR on every call.
|
||||
colors: bool,
|
||||
/// Icon tier for the session.
|
||||
glyphs: GlyphMode,
|
||||
}
|
||||
|
||||
impl LoreRenderer {
|
||||
/// Initialize the global renderer. Call once at startup.
|
||||
pub fn init(mode: ColorMode) {
|
||||
pub fn init(mode: ColorMode, glyphs: GlyphMode) {
|
||||
let colors = match mode {
|
||||
ColorMode::Always => true,
|
||||
ColorMode::Never => false,
|
||||
@@ -35,7 +252,7 @@ impl LoreRenderer {
|
||||
&& std::env::var("NO_COLOR").map_or(true, |v| v.is_empty())
|
||||
}
|
||||
};
|
||||
let _ = RENDERER.set(LoreRenderer { colors });
|
||||
let _ = RENDERER.set(LoreRenderer { colors, glyphs });
|
||||
}
|
||||
|
||||
/// Get the global renderer. Panics if `init` hasn't been called.
|
||||
@@ -49,6 +266,11 @@ impl LoreRenderer {
|
||||
pub fn colors_enabled(&self) -> bool {
|
||||
self.colors
|
||||
}
|
||||
|
||||
/// The active glyph mode.
|
||||
pub fn glyph_mode(&self) -> GlyphMode {
|
||||
self.glyphs
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if colors are enabled. Returns false if `LoreRenderer` hasn't been
|
||||
@@ -176,6 +398,39 @@ impl Theme {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state_draft() -> Style {
|
||||
if colors_on() {
|
||||
Style::new().foreground("#6b7280")
|
||||
} else {
|
||||
Style::new()
|
||||
}
|
||||
}
|
||||
|
||||
// Semantic additions
|
||||
pub fn muted() -> Style {
|
||||
if colors_on() {
|
||||
Style::new().foreground("#6b7280")
|
||||
} else {
|
||||
Style::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn highlight() -> Style {
|
||||
if colors_on() {
|
||||
Style::new().foreground("#fbbf24").bold()
|
||||
} else {
|
||||
Style::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn timing() -> Style {
|
||||
if colors_on() {
|
||||
Style::new().foreground("#94a3b8")
|
||||
} else {
|
||||
Style::new()
|
||||
}
|
||||
}
|
||||
|
||||
// Structure
|
||||
pub fn section_title() -> Style {
|
||||
if colors_on() {
|
||||
@@ -369,6 +624,24 @@ pub fn format_labels(labels: &[String], max_shown: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a slice of labels without brackets.
|
||||
/// e.g. `["bug", "urgent"]` with max 2 -> `"bug, urgent"`
|
||||
/// e.g. `["a", "b", "c", "d"]` with max 2 -> `"a, b +2"`
|
||||
pub fn format_labels_bare(labels: &[String], max_shown: usize) -> String {
|
||||
if labels.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect();
|
||||
let overflow = labels.len().saturating_sub(max_shown);
|
||||
|
||||
if overflow > 0 {
|
||||
format!("{} +{}", shown.join(", "), overflow)
|
||||
} else {
|
||||
shown.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a duration in milliseconds as a human-friendly string.
|
||||
pub fn format_duration_ms(ms: u64) -> String {
|
||||
if ms < 1000 {
|
||||
@@ -378,6 +651,26 @@ pub fn format_duration_ms(ms: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an epoch-ms timestamp as a compact relative time string.
|
||||
/// Returns short forms like `3h`, `2d`, `1w`, `3mo` suitable for tight table columns.
|
||||
pub fn format_relative_time_compact(ms_epoch: i64) -> String {
|
||||
let now = now_ms();
|
||||
let diff = now - ms_epoch;
|
||||
|
||||
if diff < 0 {
|
||||
return "future".to_string();
|
||||
}
|
||||
|
||||
match diff {
|
||||
d if d < 60_000 => "now".to_string(),
|
||||
d if d < 3_600_000 => format!("{}m", d / 60_000),
|
||||
d if d < 86_400_000 => format!("{}h", d / 3_600_000),
|
||||
d if d < 604_800_000 => format!("{}d", d / 86_400_000),
|
||||
d if d < 2_592_000_000 => format!("{}w", d / 604_800_000),
|
||||
_ => format!("{}mo", diff / 2_592_000_000),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Table Renderer ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Column alignment for the table renderer.
|
||||
@@ -946,6 +1239,121 @@ mod tests {
|
||||
assert!(plain.contains("1"), "got: {plain}");
|
||||
}
|
||||
|
||||
// ── GlyphMode ──
|
||||
|
||||
#[test]
|
||||
fn glyph_mode_cli_flag_overrides_all() {
|
||||
assert_eq!(GlyphMode::detect(Some("ascii"), false), GlyphMode::Ascii);
|
||||
assert_eq!(GlyphMode::detect(Some("nerd"), false), GlyphMode::Nerd);
|
||||
assert_eq!(
|
||||
GlyphMode::detect(Some("unicode"), false),
|
||||
GlyphMode::Unicode
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_mode_force_ascii_overrides_cli_flag() {
|
||||
assert_eq!(GlyphMode::detect(Some("nerd"), true), GlyphMode::Ascii);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_mode_unknown_falls_back_to_unicode() {
|
||||
assert_eq!(GlyphMode::detect(Some("bogus"), false), GlyphMode::Unicode);
|
||||
}
|
||||
|
||||
// ── Icons ──
|
||||
|
||||
#[test]
|
||||
fn icons_return_nonempty_strings() {
|
||||
// Without RENDERER initialized, Icons falls back to Unicode mode
|
||||
assert!(!Icons::success().is_empty());
|
||||
assert!(!Icons::warning().is_empty());
|
||||
assert!(!Icons::error().is_empty());
|
||||
assert!(!Icons::info().is_empty());
|
||||
assert!(!Icons::issue_opened().is_empty());
|
||||
assert!(!Icons::issue_closed().is_empty());
|
||||
assert!(!Icons::mr_opened().is_empty());
|
||||
assert!(!Icons::mr_merged().is_empty());
|
||||
assert!(!Icons::mr_closed().is_empty());
|
||||
assert!(!Icons::mr_draft().is_empty());
|
||||
assert!(!Icons::note().is_empty());
|
||||
assert!(!Icons::search().is_empty());
|
||||
assert!(!Icons::user().is_empty());
|
||||
assert!(!Icons::sync().is_empty());
|
||||
assert!(!Icons::waiting().is_empty());
|
||||
assert!(!Icons::progress_chars().is_empty());
|
||||
}
|
||||
|
||||
// ── format_labels_bare ──
|
||||
|
||||
#[test]
|
||||
fn format_labels_bare_empty() {
|
||||
assert_eq!(format_labels_bare(&[], 2), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_bare_no_brackets() {
|
||||
let labels = vec!["bug".to_string(), "urgent".to_string()];
|
||||
assert_eq!(format_labels_bare(&labels, 2), "bug, urgent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_bare_overflow() {
|
||||
let labels = vec![
|
||||
"a".to_string(),
|
||||
"b".to_string(),
|
||||
"c".to_string(),
|
||||
"d".to_string(),
|
||||
];
|
||||
assert_eq!(format_labels_bare(&labels, 2), "a, b +2");
|
||||
}
|
||||
|
||||
// ── format_relative_time_compact ──
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_now() {
|
||||
let recent = now_ms() - 5_000;
|
||||
assert_eq!(format_relative_time_compact(recent), "now");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_minutes() {
|
||||
let mins_ago = now_ms() - 300_000; // 5 minutes
|
||||
assert_eq!(format_relative_time_compact(mins_ago), "5m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_hours() {
|
||||
let hours_ago = now_ms() - 7_200_000; // 2 hours
|
||||
assert_eq!(format_relative_time_compact(hours_ago), "2h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_days() {
|
||||
let days_ago = now_ms() - 172_800_000; // 2 days
|
||||
assert_eq!(format_relative_time_compact(days_ago), "2d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_weeks() {
|
||||
let weeks_ago = now_ms() - 1_209_600_000; // 2 weeks
|
||||
assert_eq!(format_relative_time_compact(weeks_ago), "2w");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_months() {
|
||||
let months_ago = now_ms() - 5_184_000_000; // ~2 months
|
||||
assert_eq!(format_relative_time_compact(months_ago), "2mo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_relative_time_compact_future() {
|
||||
let future = now_ms() + 60_000;
|
||||
assert_eq!(format_relative_time_compact(future), "future");
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
/// Strip ANSI escape codes (SGR sequences) for content assertions.
|
||||
fn strip_ansi(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
|
||||
Reference in New Issue
Block a user