Skip to main content

typub_ui/
lib.rs

1//! Unified UI module for CLI output
2//!
3//! Following rust-cli standards:
4//! - Data → stdout, logs/errors → stderr
5//! - Respects NO_COLOR environment variable
6//! - TTY detection before coloring
7//! - Uses owo-colors for styling
8//! - Uses indicatif for progress bars
9//!
10//! Per [[ADR-0004]], this crate re-exports `typub-log` for convenience and
11//! provides `IndicatifReporter` implementing `ProgressReporter`.
12
13use comfy_table::Table as ComfyTable;
14use indicatif::{ProgressBar, ProgressStyle};
15use owo_colors::OwoColorize;
16use std::io::{self, IsTerminal};
17use std::time::Duration;
18
19// Re-export typub-log for convenience
20pub use typub_log::{FnReporter, NullReporter, ProgressReporter};
21pub use typub_log::{debug, error, info, init, is_verbose, trace, warn};
22
23pub mod i18n;
24
25/// Check if colors should be used
26fn use_colors() -> bool {
27    io::stdout().is_terminal() && std::env::var("NO_COLOR").is_err()
28}
29
30// ============ Status Icons ============
31
32mod icons {
33    pub const SUCCESS: &str = "✓";
34    pub const ERROR: &str = "✗";
35    pub const WARNING: &str = "⚠";
36    pub const INFO: &str = "ℹ";
37    pub const ARROW: &str = "→";
38    pub const PENDING: &str = "○";
39    pub const DONE: &str = "●";
40    pub const SKIP: &str = "⊘";
41}
42
43// ============ Styled Output Helpers ============
44
45/// Apply color conditionally based on terminal and NO_COLOR
46macro_rules! styled {
47    ($text:expr, $color:ident) => {
48        if use_colors() {
49            format!("{}", $text.$color())
50        } else {
51            $text.to_string()
52        }
53    };
54    ($text:expr, $color:ident, bold) => {
55        if use_colors() {
56            format!("{}", $text.$color().bold())
57        } else {
58            $text.to_string()
59        }
60    };
61}
62
63// ============ Public API - Messages ============
64
65/// Print a success message (to stderr for logging)
66pub fn success(message: &str) {
67    eprintln!("{} {}", styled!(icons::SUCCESS, green), message);
68}
69
70/// Print an error message (to stderr)
71pub fn error(message: &str) {
72    eprintln!("{} {}", styled!(icons::ERROR, red), styled!(message, red));
73}
74
75/// Print a warning message (to stderr)
76pub fn warn(message: &str) {
77    eprintln!(
78        "{} {}",
79        styled!(icons::WARNING, yellow),
80        styled!(message, yellow)
81    );
82}
83
84/// Print an info message (to stderr for logging)
85pub fn info(message: &str) {
86    eprintln!("{} {}", styled!(icons::INFO, blue), message);
87}
88
89/// Print a debug message (only in verbose mode, to stderr)
90pub fn debug(message: &str) {
91    if is_verbose() {
92        eprintln!("{} {}", styled!("[debug]", bright_black), message);
93    }
94}
95
96// ============ Public API - Structured Output ============
97
98/// Print a header/section title (to stderr)
99pub fn header(title: &str) {
100    eprintln!();
101    eprintln!("{}", styled!(title, cyan, bold));
102    let separator = "─".repeat(title.len().max(40));
103    eprintln!("{}", styled!(&separator, bright_black));
104}
105
106/// Print a step in a multi-step process (to stderr)
107pub fn step(number: usize, total: usize, message: &str) {
108    let step_info = format!("[{}/{}]", number, total);
109    eprintln!("{} {}", styled!(&step_info, cyan), message);
110}
111
112/// Print a sub-item with label and value (to stderr)
113pub fn item(label: &str, value: &str) {
114    eprintln!(
115        "  {} {}: {}",
116        styled!(icons::ARROW, bright_black),
117        styled!(label, cyan),
118        value
119    );
120}
121
122/// Print platform status line (to stderr)
123pub fn platform_status(platform: &str, published: bool, url: Option<&str>) {
124    let (icon, platform_styled) = if published {
125        (styled!(icons::DONE, green), styled!(platform, green))
126    } else {
127        (
128            styled!(icons::PENDING, bright_black),
129            styled!(platform, bright_black),
130        )
131    };
132
133    if let Some(url) = url {
134        eprintln!(
135            "  {} {} {}",
136            icon,
137            platform_styled,
138            styled!(url, bright_black)
139        );
140    } else {
141        eprintln!("  {} {}", icon, platform_styled);
142    }
143}
144
145// ============ Public API - Publish Logging ============
146
147/// Log start of publish operation
148pub fn log_publish_start(title: &str, platforms: &[&str]) {
149    header(&format!("Publishing: {}", title));
150    info(&format!("Targets: {}", platforms.join(", ")));
151}
152
153/// Log successful publish
154pub fn log_publish_success(platform: &str, url: Option<&str>) {
155    if let Some(url) = url {
156        eprintln!(
157            "  {} {} {} {}",
158            styled!(icons::SUCCESS, green),
159            styled!(platform, green, bold),
160            styled!(icons::ARROW, bright_black),
161            styled!(url, cyan)
162        );
163    } else {
164        eprintln!(
165            "  {} {}",
166            styled!(icons::SUCCESS, green),
167            styled!(platform, green, bold)
168        );
169    }
170}
171
172/// Log skipped operation
173pub fn log_skip(platform: &str, reason: &str) {
174    eprintln!(
175        "  {} {} ({})",
176        styled!(icons::SKIP, bright_black),
177        styled!(platform, bright_black),
178        styled!(reason, bright_black)
179    );
180}
181
182/// Log dry run
183pub fn log_dry_run(platform: &str) {
184    eprintln!(
185        "  {} Would publish to: {}",
186        styled!("[DRY RUN]", yellow),
187        platform
188    );
189}
190
191// ============ Progress Bars (using indicatif) ============
192
193/// Create a spinner for operations with unknown duration
194pub fn spinner(message: &str) -> ProgressBar {
195    let pb = ProgressBar::new_spinner();
196    let mut style = ProgressStyle::default_spinner();
197    if let Ok(s) = style.clone().template("{spinner:.cyan} {msg}") {
198        style = s.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏");
199    }
200    pb.set_style(style);
201    pb.set_message(message.to_string());
202    pb.enable_steady_tick(Duration::from_millis(80));
203    pb
204}
205
206/// Finish spinner with success
207pub fn spinner_success(pb: ProgressBar, message: &str) {
208    if let Ok(style) = ProgressStyle::default_spinner().template("{msg}") {
209        pb.set_style(style);
210    }
211    pb.finish_with_message(format!("{} {}", styled!(icons::SUCCESS, green), message));
212}
213
214/// Finish spinner with error
215pub fn spinner_error(pb: ProgressBar, message: &str) {
216    if let Ok(style) = ProgressStyle::default_spinner().template("{msg}") {
217        pb.set_style(style);
218    }
219    pb.finish_with_message(format!(
220        "{} {}",
221        styled!(icons::ERROR, red),
222        styled!(message, red)
223    ));
224}
225
226/// Create a progress bar for known-length operations
227pub fn progress_bar(len: u64, message: &str) -> ProgressBar {
228    let pb = ProgressBar::new(len);
229    let mut style = ProgressStyle::default_bar();
230    if let Ok(s) = style
231        .clone()
232        .template("{msg} [{bar:30.cyan/bright_black}] {pos}/{len}")
233    {
234        style = s.progress_chars("━━─");
235    }
236    pb.set_style(style);
237    pb.set_message(message.to_string());
238    pb
239}
240
241// ============ Multi-Step Progress ============
242
243/// Progress tracker for multi-step operations
244pub struct MultiProgress {
245    name: String,
246    current: usize,
247    total: usize,
248    spinner: Option<ProgressBar>,
249}
250
251impl MultiProgress {
252    pub fn new(name: &str, total: usize) -> Self {
253        header(name);
254        Self {
255            name: name.to_string(),
256            current: 0,
257            total,
258            spinner: None,
259        }
260    }
261
262    /// Start a new step
263    pub fn step(&mut self, message: &str) {
264        // Finish previous spinner if any
265        if let Some(pb) = self.spinner.take() {
266            spinner_success(pb, "Done");
267        }
268
269        self.current += 1;
270        let step_msg = format!("[{}/{}] {}", self.current, self.total, message);
271        self.spinner = Some(spinner(&step_msg));
272    }
273
274    /// Finish all steps successfully
275    pub fn finish(self) {
276        if let Some(pb) = self.spinner {
277            spinner_success(pb, "Done");
278        }
279        success(&format!("{} completed", self.name));
280    }
281
282    /// Finish with error
283    pub fn finish_error(self, err: &str) {
284        if let Some(pb) = self.spinner {
285            spinner_error(pb, err);
286        }
287        error(&format!("{} failed: {}", self.name, err));
288    }
289}
290
291// ============ Table Output (to stdout for data) ============
292
293/// Print a simple table (data goes to stdout)
294pub struct Table {
295    headers: Vec<String>,
296    rows: Vec<Vec<String>>,
297    widths: Vec<usize>,
298}
299
300impl Table {
301    pub fn new(headers: &[&str]) -> Self {
302        let headers: Vec<String> = headers.iter().map(|s| s.to_string()).collect();
303        let widths = headers.iter().map(|h| h.len()).collect();
304        Self {
305            headers,
306            rows: Vec::new(),
307            widths,
308        }
309    }
310
311    pub fn add_row(&mut self, row: &[&str]) {
312        let row: Vec<String> = row.iter().map(|s| s.to_string()).collect();
313        for (i, cell) in row.iter().enumerate() {
314            if i < self.widths.len() {
315                self.widths[i] = self.widths[i].max(cell.len());
316            }
317        }
318        self.rows.push(row);
319    }
320
321    /// Print table to stdout (data output)
322    pub fn print(&self) {
323        // Header
324        let header_cells: Vec<String> = self
325            .headers
326            .iter()
327            .enumerate()
328            .map(|(i, h)| format!("{:width$}", h, width = self.widths[i]))
329            .collect();
330        let header_line = header_cells.join("  ");
331        println!("{}", styled!(&header_line, bold));
332
333        // Separator
334        let sep: Vec<String> = self.widths.iter().map(|w| "─".repeat(*w)).collect();
335        println!("{}", styled!(&sep.join("──"), bright_black));
336
337        // Rows
338        for row in &self.rows {
339            let cells: Vec<String> = row
340                .iter()
341                .enumerate()
342                .map(|(i, cell)| {
343                    let width = self.widths.get(i).copied().unwrap_or(cell.len());
344                    format!("{:width$}", cell, width = width)
345                })
346                .collect();
347            println!("{}", cells.join("  "));
348        }
349    }
350}
351
352// ============ IndicatifReporter ============
353
354/// A progress reporter using indicatif progress bars.
355///
356/// Implements [[ADR-0004]] Phase 4: ProgressReporter for typub-ui.
357pub struct IndicatifReporter {
358    bar: ProgressBar,
359}
360
361impl IndicatifReporter {
362    /// Create a new reporter with a spinner (indeterminate progress).
363    pub fn spinner(message: &str) -> Self {
364        let bar = spinner(message);
365        Self { bar }
366    }
367
368    /// Create a new reporter with a progress bar (known length).
369    pub fn progress(len: u64, message: &str) -> Self {
370        let bar = progress_bar(len, message);
371        Self { bar }
372    }
373}
374
375impl ProgressReporter for IndicatifReporter {
376    fn set_message(&self, message: &str) {
377        self.bar.set_message(message.to_string());
378    }
379
380    fn set_progress(&self, current: u64, total: u64) {
381        if total > 0 {
382            self.bar.set_length(total);
383            self.bar.set_position(current);
384        }
385    }
386
387    fn finish_success(&self, message: &str) {
388        spinner_success_ref(&self.bar, message);
389    }
390
391    fn finish_error(&self, message: &str) {
392        spinner_error_ref(&self.bar, message);
393    }
394
395    fn inc(&self, delta: u64) {
396        self.bar.inc(delta);
397    }
398}
399
400/// Finish spinner with success (by reference).
401fn spinner_success_ref(pb: &ProgressBar, message: &str) {
402    if let Ok(style) = ProgressStyle::default_spinner().template("{msg}") {
403        pb.set_style(style);
404    }
405    pb.finish_with_message(format!("{} {}", styled!(icons::SUCCESS, green), message));
406}
407
408/// Finish spinner with error (by reference).
409fn spinner_error_ref(pb: &ProgressBar, message: &str) {
410    if let Ok(style) = ProgressStyle::default_spinner().template("{msg}") {
411        pb.set_style(style);
412    }
413    pb.finish_with_message(format!(
414        "{} {}",
415        styled!(icons::ERROR, red),
416        styled!(message, red)
417    ));
418}
419
420// ============ Asset Analysis Display ============
421
422/// Format bytes into human-readable string.
423fn format_size(bytes: u64) -> String {
424    const KB: u64 = 1024;
425    const MB: u64 = KB * 1024;
426    const GB: u64 = MB * 1024;
427
428    if bytes >= GB {
429        format!("{:.1} GB", bytes as f64 / GB as f64)
430    } else if bytes >= MB {
431        format!("{:.1} MB", bytes as f64 / MB as f64)
432    } else if bytes >= KB {
433        format!("{:.1} KB", bytes as f64 / KB as f64)
434    } else {
435        format!("{} B", bytes)
436    }
437}
438
439/// Display asset analysis in a formatted table.
440///
441/// Shows summary of assets: total count, new (will upload), cached (reused).
442pub fn log_asset_analysis(
443    title: &str,
444    total_count: usize,
445    new_count: usize,
446    new_size_bytes: u64,
447    cached_count: usize,
448    cached_size_bytes: u64,
449) {
450    let mut table = ComfyTable::new();
451    table.load_preset(comfy_table::presets::NOTHING);
452
453    // Header
454    table.set_header(vec!["", "Count", "Size"]);
455
456    // Total row
457    table.add_row(vec![
458        "📦 Total",
459        &total_count.to_string(),
460        &format_size(new_size_bytes + cached_size_bytes),
461    ]);
462
463    // New row (will upload)
464    table.add_row(vec![
465        "✅ New (will upload)",
466        &new_count.to_string(),
467        &format_size(new_size_bytes),
468    ]);
469
470    // Cached row (will skip)
471    table.add_row(vec![
472        "🔄 Cached (will skip)",
473        &cached_count.to_string(),
474        &format_size(cached_size_bytes),
475    ]);
476
477    eprintln!();
478    eprintln!("{}", styled!(title, cyan, bold));
479    eprintln!("{}", table);
480}