Skip to main content

uucore/mods/
clap_localization.rs

1// This file is part of the uutils coreutils package.
2//
3// For the full copyright and license information, please view the LICENSE
4// file that was distributed with this source code.
5// spell-checker:ignore (path) osrelease myutil
6
7//! Helper clap functions to localize error handling and options
8//!
9//! This module provides utilities for handling clap errors with localization support.
10//! It uses clap's error context API to extract structured information from errors
11//! instead of parsing error strings, providing a more robust solution.
12//!
13
14use crate::error::{UResult, USimpleError};
15use crate::locale::translate;
16
17use clap::error::{ContextKind, ErrorKind};
18use clap::{ArgMatches, Command, Error};
19
20use std::error::Error as StdError;
21use std::ffi::OsString;
22
23use std::io::Write as _;
24use std::io::stderr;
25
26/// Color enum for consistent styling
27#[derive(Debug, Clone, Copy)]
28pub enum Color {
29    Red,
30    Yellow,
31    Green,
32}
33
34impl Color {
35    fn code(self) -> &'static str {
36        match self {
37            Self::Red => "31",
38            Self::Yellow => "33",
39            Self::Green => "32",
40        }
41    }
42}
43
44/// Determine color choice based on environment variables
45fn get_color_choice() -> clap::ColorChoice {
46    if std::env::var("NO_COLOR").is_ok() {
47        clap::ColorChoice::Never
48    } else if std::env::var("CLICOLOR_FORCE").is_ok() || std::env::var("FORCE_COLOR").is_ok() {
49        clap::ColorChoice::Always
50    } else {
51        clap::ColorChoice::Auto
52    }
53}
54
55/// Generic helper to check if colors should be enabled for a given stream
56fn should_use_color_for_stream<S: std::io::IsTerminal>(stream: &S) -> bool {
57    match get_color_choice() {
58        clap::ColorChoice::Always => true,
59        clap::ColorChoice::Never => false,
60        clap::ColorChoice::Auto => {
61            stream.is_terminal() && std::env::var("TERM").unwrap_or_default() != "dumb"
62        }
63    }
64}
65
66/// Manages color output based on environment settings
67struct ColorManager(bool);
68
69impl ColorManager {
70    /// Create a new ColorManager based on environment variables
71    fn from_env() -> Self {
72        Self(should_use_color_for_stream(&stderr()))
73    }
74
75    /// Apply color to text if colors are enabled
76    fn colorize(&self, text: &str, color: Color) -> String {
77        if self.0 {
78            format!("\x1b[{}m{text}\x1b[0m", color.code())
79        } else {
80            text.to_string()
81        }
82    }
83}
84
85/// Unified error formatter that handles all error types consistently
86pub struct ErrorFormatter<'a> {
87    color_mgr: ColorManager,
88    util_name: &'a str,
89}
90
91impl<'a> ErrorFormatter<'a> {
92    pub fn new(util_name: &'a str) -> Self {
93        Self {
94            color_mgr: ColorManager::from_env(),
95            util_name,
96        }
97    }
98
99    /// Print error and exit with the specified code
100    fn print_error_and_exit(&self, err: &Error, exit_code: i32) -> ! {
101        self.print_error_and_exit_with_callback(err, exit_code, || {})
102    }
103
104    /// Print error with optional callback before exit
105    pub fn print_error_and_exit_with_callback<F>(
106        &self,
107        err: &Error,
108        exit_code: i32,
109        callback: F,
110    ) -> !
111    where
112        F: FnOnce(),
113    {
114        let code = self.print_error(err, exit_code);
115        callback();
116        std::process::exit(code);
117    }
118
119    /// Print error and return exit code (no exit call)
120    pub fn print_error(&self, err: &Error, exit_code: i32) -> i32 {
121        match err.kind() {
122            ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => self.handle_display_errors(err),
123            ErrorKind::UnknownArgument => self.handle_unknown_argument(err, exit_code),
124            ErrorKind::InvalidValue | ErrorKind::ValueValidation => {
125                self.handle_invalid_value(err, exit_code)
126            }
127            ErrorKind::MissingRequiredArgument => self.handle_missing_required(err, exit_code),
128            ErrorKind::TooFewValues | ErrorKind::TooManyValues | ErrorKind::WrongNumberOfValues => {
129                // These need full clap formatting
130                let _ = write!(stderr(), "{}", err.render());
131                exit_code
132            }
133            _ => self.handle_generic_error(err, exit_code),
134        }
135    }
136
137    /// Handle help and version display
138    fn handle_display_errors(&self, err: &Error) -> i32 {
139        print!("{}", err.render());
140        0
141    }
142
143    /// Handle unknown argument errors
144    fn handle_unknown_argument(&self, err: &Error, exit_code: i32) -> i32 {
145        if let Some(invalid_arg) = err.get(ContextKind::InvalidArg) {
146            let arg_str = invalid_arg.to_string();
147            let error_word = translate!("common-error");
148
149            // Print main error
150            let _ = write!(
151                stderr(),
152                "{}\n\n",
153                translate!(
154                    "clap-error-unexpected-argument",
155                    "arg" => self.color_mgr.colorize(&arg_str, Color::Yellow),
156                    "error_word" => self.color_mgr.colorize(&error_word, Color::Red)
157                )
158            );
159
160            // Show suggestion if available
161            if let Some(suggested_arg) = err.get(ContextKind::SuggestedArg) {
162                let tip_word = translate!("common-tip");
163                let _ = write!(
164                    stderr(),
165                    "{}\n\n",
166                    translate!(
167                        "clap-error-similar-argument",
168                        "tip_word" => self.color_mgr.colorize(&tip_word, Color::Green),
169                        "suggestion" => self.color_mgr.colorize(&suggested_arg.to_string(), Color::Green)
170                    )
171                );
172            } else {
173                // Look for other tips from clap
174                self.print_clap_tips(err);
175            }
176
177            self.print_usage_and_help();
178        } else {
179            self.print_simple_error_msg(&translate!("clap-error-unexpected-argument-simple"));
180        }
181        exit_code
182    }
183
184    /// Handle invalid value errors
185    fn handle_invalid_value(&self, err: &Error, exit_code: i32) -> i32 {
186        let invalid_arg = err.get(ContextKind::InvalidArg);
187        let invalid_value = err.get(ContextKind::InvalidValue);
188
189        if let (Some(arg), Some(value)) = (invalid_arg, invalid_value) {
190            let option = arg.to_string();
191            let value = value.to_string();
192
193            if value.is_empty() {
194                // Value required but not provided
195                let error_word = translate!("common-error");
196                let _ = writeln!(
197                    stderr(),
198                    "{}",
199                    translate!("clap-error-value-required",
200                        "error_word" => self.color_mgr.colorize(&error_word, Color::Red),
201                        "option" => self.color_mgr.colorize(&option, Color::Green))
202                );
203            } else {
204                // Invalid value provided
205                let error_word = translate!("common-error");
206                let error_msg = translate!(
207                    "clap-error-invalid-value",
208                    "error_word" => self.color_mgr.colorize(&error_word, Color::Red),
209                    "value" => self.color_mgr.colorize(&value, Color::Yellow),
210                    "option" => self.color_mgr.colorize(&option, Color::Green)
211                );
212                // Include validation error if present
213                match err.source() {
214                    Some(source) if matches!(err.kind(), ErrorKind::ValueValidation) => {
215                        let _ = writeln!(stderr(), "{error_msg}: {source}");
216                    }
217                    _ => eprintln!("{error_msg}"),
218                }
219            }
220
221            // Show possible values for InvalidValue errors
222            if matches!(err.kind(), ErrorKind::InvalidValue) {
223                if let Some(valid_values) = err.get(ContextKind::ValidValue) {
224                    if !valid_values.to_string().is_empty() {
225                        let _ = writeln!(
226                            stderr(),
227                            "\n  [{}: {valid_values}]",
228                            translate!("clap-error-possible-values")
229                        );
230                    }
231                }
232            }
233            let _ = writeln!(stderr(), "\n{}", translate!("common-help-suggestion"));
234        } else {
235            self.print_simple_error_msg(&err.render().to_string());
236        }
237
238        // InvalidValue errors traditionally use exit code 1 for backward compatibility
239        // But if a utility explicitly requests a high exit code (>= 125), respect it
240        // This allows utilities like runcon (125) to override the default while preserving
241        // the standard behavior for utilities using normal error codes (1, 2, etc.)
242        if matches!(err.kind(), ErrorKind::InvalidValue) && exit_code < 125 {
243            1 // Force exit code 1 for InvalidValue unless using special exit codes
244        } else {
245            exit_code // Respect the requested exit code for special cases
246        }
247    }
248
249    /// Handle missing required argument errors
250    fn handle_missing_required(&self, err: &Error, exit_code: i32) -> i32 {
251        let rendered_str = err.render().to_string();
252        let lines: Vec<&str> = rendered_str.lines().collect();
253
254        match lines.first() {
255            Some(first_line)
256                if first_line
257                    .starts_with("error: the following required arguments were not provided:") =>
258            {
259                let error_word = translate!("common-error");
260                let _ = writeln!(
261                    stderr(),
262                    "{}",
263                    translate!(
264                        "clap-error-missing-required-arguments",
265                        "error_word" => self.color_mgr.colorize(&error_word, Color::Red)
266                    )
267                );
268
269                // Print the missing arguments
270                for line in lines.iter().skip(1) {
271                    if line.starts_with("  ") {
272                        let _ = writeln!(stderr(), "{line}");
273                    } else if line.starts_with("Usage:") || line.starts_with("For more information")
274                    {
275                        break;
276                    }
277                }
278                let _ = writeln!(stderr());
279
280                // Print usage
281                lines
282                    .iter()
283                    .skip_while(|line| !line.starts_with("Usage:"))
284                    .for_each(|line| {
285                        if line.starts_with("For more information, try '--help'.") {
286                            let _ = writeln!(stderr(), "{}", translate!("common-help-suggestion"));
287                        } else {
288                            let _ = writeln!(stderr(), "{line}");
289                        }
290                    });
291            }
292            _ => eprint!("{}", err.render()),
293        }
294        exit_code
295    }
296
297    /// Handle generic errors
298    fn handle_generic_error(&self, err: &Error, exit_code: i32) -> i32 {
299        let rendered_str = err.render().to_string();
300        if let Some(main_error_line) = rendered_str.lines().next() {
301            self.print_localized_error_line(main_error_line);
302            let _ = writeln!(stderr(), "\n{}", translate!("common-help-suggestion"));
303        } else {
304            let _ = write!(stderr(), "{}", err.render());
305        }
306        exit_code
307    }
308
309    /// Print a simple error message (no exit)
310    fn print_simple_error_msg(&self, message: &str) {
311        let error_word = translate!("common-error");
312        let _ = writeln!(
313            stderr(),
314            "{}: {message}",
315            self.color_mgr.colorize(&error_word, Color::Red)
316        );
317    }
318
319    /// Print error line with localized "error:" prefix
320    fn print_localized_error_line(&self, line: &str) {
321        let error_word = translate!("common-error");
322        let colored_error = self.color_mgr.colorize(&error_word, Color::Red);
323
324        if let Some(colon_pos) = line.find(':') {
325            let after_colon = &line[colon_pos..];
326            let _ = writeln!(stderr(), "{colored_error}{after_colon}");
327        } else {
328            let _ = writeln!(stderr(), "{line}");
329        }
330    }
331
332    /// Extract and print clap's built-in tips
333    fn print_clap_tips(&self, err: &Error) {
334        let rendered_str = err.render().to_string();
335        for line in rendered_str.lines() {
336            let trimmed = line.trim_start();
337            if trimmed.starts_with("tip:") && !line.contains("similar argument") {
338                let tip_word = translate!("common-tip");
339                if let Some(colon_pos) = trimmed.find(':') {
340                    let after_colon = &trimmed[colon_pos..];
341                    let _ = writeln!(
342                        stderr(),
343                        "  {}{after_colon}",
344                        self.color_mgr.colorize(&tip_word, Color::Green)
345                    );
346                } else {
347                    let _ = writeln!(stderr(), "{line}");
348                }
349                let _ = writeln!(stderr());
350            }
351        }
352    }
353
354    /// Print usage information and help suggestion
355    fn print_usage_and_help(&self) {
356        let usage_key = format!("{}-usage", self.util_name);
357        let usage_text = translate!(&usage_key);
358        let formatted_usage = crate::format_usage(&usage_text);
359        let usage_label = translate!("common-usage");
360        let _ = writeln!(
361            stderr(),
362            "{usage_label}: {formatted_usage}\n\n{}",
363            translate!("common-help-suggestion")
364        );
365    }
366}
367
368/// Handles clap command parsing results with proper localization support.
369///
370/// This is the main entry point for processing command-line arguments with localized error messages.
371/// It parses the provided arguments and returns either the parsed matches or handles errors with
372/// localized messages.
373///
374/// # Arguments
375///
376/// * `cmd` - The clap `Command` to parse arguments against
377/// * `itr` - An iterator of command-line arguments to parse
378///
379/// # Returns
380///
381/// * `Ok(ArgMatches)` - Successfully parsed command-line arguments
382/// * `Err` - For help/version display (preserves original styling)
383///
384/// # Examples
385///
386/// ```no_run
387/// use clap::Command;
388/// use uucore::clap_localization::handle_clap_result;
389///
390/// let cmd = Command::new("myutil");
391/// let args = vec!["myutil", "--help"];
392/// let result = handle_clap_result(cmd, args);
393/// ```
394pub fn handle_clap_result<I, T>(cmd: Command, itr: I) -> UResult<ArgMatches>
395where
396    I: IntoIterator<Item = T>,
397    T: Into<OsString> + Clone,
398{
399    handle_clap_result_with_exit_code(cmd, itr, 1)
400}
401
402/// Handles clap command parsing with a custom exit code for errors.
403///
404/// Similar to `handle_clap_result` but allows specifying a custom exit code
405/// for error conditions. This is useful for utilities that need specific
406/// exit codes for different error types.
407///
408/// # Arguments
409///
410/// * `cmd` - The clap `Command` to parse arguments against
411/// * `itr` - An iterator of command-line arguments to parse
412/// * `exit_code` - The exit code to use when exiting due to an error
413///
414/// # Returns
415///
416/// * `Ok(ArgMatches)` - Successfully parsed command-line arguments
417/// * `Err` - For help/version display (preserves original styling)
418///
419/// # Exit Behavior
420///
421/// This function will call `std::process::exit()` with the specified exit code
422/// when encountering parsing errors (except help/version which use exit code 0).
423///
424/// # Examples
425///
426/// ```no_run
427/// use clap::Command;
428/// use uucore::clap_localization::handle_clap_result_with_exit_code;
429///
430/// let cmd = Command::new("myutil");
431/// let args = vec!["myutil", "--invalid"];
432/// let result = handle_clap_result_with_exit_code(cmd, args, 125);
433/// ```
434pub fn handle_clap_result_with_exit_code<I, T>(
435    cmd: Command,
436    itr: I,
437    exit_code: i32,
438) -> UResult<ArgMatches>
439where
440    I: IntoIterator<Item = T>,
441    T: Into<OsString> + Clone,
442{
443    cmd.try_get_matches_from(itr).map_err(|e| {
444        if e.exit_code() == 0 {
445            e.into() // Preserve help/version
446        } else {
447            let formatter = ErrorFormatter::new(crate::util_name());
448            let code = formatter.print_error(&e, exit_code);
449            USimpleError::new(code, "")
450        }
451    })
452}
453
454/// Handles a clap error directly with a custom exit code.
455///
456/// This function processes a clap error and exits the program with the specified
457/// exit code. It formats error messages with proper localization and color support
458/// based on environment variables.
459///
460/// # Arguments
461///
462/// * `err` - The clap `Error` to handle
463/// * `exit_code` - The exit code to use when exiting
464///
465/// # Panics
466///
467/// This function never returns - it always calls `std::process::exit()`.
468///
469/// # Examples
470///
471/// ```no_run
472/// use clap::Command;
473/// use uucore::clap_localization::handle_clap_error_with_exit_code;
474///
475/// let cmd = Command::new("myutil");
476/// match cmd.try_get_matches() {
477///     Ok(matches) => { /* handle matches */ },
478///     Err(e) => handle_clap_error_with_exit_code(e, 1),
479/// }
480/// ```
481pub fn handle_clap_error_with_exit_code(err: Error, exit_code: i32) -> ! {
482    let formatter = ErrorFormatter::new(crate::util_name());
483    formatter.print_error_and_exit(&err, exit_code);
484}
485
486/// Configures a clap `Command` with proper localization and color settings.
487///
488/// This function sets up a `Command` with:
489/// - Appropriate color settings based on environment variables (`NO_COLOR`, `CLICOLOR_FORCE`, etc.)
490/// - Localized help template with proper formatting
491/// - TTY detection for automatic color enabling/disabling
492///
493/// # Arguments
494///
495/// * `cmd` - The clap `Command` to configure
496///
497/// # Returns
498///
499/// The configured `Command` with localization and color settings applied.
500///
501/// # Environment Variables
502///
503/// The following environment variables affect color output:
504/// - `NO_COLOR` - Disables all color output
505/// - `CLICOLOR_FORCE` or `FORCE_COLOR` - Forces color output even when not in a TTY
506/// - `TERM` - If set to "dumb", colors are disabled in auto mode
507///
508/// # Examples
509///
510/// ```no_run
511/// use clap::Command;
512/// use uucore::clap_localization::configure_localized_command;
513///
514/// let cmd = Command::new("myutil")
515///     .arg(clap::Arg::new("input").short('i'));
516/// let configured_cmd = configure_localized_command(cmd);
517/// ```
518pub fn configure_localized_command(mut cmd: Command) -> Command {
519    let color_choice = get_color_choice();
520    cmd = cmd.color(color_choice);
521
522    // For help output (stdout), we check stdout TTY status
523    let colors_enabled = should_use_color_for_stream(&std::io::stdout());
524
525    cmd = cmd.help_template(crate::localized_help_template_with_colors(
526        crate::util_name(),
527        colors_enabled,
528    ));
529    cmd
530}
531
532/* spell-checker: disable */
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use clap::{Arg, Command};
537    use std::ffi::OsString;
538
539    #[test]
540    fn test_color_codes() {
541        assert_eq!(Color::Red.code(), "31");
542        assert_eq!(Color::Yellow.code(), "33");
543        assert_eq!(Color::Green.code(), "32");
544    }
545
546    #[test]
547    fn test_color_manager() {
548        let mgr = ColorManager(true);
549        let red_text = mgr.colorize("error", Color::Red);
550        assert_eq!(red_text, "\x1b[31merror\x1b[0m");
551
552        let mgr_disabled = ColorManager(false);
553        let plain_text = mgr_disabled.colorize("error", Color::Red);
554        assert_eq!(plain_text, "error");
555    }
556
557    fn create_test_command() -> Command {
558        Command::new("test")
559            .arg(
560                Arg::new("input")
561                    .short('i')
562                    .long("input")
563                    .value_name("FILE")
564                    .help("Input file"),
565            )
566            .arg(
567                Arg::new("output")
568                    .short('o')
569                    .long("output")
570                    .value_name("FILE")
571                    .help("Output file"),
572            )
573            .arg(
574                Arg::new("format")
575                    .long("format")
576                    .value_parser(["json", "xml", "csv"])
577                    .help("Output format"),
578            )
579    }
580
581    #[test]
582    fn test_handle_clap_result_with_valid_args() {
583        let cmd = create_test_command();
584        let result = handle_clap_result(cmd, vec!["test", "--input", "file.txt"]);
585        assert!(result.is_ok());
586        let matches = result.unwrap();
587        assert_eq!(matches.get_one::<String>("input").unwrap(), "file.txt");
588    }
589
590    #[test]
591    fn test_handle_clap_result_with_osstring() {
592        let args: Vec<OsString> = vec!["test".into(), "--output".into(), "out.txt".into()];
593        let cmd = create_test_command();
594        let result = handle_clap_result(cmd, args);
595        assert!(result.is_ok());
596        let matches = result.unwrap();
597        assert_eq!(matches.get_one::<String>("output").unwrap(), "out.txt");
598    }
599
600    #[test]
601    fn test_configure_localized_command() {
602        let cmd = Command::new("test");
603        let configured = configure_localized_command(cmd);
604        // The command should have color and help template configured
605        // We can't easily test the internal state, but we can verify it doesn't panic
606        assert_eq!(configured.get_name(), "test");
607    }
608
609    #[test]
610    fn test_color_environment_vars() {
611        use std::env;
612
613        // Test NO_COLOR disables colors
614        unsafe {
615            env::set_var("NO_COLOR", "1");
616        }
617        assert_eq!(get_color_choice(), clap::ColorChoice::Never);
618        assert!(!should_use_color_for_stream(&stderr()));
619        let mgr = ColorManager::from_env();
620        assert!(!mgr.0);
621        unsafe {
622            env::remove_var("NO_COLOR");
623        }
624
625        // Test CLICOLOR_FORCE enables colors
626        unsafe {
627            env::set_var("CLICOLOR_FORCE", "1");
628        }
629        assert_eq!(get_color_choice(), clap::ColorChoice::Always);
630        assert!(should_use_color_for_stream(&stderr()));
631        let mgr = ColorManager::from_env();
632        assert!(mgr.0);
633        unsafe {
634            env::remove_var("CLICOLOR_FORCE");
635        }
636
637        // Test FORCE_COLOR also enables colors
638        unsafe {
639            env::set_var("FORCE_COLOR", "1");
640        }
641        assert_eq!(get_color_choice(), clap::ColorChoice::Always);
642        assert!(should_use_color_for_stream(&stderr()));
643        unsafe {
644            env::remove_var("FORCE_COLOR");
645        }
646    }
647
648    #[test]
649    fn test_error_formatter_creation() {
650        let formatter = ErrorFormatter::new("test");
651        assert_eq!(formatter.util_name, "test");
652        // Color manager should be created based on environment
653    }
654
655    #[test]
656    fn test_localization_keys_exist() {
657        use crate::locale::{get_message, setup_localization};
658
659        let _ = setup_localization("test");
660
661        let required_keys = [
662            "common-error",
663            "common-usage",
664            "common-tip",
665            "common-help-suggestion",
666            "clap-error-unexpected-argument",
667            "clap-error-invalid-value",
668            "clap-error-missing-required-arguments",
669            "clap-error-similar-argument",
670            "clap-error-possible-values",
671            "clap-error-value-required",
672        ];
673
674        for key in &required_keys {
675            let message = get_message(key);
676            assert_ne!(message, *key, "Translation missing for key: {key}");
677        }
678    }
679
680    #[test]
681    fn test_french_localization() {
682        use crate::locale::{get_message, setup_localization};
683        use std::env;
684
685        let original_lang = env::var_os("LANG").unwrap_or_default();
686
687        unsafe {
688            env::set_var("LANG", "fr_FR.UTF-8");
689        }
690
691        if setup_localization("test").is_ok() {
692            assert_eq!(get_message("common-error"), "erreur");
693            assert_eq!(get_message("common-usage"), "Utilisation");
694            assert_eq!(get_message("common-tip"), "conseil");
695        }
696
697        unsafe {
698            if original_lang.is_empty() {
699                env::remove_var("LANG");
700            } else {
701                env::set_var("LANG", original_lang);
702            }
703        }
704    }
705}
706/* spell-checker: enable */