human_time_cli/
lib.rs

1#[doc(hidden)]
2pub mod internal {
3
4    use argh::FromArgs;
5    use human_time::ToHumanTimeString;
6    use regex_lite::Regex;
7    use serde::Deserialize;
8    use std::env;
9    use std::fs;
10    use std::path::{Path, PathBuf};
11    use std::time::Duration;
12
13    /// Convert time duration to human-readable format
14    #[derive(FromArgs)]
15    #[argh(
16        name = "human-time",
17        description = "Converts a time duration to a human-readable format"
18    )]
19    pub struct Args {
20        /// the time duration to convert
21        #[argh(positional)]
22        pub time_value: Option<u64>,
23
24        /// specify the unit of the time duration (milli, micro). If not specified, defaults to seconds.
25        #[argh(option, short = 'u', long = "unit")]
26        pub unit: Option<String>,
27
28        /// specify if there is a config file (the name will be human-time.toml)
29        #[argh(switch, short = 'c', long = "config")]
30        pub config: bool,
31    }
32
33    #[derive(Deserialize)]
34    pub struct Config {
35        pub default_time_value_units: String,
36        pub formatting: Formatting,
37        pub units: Units,
38    }
39
40    #[derive(Deserialize)]
41    pub struct Formatting {
42        pub format: String,
43        pub delimiter_text: String,
44    }
45
46    #[derive(Deserialize)]
47    pub struct Units {
48        pub d: String,
49        pub h: String,
50        pub m: String,
51        pub s: String,
52        pub ms: String,
53        pub us: String,
54    }
55
56    impl Default for Config {
57        fn default() -> Self {
58            Config {
59                default_time_value_units: "seconds".to_string(),
60                formatting: Formatting {
61                    format: "{}{}".to_string(),
62                    delimiter_text: ",".to_string(),
63                },
64                units: Units {
65                    d: "d".to_string(),
66                    h: "h".to_string(),
67                    m: "m".to_string(),
68                    s: "s".to_string(),
69                    ms: "ms".to_string(),
70                    us: "µs".to_string(),
71                },
72            }
73        }
74    }
75
76    const MILLI_REGEX: &str = r"^(?:milli(?:second|sec)?s?|ms)$";
77    const MICRO_REGEX: &str = r"^micro(?:second|sec)?s?$";
78    const SEC_REGEX: &str = r"^(?:sec(?:ond)?s?|s)$";
79
80    pub fn print_error_and_exit(error_message: &str) -> ! {
81        eprintln!("{error_message}");
82        eprintln!(
83            r#"Usage: human-time-cli [OPTIONS] <TIME_DURATION>
84Options:
85  -u, --unit <UNIT>       specify the unit of the time value (milli, micro). If not specified, defaults to seconds.
86  -c, --config            specify if there is a config file"#
87        );
88        std::process::exit(1);
89    }
90
91    pub fn validate_config(config: &Config) -> Result<(), String> {
92        let unit = config.default_time_value_units.to_lowercase();
93        let milli_regex = Regex::new(MILLI_REGEX).unwrap();
94        let micro_regex = Regex::new(MICRO_REGEX).unwrap();
95        let sec_regex = Regex::new(SEC_REGEX).unwrap();
96
97        if !(milli_regex.is_match(&unit)
98            || micro_regex.is_match(&unit)
99            || sec_regex.is_match(&unit))
100        {
101            return Err(format!(
102            "Invalid default_time_value_units: {}. Valid options are: milliseconds, microseconds, or seconds.",
103            config.default_time_value_units
104        ));
105        }
106
107        // Check if formatting.format contains exactly two sets of {}
108        let format = &config.formatting.format;
109        let placeholder_count = format.matches("{}").count();
110        if placeholder_count != 2 {
111            return Err(format!(
112                "Invalid formatting.format: {}. It must contain exactly two sets of {{}}.",
113                format
114            ));
115        }
116
117        Ok(())
118    }
119
120    pub fn convert_time(time_value: u64, unit: Option<&str>) -> Result<Duration, String> {
121        let unit = unit.unwrap_or("sec").to_lowercase();
122
123        let milli_regex = Regex::new(MILLI_REGEX).unwrap();
124        let micro_regex = Regex::new(MICRO_REGEX).unwrap();
125        let sec_regex = Regex::new(SEC_REGEX).unwrap();
126
127        let duration = if milli_regex.is_match(&unit) {
128            Duration::from_millis(time_value)
129        } else if micro_regex.is_match(&unit) {
130            Duration::from_micros(time_value)
131        } else if sec_regex.is_match(&unit) {
132            Duration::from_secs(time_value)
133        } else {
134            return Err(format!(
135            "Invalid unit '{}'. Please specify one of: milli, micro, or leave empty for seconds.",
136            unit
137        ));
138        };
139
140        Ok(duration)
141    }
142
143    pub fn read_config<P: AsRef<Path>>(path: P) -> Config {
144        match fs::read_to_string(&path) {
145            Ok(config_content) => match toml::from_str(&config_content) {
146                Ok(config) => {
147                    if let Err(err) = validate_config(&config) {
148                        eprintln!("{}", err);
149                        std::process::exit(1);
150                    }
151                    config
152                }
153                Err(_) => {
154                    eprintln!("Error: Failed to parse the config file.");
155                    std::process::exit(1);
156                }
157            },
158            Err(_) => {
159                eprintln!(
160                    "Error: Config file not found at this location: {}",
161                    path.as_ref().display()
162                );
163                std::process::exit(1);
164            }
165        }
166    }
167
168    pub fn find_config_file() -> Option<PathBuf> {
169        let exe_path = env::current_exe().ok()?;
170        let exe_dir = exe_path.parent()?;
171        let config_file_name = "human-time.toml";
172
173        let exe_config_path = exe_dir.join(config_file_name);
174        if exe_config_path.exists() {
175            return Some(exe_config_path);
176        }
177
178        let home_dir = dirs::home_dir()?;
179        let home_config_path = home_dir.join(config_file_name);
180        if home_config_path.exists() {
181            return Some(home_config_path);
182        }
183        None
184    }
185
186    pub fn format_duration(time_value: u64, unit: &str, config: &Config) -> Result<String, String> {
187        match convert_time(time_value, Some(unit)) {
188            Ok(duration) => {
189                let formatted_duration = duration.to_human_time_string_with_format(
190                    |n, unit| {
191                        let unit_str = match unit {
192                            "d" => &config.units.d,
193                            "h" => &config.units.h,
194                            "m" => &config.units.m,
195                            "s" => &config.units.s,
196                            "ms" => &config.units.ms,
197                            _ => &config.units.us,
198                        };
199
200                        let unit_str = if n == 1 {
201                            unit_str.replace("(s)", "")
202                        } else {
203                            unit_str.replace("(s)", "s")
204                        };
205
206                        config
207                            .formatting
208                            .format
209                            .replacen("{}", &n.to_string(), 1)
210                            .replacen("{}", &unit_str, 1)
211                    },
212                    |acc, item| format!("{}{}{}", acc, config.formatting.delimiter_text, item),
213                );
214                Ok(formatted_duration)
215            }
216            Err(err) => Err(err),
217        }
218    }
219}
220
221// Re-export hidden internal functions for use within the crate
222#[doc(hidden)]
223pub use internal::*;