Skip to main content

es_fluent_cli/utils/
ui.rs

1// CLI output formatting with consistent styling using indicatif and colored.
2// We stick to standard println!/eprintln! for textual output to ensure ANSI color compatibility.
3
4use crate::core::CrateInfo;
5use colored::Colorize as _;
6use indicatif::{ProgressBar, ProgressStyle};
7use std::io::IsTerminal as _;
8use std::path::Path;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::time::Duration;
11
12const PD_TICK: Duration = Duration::from_millis(100);
13
14static E2E_MODE: AtomicBool = AtomicBool::new(false);
15
16/// Enable E2E mode for deterministic output (no colors, fixed durations, hidden progress bars).
17pub fn set_e2e_mode(enabled: bool) {
18    E2E_MODE.store(enabled, Ordering::SeqCst);
19    if enabled {
20        colored::control::set_override(false);
21    }
22}
23
24pub fn is_e2e() -> bool {
25    E2E_MODE.load(Ordering::SeqCst)
26}
27
28/// Whether terminal hyperlinks should be emitted.
29pub fn terminal_links_enabled() -> bool {
30    if let Ok(force) = std::env::var("FORCE_HYPERLINK") {
31        return force.trim() != "0";
32    }
33    if is_e2e() {
34        return false;
35    }
36    if std::env::var("NO_COLOR").is_ok() {
37        return false;
38    }
39    if std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok() {
40        return false;
41    }
42    std::io::stderr().is_terminal()
43}
44
45fn format_duration(duration: Duration) -> String {
46    if is_e2e() {
47        "[DURATION]".to_string()
48    } else {
49        humantime::format_duration(duration).to_string()
50    }
51}
52
53pub fn create_spinner(msg: &str) -> ProgressBar {
54    if is_e2e() {
55        return ProgressBar::hidden();
56    }
57    let pb = ProgressBar::new_spinner();
58    pb.set_style(
59        ProgressStyle::default_spinner()
60            .template("{spinner:.green} {msg}")
61            .unwrap()
62            .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
63    );
64    pb.set_message(msg.to_string());
65    pb.enable_steady_tick(PD_TICK);
66    pb
67}
68
69pub fn create_progress_bar(len: u64, msg: &str) -> ProgressBar {
70    if is_e2e() {
71        return ProgressBar::hidden();
72    }
73    let pb = ProgressBar::new(len);
74    pb.set_style(
75        ProgressStyle::default_bar()
76            .template("{spinner:.green} {msg} [{bar:40.cyan/blue}] {pos}/{len}")
77            .unwrap()
78            .progress_chars("#>-"),
79    );
80    pb.set_message(msg.to_string());
81    pb.enable_steady_tick(PD_TICK);
82    pb
83}
84
85// Deprecated/Legacy output helpers - redirected to println/eprintln to preserve formatting
86// Tracing proved problematic for raw ANSI passthrough in some environments or configs.
87
88pub fn print_header() {
89    println!("{}", "Fluent FTL Generator".dimmed());
90}
91
92pub fn print_discovered(crates: &[CrateInfo]) {
93    if crates.is_empty() {
94        eprintln!("{}", "No crates with i18n.toml found.".red());
95    } else {
96        println!(
97            "{} {}",
98            "Discovered".dimmed(),
99            format!("{} crate(s)", crates.len()).green()
100        );
101    }
102}
103
104pub fn print_missing_lib_rs(crate_name: &str) {
105    println!(
106        "{} {}",
107        "Skipping".dimmed(),
108        format!("{} (missing lib.rs)", crate_name).yellow()
109    );
110}
111
112// Action-specific printers
113
114pub fn print_generating(crate_name: &str) {
115    println!("{} {}", "Generating FTL for".dimmed(), crate_name.green());
116}
117
118pub fn print_generated(crate_name: &str, duration: Duration, resource_count: usize) {
119    println!(
120        "{} {} ({} resources)",
121        format!("{} generated in", crate_name).dimmed(),
122        format_duration(duration).green(),
123        resource_count.to_string().cyan()
124    );
125}
126
127pub fn print_cleaning(crate_name: &str) {
128    println!("{} {}", "Cleaning FTL for".dimmed(), crate_name.green());
129}
130
131pub fn print_cleaned(crate_name: &str, duration: Duration, resource_count: usize) {
132    println!(
133        "{} {} ({} resources)",
134        format!("{} cleaned in", crate_name).dimmed(),
135        format_duration(duration).green(),
136        resource_count.to_string().cyan()
137    );
138}
139
140pub fn print_generation_error(crate_name: &str, error: &str) {
141    eprintln!(
142        "{} {}: {}",
143        "Generation failed for".red(),
144        crate_name.white().bold(),
145        error
146    );
147}
148
149pub fn print_package_not_found(package: &str) {
150    println!(
151        "{} '{}'",
152        "No crate found matching package filter:".yellow(),
153        package.white().bold()
154    );
155}
156
157pub fn print_check_header() {
158    println!("{}", "Fluent FTL Checker".dimmed());
159}
160
161pub fn print_checking(crate_name: &str) {
162    println!("{} {}", "Checking".dimmed(), crate_name.green());
163}
164
165pub fn print_check_error(crate_name: &str, error: &str) {
166    eprintln!(
167        "{} {}: {}",
168        "Check failed for".red(),
169        crate_name.white().bold(),
170        error
171    );
172}
173
174pub fn print_check_success() {
175    println!("{}", "No issues found!".green());
176}
177
178pub fn print_format_header() {
179    println!("{}", "Fluent FTL Formatter".dimmed());
180}
181
182pub fn print_would_format(path: &Path) {
183    println!("{} {}", "Would format:".yellow(), path.display());
184}
185
186pub fn print_formatted(path: &Path) {
187    println!("{} {}", "Formatted:".green(), path.display());
188}
189
190pub fn print_format_dry_run_summary(count: usize) {
191    println!(
192        "{} {} file(s) would be formatted",
193        "Dry run:".yellow(),
194        count
195    );
196}
197
198pub fn print_format_summary(formatted: usize, unchanged: usize) {
199    println!(
200        "{} {} formatted, {} unchanged",
201        "Done:".green(),
202        formatted,
203        unchanged
204    );
205}
206
207pub fn print_sync_header() {
208    println!("{}", "Fluent FTL Sync".dimmed());
209}
210
211pub fn print_syncing(crate_name: &str) {
212    println!("{} {}", "Syncing".dimmed(), crate_name.green());
213}
214
215pub fn print_would_add_keys(count: usize, locale: &str, crate_name: &str) {
216    println!(
217        "{} {} key(s) to {} ({})",
218        "Would add".yellow(),
219        count,
220        locale.cyan(),
221        crate_name.bold()
222    );
223}
224
225pub fn print_added_keys(count: usize, locale: &str) {
226    println!("{} {} key(s) to {}", "Added".green(), count, locale.cyan());
227}
228
229pub fn print_synced_key(key: &str) {
230    println!("  {} {}", "->".dimmed(), key);
231}
232
233pub fn print_all_in_sync() {
234    println!("{}", "All locales are in sync!".green());
235}
236
237pub fn print_sync_dry_run_summary(keys: usize, locales: usize) {
238    println!(
239        "{} {} key(s) across {} locale(s)",
240        "Would sync".yellow(),
241        keys,
242        locales
243    );
244}
245
246pub fn print_sync_summary(keys: usize, locales: usize) {
247    println!(
248        "{} {} key(s) synced to {} locale(s)",
249        "Done:".green(),
250        keys,
251        locales
252    );
253}
254
255pub fn print_no_locales_specified() {
256    println!(
257        "{}",
258        "No locales specified. Use --locale <LOCALE> or --all".yellow()
259    );
260}
261
262pub fn print_no_crates_found() {
263    eprintln!("{}", "No crates with i18n.toml found.".red());
264}
265
266pub fn print_locale_not_found(locale: &str, available: &[String]) {
267    let available_str = if available.is_empty() {
268        "none".to_string()
269    } else {
270        available.join(", ")
271    };
272    eprintln!(
273        "{} '{}'. Available locales: {}",
274        "Locale not found:".red(),
275        locale.white().bold(),
276        available_str.cyan()
277    );
278}
279
280pub fn print_diff(old: &str, new: &str) {
281    // If e2e mode, just print a marker or simplified diff to avoid colored crate dependency affecting things
282    // But we still want to see the diff content.
283    // Use the existing logic but colors will be suppressed by `colored::control::set_override(false)`.
284
285    use similar::{ChangeTag, TextDiff};
286
287    let diff = TextDiff::from_lines(old, new);
288
289    for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
290        if idx > 0 {
291            println!("{}", "  ...".dimmed());
292        }
293        for op in group {
294            for change in diff.iter_changes(op) {
295                let sign = match change.tag() {
296                    ChangeTag::Delete => "-",
297                    ChangeTag::Insert => "+",
298                    ChangeTag::Equal => " ",
299                };
300                let line = format!("{} {}", sign, change);
301                match change.tag() {
302                    ChangeTag::Delete => print!("{}", line.red()),
303                    ChangeTag::Insert => print!("{}", line.green()),
304                    ChangeTag::Equal => print!("{}", line.dimmed()),
305                }
306            }
307        }
308    }
309}