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);
13const FRIENDLY_DURATION_PRINTER: jiff::fmt::friendly::SpanPrinter =
14    jiff::fmt::friendly::SpanPrinter::new();
15
16static E2E_MODE: AtomicBool = AtomicBool::new(false);
17
18/// Enable E2E mode for deterministic output (no colors, fixed durations, hidden progress bars).
19pub fn set_e2e_mode(enabled: bool) {
20    E2E_MODE.store(enabled, Ordering::SeqCst);
21    if enabled {
22        colored::control::set_override(false);
23    }
24}
25
26pub fn is_e2e() -> bool {
27    E2E_MODE.load(Ordering::SeqCst)
28}
29
30/// Whether terminal hyperlinks should be emitted.
31pub fn terminal_links_enabled() -> bool {
32    if let Ok(force) = std::env::var("FORCE_HYPERLINK") {
33        return force.trim() != "0";
34    }
35    if is_e2e() {
36        return false;
37    }
38    if std::env::var("NO_COLOR").is_ok() {
39        return false;
40    }
41    if std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok() {
42        return false;
43    }
44    std::io::stderr().is_terminal()
45}
46
47pub(crate) fn format_duration(duration: Duration) -> String {
48    if is_e2e() {
49        "[DURATION]".to_string()
50    } else {
51        FRIENDLY_DURATION_PRINTER.unsigned_duration_to_string(&duration)
52    }
53}
54
55pub fn create_spinner(msg: &str) -> ProgressBar {
56    if is_e2e() {
57        return ProgressBar::hidden();
58    }
59    let pb = ProgressBar::new_spinner();
60    pb.set_style(
61        ProgressStyle::default_spinner()
62            .template("{spinner:.green} {msg}")
63            .unwrap()
64            .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
65    );
66    pb.set_message(msg.to_string());
67    pb.enable_steady_tick(PD_TICK);
68    pb
69}
70
71pub fn create_progress_bar(len: u64, msg: &str) -> ProgressBar {
72    if is_e2e() {
73        return ProgressBar::hidden();
74    }
75    let pb = ProgressBar::new(len);
76    pb.set_style(
77        ProgressStyle::default_bar()
78            .template("{spinner:.green} {msg} [{bar:40.cyan/blue}] {pos}/{len}")
79            .unwrap()
80            .progress_chars("#>-"),
81    );
82    pb.set_message(msg.to_string());
83    pb.enable_steady_tick(PD_TICK);
84    pb
85}
86
87// Deprecated/Legacy output helpers - redirected to println/eprintln to preserve formatting
88// Tracing proved problematic for raw ANSI passthrough in some environments or configs.
89
90pub fn print_header() {
91    println!("{}", "Fluent FTL Generator".dimmed());
92}
93
94pub fn print_discovered(crates: &[CrateInfo]) {
95    if crates.is_empty() {
96        eprintln!("{}", "No crates with i18n.toml found.".red());
97    } else {
98        println!(
99            "{} {}",
100            "Discovered".dimmed(),
101            format!("{} crate(s)", crates.len()).green()
102        );
103    }
104}
105
106pub fn print_missing_lib_rs(crate_name: &str) {
107    println!(
108        "{} {}",
109        "Skipping".dimmed(),
110        format!("{} (missing lib.rs)", crate_name).yellow()
111    );
112}
113
114// Action-specific printers
115
116pub fn print_generating(crate_name: &str) {
117    println!("{} {}", "Generating FTL for".dimmed(), crate_name.green());
118}
119
120pub fn print_generated(crate_name: &str, duration: Duration, resource_count: usize) {
121    println!(
122        "{} {} ({} resources)",
123        format!("{} generated in", crate_name).dimmed(),
124        format_duration(duration).green(),
125        resource_count.to_string().cyan()
126    );
127}
128
129pub fn print_cleaning(crate_name: &str) {
130    println!("{} {}", "Cleaning FTL for".dimmed(), crate_name.green());
131}
132
133pub fn print_cleaned(crate_name: &str, duration: Duration, resource_count: usize) {
134    println!(
135        "{} {} ({} resources)",
136        format!("{} cleaned in", crate_name).dimmed(),
137        format_duration(duration).green(),
138        resource_count.to_string().cyan()
139    );
140}
141
142pub fn print_generation_error(crate_name: &str, error: &str) {
143    eprintln!(
144        "{} {}: {}",
145        "Generation failed for".red(),
146        crate_name.white().bold(),
147        error
148    );
149}
150
151pub fn print_package_not_found(package: &str) {
152    println!(
153        "{} '{}'",
154        "No crate found matching package filter:".yellow(),
155        package.white().bold()
156    );
157}
158
159pub fn print_check_header() {
160    println!("{}", "Fluent FTL Checker".dimmed());
161}
162
163pub fn print_checking(crate_name: &str) {
164    println!("{} {}", "Checking".dimmed(), crate_name.green());
165}
166
167pub fn print_check_error(crate_name: &str, error: &str) {
168    eprintln!(
169        "{} {}: {}",
170        "Check failed for".red(),
171        crate_name.white().bold(),
172        error
173    );
174}
175
176pub fn print_check_success() {
177    println!("{}", "No issues found!".green());
178}
179
180pub fn print_format_header() {
181    println!("{}", "Fluent FTL Formatter".dimmed());
182}
183
184pub fn print_tree_header() {
185    println!("{}", "Fluent FTL Tree".dimmed());
186}
187
188pub fn print_would_format(path: &Path) {
189    println!("{} {}", "Would format:".yellow(), path.display());
190}
191
192pub fn print_formatted(path: &Path) {
193    println!("{} {}", "Formatted:".green(), path.display());
194}
195
196pub fn print_format_dry_run_summary(count: usize) {
197    println!(
198        "{} {} file(s) would be formatted",
199        "Dry run:".yellow(),
200        count
201    );
202}
203
204pub fn print_format_summary(formatted: usize, unchanged: usize) {
205    println!(
206        "{} {} formatted, {} unchanged",
207        "Done:".green(),
208        formatted,
209        unchanged
210    );
211}
212
213pub fn print_sync_header() {
214    println!("{}", "Fluent FTL Sync".dimmed());
215}
216
217pub fn print_syncing(crate_name: &str) {
218    println!("{} {}", "Syncing".dimmed(), crate_name.green());
219}
220
221pub fn print_would_add_keys(count: usize, locale: &str, crate_name: &str) {
222    println!(
223        "{} {} key(s) to {} ({})",
224        "Would add".yellow(),
225        count,
226        locale.cyan(),
227        crate_name.bold()
228    );
229}
230
231pub fn print_added_keys(count: usize, locale: &str) {
232    println!("{} {} key(s) to {}", "Added".green(), count, locale.cyan());
233}
234
235pub fn print_synced_key(key: &str) {
236    println!("  {} {}", "->".dimmed(), key);
237}
238
239pub fn print_all_in_sync() {
240    println!("{}", "All locales are in sync!".green());
241}
242
243pub fn print_sync_dry_run_summary(keys: usize, locales: usize) {
244    println!(
245        "{} {} key(s) across {} locale(s)",
246        "Would sync".yellow(),
247        keys,
248        locales
249    );
250}
251
252pub fn print_sync_summary(keys: usize, locales: usize) {
253    println!(
254        "{} {} key(s) synced to {} locale(s)",
255        "Done:".green(),
256        keys,
257        locales
258    );
259}
260
261pub fn print_no_locales_specified() {
262    println!(
263        "{}",
264        "No locales specified. Use --locale <LOCALE> or --all".yellow()
265    );
266}
267
268pub fn print_no_crates_found() {
269    eprintln!("{}", "No crates with i18n.toml found.".red());
270}
271
272pub fn print_locale_not_found(locale: &str, available: &[String]) {
273    let available_str = if available.is_empty() {
274        "none".to_string()
275    } else {
276        available.join(", ")
277    };
278    eprintln!(
279        "{} '{}'. Available locales: {}",
280        "Locale not found:".red(),
281        locale.white().bold(),
282        available_str.cyan()
283    );
284}
285
286pub fn print_diff(old: &str, new: &str) {
287    // If e2e mode, just print a marker or simplified diff to avoid colored crate dependency affecting things
288    // But we still want to see the diff content.
289    // Use the existing logic but colors will be suppressed by `colored::control::set_override(false)`.
290
291    use similar::{ChangeTag, TextDiff};
292
293    let diff = TextDiff::from_lines(old, new);
294
295    for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
296        if idx > 0 {
297            println!("{}", "  ...".dimmed());
298        }
299        for op in group {
300            for change in diff.iter_changes(op) {
301                let sign = match change.tag() {
302                    ChangeTag::Delete => "-",
303                    ChangeTag::Insert => "+",
304                    ChangeTag::Equal => " ",
305                };
306                let line = format!("{} {}", sign, change);
307                match change.tag() {
308                    ChangeTag::Delete => print!("{}", line.red()),
309                    ChangeTag::Insert => print!("{}", line.green()),
310                    ChangeTag::Equal => print!("{}", line.dimmed()),
311                }
312            }
313        }
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use std::sync::{Mutex, OnceLock};
321
322    fn env_lock() -> &'static Mutex<()> {
323        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
324        LOCK.get_or_init(|| Mutex::new(()))
325    }
326
327    fn test_crate(name: &str) -> CrateInfo {
328        CrateInfo {
329            name: name.to_string(),
330            manifest_dir: std::path::PathBuf::from("/tmp/test"),
331            src_dir: std::path::PathBuf::from("/tmp/test/src"),
332            i18n_config_path: std::path::PathBuf::from("/tmp/test/i18n.toml"),
333            ftl_output_dir: std::path::PathBuf::from("/tmp/test/i18n/en"),
334            has_lib_rs: true,
335            fluent_features: Vec::new(),
336        }
337    }
338
339    #[test]
340    fn terminal_links_enabled_honors_env_and_modes() {
341        let _guard = env_lock().lock().unwrap();
342
343        set_e2e_mode(false);
344        // SAFETY: We serialize env var mutations with a global mutex.
345        unsafe {
346            std::env::remove_var("FORCE_HYPERLINK");
347            std::env::remove_var("NO_COLOR");
348            std::env::remove_var("CI");
349            std::env::remove_var("GITHUB_ACTIONS");
350            std::env::set_var("FORCE_HYPERLINK", "1");
351        }
352        assert!(terminal_links_enabled());
353
354        // SAFETY: Protected by the same global mutex.
355        unsafe {
356            std::env::set_var("FORCE_HYPERLINK", "0");
357        }
358        assert!(!terminal_links_enabled());
359
360        // SAFETY: Protected by the same global mutex.
361        unsafe {
362            std::env::remove_var("FORCE_HYPERLINK");
363            std::env::set_var("NO_COLOR", "1");
364        }
365        assert!(!terminal_links_enabled());
366
367        // SAFETY: Protected by the same global mutex.
368        unsafe {
369            std::env::remove_var("NO_COLOR");
370            std::env::set_var("CI", "1");
371        }
372        assert!(!terminal_links_enabled());
373
374        // SAFETY: Protected by the same global mutex.
375        unsafe {
376            std::env::remove_var("CI");
377        }
378        set_e2e_mode(true);
379        assert!(!terminal_links_enabled());
380
381        set_e2e_mode(false);
382    }
383
384    #[test]
385    fn duration_and_progress_helpers_cover_e2e_and_default_modes() {
386        set_e2e_mode(true);
387        assert_eq!(format_duration(Duration::from_millis(5)), "[DURATION]");
388        assert!(create_spinner("spin").is_hidden());
389        assert!(create_progress_bar(3, "progress").is_hidden());
390
391        set_e2e_mode(false);
392        let formatted = format_duration(Duration::from_millis(5));
393        assert!(!formatted.is_empty());
394
395        let spinner = create_spinner("spin");
396        spinner.finish_and_clear();
397
398        let pb = create_progress_bar(3, "progress");
399        pb.finish_and_clear();
400    }
401
402    #[test]
403    fn terminal_links_enabled_falls_back_to_terminal_probe_branch() {
404        let _guard = env_lock().lock().unwrap();
405        set_e2e_mode(false);
406        // SAFETY: Protected by env_lock mutex.
407        unsafe {
408            std::env::remove_var("FORCE_HYPERLINK");
409            std::env::remove_var("NO_COLOR");
410            std::env::remove_var("CI");
411            std::env::remove_var("GITHUB_ACTIONS");
412        }
413
414        // Environment-dependent; the assertion is that the code path executes without panicking.
415        let _ = terminal_links_enabled();
416    }
417
418    #[test]
419    fn print_helpers_do_not_panic() {
420        let crates = vec![test_crate("crate-a"), test_crate("crate-b")];
421
422        print_header();
423        print_discovered(&crates);
424        print_discovered(&[]);
425        print_missing_lib_rs("crate-missing");
426        print_generating("crate-a");
427        print_generated("crate-a", Duration::from_millis(1), 2);
428        print_cleaning("crate-a");
429        print_cleaned("crate-a", Duration::from_millis(1), 2);
430        print_generation_error("crate-a", "boom");
431        print_package_not_found("crate-z");
432
433        print_check_header();
434        print_checking("crate-a");
435        print_check_error("crate-a", "bad check");
436        print_check_success();
437
438        print_format_header();
439        print_tree_header();
440        print_would_format(Path::new("i18n/en/test.ftl"));
441        print_formatted(Path::new("i18n/en/test.ftl"));
442        print_format_dry_run_summary(1);
443        print_format_summary(2, 3);
444
445        print_sync_header();
446        print_syncing("crate-a");
447        print_would_add_keys(2, "es", "crate-a");
448        print_added_keys(2, "es");
449        print_synced_key("hello_world");
450        print_all_in_sync();
451        print_sync_dry_run_summary(3, 2);
452        print_sync_summary(3, 2);
453        print_no_locales_specified();
454        print_no_crates_found();
455        print_locale_not_found("zz", &["en".to_string(), "es".to_string()]);
456        print_locale_not_found("zz", &[]);
457
458        print_diff("a = 1\nb = 2\n", "a = 1\nc = 3\n");
459        print_diff(
460            "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\n",
461            "l1\nx2\nl3\nl4\nl5\nl6\nl7\nx8\nl9\nl10\n",
462        );
463    }
464}