subx_cli/cli/ui.rs
1//! User interface utilities and display helpers for SubX CLI.
2//!
3//! This module provides a comprehensive set of utilities for creating
4//! consistent and user-friendly command-line interfaces. It handles
5//! status messages, progress indicators, result displays, and AI usage
6//! statistics with consistent styling and formatting.
7//!
8//! # Features
9//!
10//! - **Status Messages**: Success, error, and warning message formatting
11//! - **Progress Indicators**: Configurable progress bars for long operations
12//! - **Result Display**: Formatted tables and structured output
13//! - **AI Statistics**: Usage tracking and cost information display
14//! - **Consistent Styling**: Color-coded messages with Unicode symbols
15//!
16//! # Design Principles
17//!
18//! - **Accessibility**: Clear visual hierarchy with color and symbols
19//! - **Configurability**: Respects user preferences for progress display
20//! - **Consistency**: Unified styling across all CLI operations
21//! - **Informativeness**: Rich context and actionable information
22//!
23//! # Examples
24//!
25//! ```rust
26//! use subx_cli::cli::ui;
27//!
28//! // Display status messages
29//! ui::print_success("Subtitle files processed successfully");
30//! ui::print_warning("File format might be incompatible");
31//! ui::print_error("Unable to read configuration file");
32//!
33//! // Create progress bar for batch operations
34//! let progress = ui::create_progress_bar(100);
35//! for i in 0..100 {
36//! progress.inc(1);
37//! // ... processing ...
38//! }
39//! progress.finish_with_message("Processing completed");
40//! ```
41
42// src/cli/ui.rs
43use crate::cli::output::{self, OutputMode};
44use crate::cli::table::{MatchDisplayRow, create_match_table};
45use crate::core::matcher::MatchOperation;
46use colored::*;
47use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
48
49/// Display a success message with consistent formatting.
50///
51/// Prints a success message with a green checkmark symbol and styled text.
52/// Used throughout the application to indicate successful completion of
53/// operations such as file processing, configuration updates, or command execution.
54///
55/// # Format
56/// ```text
57/// ✓ [message]
58/// ```
59///
60/// # Examples
61///
62/// ```rust
63/// use subx_cli::cli::ui::print_success;
64///
65/// print_success("Successfully processed 15 subtitle files");
66/// print_success("Configuration saved to ~/.config/subx/config.toml");
67/// print_success("AI matching completed with 98% confidence");
68/// ```
69///
70/// # Output Examples
71/// ```text
72/// ✓ Successfully processed 15 subtitle files
73/// ✓ Configuration saved to ~/.config/subx/config.toml
74/// ✓ AI matching completed with 98% confidence
75/// ```
76pub fn print_success(message: &str) {
77 // In JSON output mode, success/warning helpers are silent — the
78 // command's structured payload conveys the same information through
79 // the envelope. `--quiet` in text mode also suppresses these lines.
80 if output::active_mode().is_json() || output::is_quiet() {
81 return;
82 }
83 println!("{} {}", "✓".green().bold(), message);
84}
85
86/// Display an error message with consistent formatting.
87///
88/// Prints an error message to stderr with a red X symbol and styled text.
89/// Used for reporting errors, failures, and critical issues that prevent
90/// operation completion. Messages are sent to stderr to separate them
91/// from normal program output.
92///
93/// # Format
94/// ```text
95/// ✗ [message]
96/// ```
97///
98/// # Examples
99///
100/// ```rust
101/// use subx_cli::cli::ui::print_error;
102///
103/// print_error("Failed to load configuration file");
104/// print_error("AI API request timed out after 30 seconds");
105/// print_error("Invalid subtitle format detected");
106/// ```
107///
108/// # Output Examples
109/// ```text
110/// ✗ Failed to load configuration file
111/// ✗ AI API request timed out after 30 seconds
112/// ✗ Invalid subtitle format detected
113/// ```
114pub fn print_error(message: &str) {
115 // In JSON mode `print_error` SHALL still write to stderr but
116 // without ANSI styling and without the `✗ ` symbol prefix so logs
117 // stay greppable. With `--quiet` in JSON mode all stderr chatter
118 // is suppressed; fatal errors are surfaced through the JSON
119 // envelope on stdout instead.
120 if output::active_mode().is_json() {
121 if !output::is_quiet() {
122 eprintln!("{}", output::strip_ansi(message));
123 }
124 return;
125 }
126 eprintln!("{} {}", "✗".red().bold(), message);
127}
128
129/// Display a warning message with consistent formatting.
130///
131/// Prints a warning message with a yellow warning symbol and styled text.
132/// Used for non-critical issues, deprecation notices, or situations that
133/// may require user attention but don't prevent operation completion.
134///
135/// # Format
136/// ```text
137/// ⚠ [message]
138/// ```
139///
140/// # Examples
141///
142/// ```rust
143/// use subx_cli::cli::ui::print_warning;
144///
145/// print_warning("Legacy subtitle format detected, consider upgrading");
146/// print_warning("AI confidence below 80%, manual review recommended");
147/// print_warning("Configuration file not found, using defaults");
148/// ```
149///
150/// # Output Examples
151/// ```text
152/// ⚠ Legacy subtitle format detected, consider upgrading
153/// ⚠ AI confidence below 80%, manual review recommended
154/// ⚠ Configuration file not found, using defaults
155/// ```
156pub fn print_warning(message: &str) {
157 if output::active_mode().is_json() || output::is_quiet() {
158 return;
159 }
160 println!("{} {}", "⚠".yellow().bold(), message);
161}
162
163/// Create a progress bar with consistent styling and configuration.
164///
165/// Creates a progress bar with customized styling that respects user
166/// configuration preferences. The progress bar can be hidden based on
167/// the `enable_progress_bar` configuration setting, allowing users to
168/// disable progress indicators if desired.
169///
170/// # Configuration Integration
171///
172/// The progress bar visibility is controlled by the configuration setting:
173/// ```toml
174/// [general]
175/// enable_progress_bar = true # Show progress bars
176/// # or
177/// enable_progress_bar = false # Hide progress bars
178/// ```
179///
180/// # Progress Bar Features
181///
182/// - **Animated spinner**: Indicates ongoing activity
183/// - **Elapsed time**: Shows time since operation started
184/// - **Progress bar**: Visual representation of completion percentage
185/// - **ETA estimation**: Estimated time to completion
186/// - **Current/total counts**: Numeric progress indicator
187///
188/// # Template Format
189/// ```text
190/// ⠋ [00:01:23] [████████████████████████████████████████] 75/100 (00:00:17)
191/// ```
192///
193/// # Arguments
194///
195/// * `total` - The total number of items to be processed
196///
197/// # Returns
198///
199/// A configured `ProgressBar` instance ready for use
200///
201/// # Examples
202///
203/// ```rust
204/// use subx_cli::cli::ui::create_progress_bar;
205///
206/// // Create progress bar for 100 items
207/// let progress = create_progress_bar(100);
208///
209/// for i in 0..100 {
210/// // ... process item ...
211/// progress.inc(1);
212///
213/// if i % 10 == 0 {
214/// progress.set_message(format!("Processing item {}", i));
215/// }
216/// }
217///
218/// progress.finish_with_message("✓ All items processed successfully");
219/// ```
220///
221/// # Error Handling
222///
223/// If configuration loading fails, the progress bar will default to visible.
224/// This ensures that progress indication is available even when configuration
225/// is problematic.
226pub fn create_progress_bar(total: u64) -> ProgressBar {
227 let pb = ProgressBar::new(total);
228 pb.set_draw_target(progress_draw_target_for(output::active_mode()));
229 pb.set_style(
230 ProgressStyle::default_bar()
231 .template(
232 "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})",
233 )
234 .unwrap(),
235 );
236 pb
237}
238
239/// Resolve the progress-bar draw target for the active output mode.
240///
241/// Per the `progress-reporting` spec, every `indicatif::ProgressBar`
242/// constructed by SubX SHALL obtain its `ProgressDrawTarget` from this
243/// helper so JSON mode force-hides progress frames regardless of
244/// `general.enable_progress_bar`.
245pub fn progress_draw_target_for(mode: OutputMode) -> ProgressDrawTarget {
246 if mode.is_json() {
247 ProgressDrawTarget::hidden()
248 } else {
249 ProgressDrawTarget::stderr()
250 }
251}
252
253/// Display comprehensive AI API usage statistics and cost information.
254///
255/// Presents detailed information about AI API calls including token usage,
256/// model information, and cost implications. This helps users understand
257/// their AI service consumption and manage usage costs effectively.
258///
259/// # Information Displayed
260///
261/// - **Model Name**: The specific AI model used for processing
262/// - **Token Breakdown**: Detailed token usage by category
263/// - Prompt tokens: Input text sent to the AI
264/// - Completion tokens: AI-generated response text
265/// - Total tokens: Sum of prompt and completion tokens
266/// - **Cost Implications**: Helps users understand billing impact
267///
268/// # Format Example
269/// ```text
270/// 🤖 AI API Call Details:
271/// Model: gpt-4-turbo-preview
272/// Prompt tokens: 1,247
273/// Completion tokens: 892
274/// Total tokens: 2,139
275/// ```
276///
277/// # Arguments
278///
279/// * `usage` - AI usage statistics containing token counts and model information
280///
281/// # Examples
282///
283/// ```rust
284/// use subx_cli::cli::ui::display_ai_usage;
285/// use subx_cli::services::ai::AiUsageStats;
286///
287/// let usage = AiUsageStats {
288/// model: "gpt-4-turbo-preview".to_string(),
289/// prompt_tokens: 1247,
290/// completion_tokens: 892,
291/// total_tokens: 2139,
292/// };
293///
294/// display_ai_usage(&usage);
295/// ```
296///
297/// # Use Cases
298///
299/// - **Cost monitoring**: Track API usage for billing awareness
300/// - **Performance analysis**: Understand token efficiency
301/// - **Debugging**: Verify expected model usage
302/// - **Optimization**: Identify opportunities to reduce token consumption
303pub fn display_ai_usage(usage: &crate::services::ai::AiUsageStats) {
304 if output::active_mode().is_json() {
305 return;
306 }
307 println!("🤖 AI API Call Details:");
308 println!(" Model: {}", usage.model);
309 println!(" Prompt tokens: {}", usage.prompt_tokens);
310 println!(" Completion tokens: {}", usage.completion_tokens);
311 println!(" Total tokens: {}", usage.total_tokens);
312 println!();
313}
314
315/// Display file matching results with support for dry-run preview mode.
316pub fn display_match_results(results: &[MatchOperation], is_dry_run: bool) {
317 // The match table is suppressed in JSON mode — the command's
318 // structured payload covers the same information.
319 if output::active_mode().is_json() {
320 return;
321 }
322 if results.is_empty() {
323 println!("{}", "No matching file pairs found".yellow());
324 return;
325 }
326
327 println!("\n{}", "📋 File Matching Results".bold().blue());
328 if is_dry_run {
329 println!(
330 "{}",
331 "🔍 Preview mode (files will not be modified)".yellow()
332 );
333 }
334 println!();
335
336 // Split each match result into multiple lines: video, subtitle, new name, and optionally relocation
337 let rows: Vec<MatchDisplayRow> = results
338 .iter()
339 .enumerate()
340 .flat_map(|(i, op)| {
341 let idx = i + 1;
342 let video = op.video_file.path.to_string_lossy();
343 let subtitle = op.subtitle_file.path.to_string_lossy();
344 let new_name = &op.new_subtitle_name;
345
346 // Add status symbol and tree structure
347 let status_symbol = if is_dry_run { "🔍" } else { "✓" };
348
349 let mut rows = vec![
350 MatchDisplayRow {
351 file_type: format!("{status_symbol} Video {idx}"),
352 file_path: video.to_string(),
353 },
354 MatchDisplayRow {
355 file_type: format!("├ Subtitle {idx}"),
356 file_path: subtitle.to_string(),
357 },
358 MatchDisplayRow {
359 file_type: format!("├ New name {idx}"),
360 file_path: new_name.clone(),
361 },
362 ];
363
364 // Add relocation operation row if needed
365 if op.requires_relocation {
366 let operation_icon = match op.relocation_mode {
367 crate::core::matcher::engine::FileRelocationMode::Copy => "📄",
368 crate::core::matcher::engine::FileRelocationMode::Move => "📁",
369 _ => "",
370 };
371
372 let operation_verb = match op.relocation_mode {
373 crate::core::matcher::engine::FileRelocationMode::Copy => "Copy to",
374 crate::core::matcher::engine::FileRelocationMode::Move => "Move to",
375 _ => "",
376 };
377
378 if let Some(target_path) = &op.relocation_target_path {
379 rows.push(MatchDisplayRow {
380 file_type: format!("└ {operation_icon} {operation_verb}"),
381 file_path: target_path.to_string_lossy().to_string(),
382 });
383 } else {
384 // Update the last row to have the proper tree ending
385 if let Some(last_row) = rows.last_mut() {
386 last_row.file_type = last_row.file_type.replace("├", "└");
387 }
388 }
389 } else {
390 // Update the last row to have the proper tree ending
391 if let Some(last_row) = rows.last_mut() {
392 last_row.file_type = last_row.file_type.replace("├", "└");
393 }
394 }
395
396 rows
397 })
398 .collect();
399
400 println!("{}", create_match_table(rows));
401
402 println!(
403 "\n{}",
404 format!("Total processed {} file mappings", results.len()).bold()
405 );
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn test_match_table_display() {
414 let rows = vec![
415 MatchDisplayRow {
416 file_type: "✓ Video 1".to_string(),
417 file_path: "movie1.mp4".to_string(),
418 },
419 MatchDisplayRow {
420 file_type: "├ Subtitle 1".to_string(),
421 file_path: "subtitle1.srt".to_string(),
422 },
423 MatchDisplayRow {
424 file_type: "└ New name 1".to_string(),
425 file_path: "movie1.srt".to_string(),
426 },
427 ];
428 let table = create_match_table(rows);
429 assert!(table.contains("✓ Video 1"));
430 assert!(table.contains("movie1.mp4"));
431 assert!(table.contains("├ Subtitle 1"));
432 assert!(table.contains("subtitle1.srt"));
433 assert!(table.contains("└ New name 1"));
434 assert!(table.contains("movie1.srt"));
435 }
436}