Skip to main content

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}