cli_helpers/
lib.rs

1//! Opinionated helpers for building consistent command-line interfaces with [`clap`][clap] and [`simplelog`][simplelog].
2//!
3//! ## Example
4//!
5//! The [`prelude`] module exports a minimal subset of these two crates.
6//!
7//! ```rust,no_run
8//! use cli_helpers::prelude::*;
9//!
10//! #[derive(Debug, Parser)]
11//! #[clap(name = "demo", version, author)]
12//! struct Opts {
13//!     #[clap(flatten)]
14//!     verbose: Verbosity,
15//! }
16//!
17//! fn main() -> Result<(), cli_helpers::Error> {
18//!     let opts: Opts = Opts::parse();
19//!     opts.verbose.init_logging()?;
20//!     Ok(())
21//! }
22//! ```
23//!
24//! [clap]: https://docs.rs/clap/latest/clap/
25//! [simplelog]: https://docs.rs/simplelog/latest/simplelog/
26
27use std::str::FromStr;
28
29use chrono::{DateTime, TimeZone, Utc};
30use simplelog::LevelFilter;
31
32const TIMESTAMP_FMT_EN_US: &str = "%a %b %e %I:%M:%S %p %z %Y";
33const S_TO_MS_CUTOFF: i64 = 1000000000000;
34
35#[derive(Debug, thiserror::Error)]
36pub enum Error {
37    #[error("Logger initialization error")]
38    Logger(#[from] log::SetLoggerError),
39    #[error("Invalid timestamp format")]
40    InvalidTimestamp(String),
41}
42
43fn select_log_level_filter(verbosity: u8) -> LevelFilter {
44    match verbosity {
45        0 => LevelFilter::Off,
46        1 => LevelFilter::Error,
47        2 => LevelFilter::Warn,
48        3 => LevelFilter::Info,
49        4 => LevelFilter::Debug,
50        _ => LevelFilter::Trace,
51    }
52}
53
54#[derive(clap::Args, Debug, Clone, PartialEq, Eq)]
55pub struct Verbosity {
56    /// Level of verbosity
57    #[clap(long, short = 'v', global = true, action = clap::ArgAction::Count)]
58    verbose: u8,
59}
60
61impl Verbosity {
62    pub fn new(verbose: u8) -> Self {
63        Self { verbose }
64    }
65
66    /// Initialize a default terminal logger with the indicated log level.
67    pub fn init_logging(&self) -> Result<(), Error> {
68        Ok(simplelog::TermLogger::init(
69            select_log_level_filter(self.verbose),
70            simplelog::Config::default(),
71            simplelog::TerminalMode::Stderr,
72            simplelog::ColorChoice::Auto,
73        )?)
74    }
75}
76
77/// A timestamp represented as either an epoch second or the `en_US.UTF-8` default on Linux.
78#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
79pub struct Timestamp(DateTime<Utc>);
80
81impl From<Timestamp> for DateTime<Utc> {
82    fn from(value: Timestamp) -> Self {
83        value.0
84    }
85}
86
87impl FromStr for Timestamp {
88    type Err = Error;
89
90    fn from_str(s: &str) -> Result<Self, Self::Err> {
91        s.parse::<i64>()
92            .ok()
93            .and_then(|timestamp_n| {
94                if timestamp_n < S_TO_MS_CUTOFF {
95                    Utc.timestamp_opt(timestamp_n, 0).single()
96                } else {
97                    Utc.timestamp_millis_opt(timestamp_n).single()
98                }
99            })
100            .map(Timestamp)
101            .or_else(|| {
102                DateTime::parse_from_str(&tz_name_to_offset(s), TIMESTAMP_FMT_EN_US)
103                    .ok()
104                    .map(|timestamp| Timestamp(timestamp.into()))
105            })
106            .ok_or_else(|| Error::InvalidTimestamp(s.to_string()))
107    }
108}
109
110/// This is a very simple hack to support copy-paste from `date` for me without pulling in chrono-tz.
111fn tz_name_to_offset(input: &str) -> String {
112    input.replace("CET", "+0100").replace("CEST", "+0200")
113}
114
115pub mod prelude {
116    pub use super::{Timestamp, Verbosity};
117    pub use ::clap::Parser;
118    pub use clap;
119    pub mod log {
120        pub use log::{error, info, warn, SetLoggerError};
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    #[test]
127    fn test_prelude() {
128        use super::prelude::*;
129        use chrono::{TimeZone, Utc};
130
131        #[derive(Debug, Parser, PartialEq, Eq)]
132        #[clap(name = "test", version, author)]
133        struct Opts {
134            #[clap(flatten)]
135            verbose: Verbosity,
136            #[clap(long)]
137            timestamp_a: Timestamp,
138            #[clap(long)]
139            timestamp_b: Timestamp,
140            #[clap(long)]
141            timestamp_c: Timestamp,
142        }
143
144        let parsed = Opts::try_parse_from([
145            "test",
146            "-vvvv",
147            "--timestamp-a",
148            "1692946034",
149            "--timestamp-b",
150            "Fri Aug 25 08:47:09 AM CEST 2023",
151            "--timestamp-c",
152            "1692946034632",
153        ])
154        .unwrap();
155
156        let expected = Opts {
157            verbose: Verbosity { verbose: 4 },
158            timestamp_a: Timestamp(Utc.timestamp_opt(1692946034, 0).single().unwrap()),
159            timestamp_b: Timestamp(Utc.timestamp_opt(1692946029, 0).single().unwrap()),
160            timestamp_c: Timestamp(Utc.timestamp_opt(1692946034, 632000000).single().unwrap()),
161        };
162
163        assert_eq!(parsed, expected);
164    }
165}