Skip to main content

lintel_cli_common/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use core::time::Duration;
4
5use bpaf::Bpaf;
6
7/// Global options applied to all commands
8#[derive(Debug, Clone, Bpaf)]
9#[bpaf(generate(cli_global_options))]
10#[allow(clippy::upper_case_acronyms)]
11pub struct CLIGlobalOptions {
12    /// Set the formatting mode for markup: "off" prints everything as plain text,
13    /// "force" forces the formatting of markup using ANSI even if the console
14    /// output is determined to be incompatible
15    #[bpaf(long("colors"), argument("off|force"))]
16    pub colors: Option<ColorsArg>,
17
18    /// Print additional diagnostics, and some diagnostics show more information.
19    /// Also, print out what files were processed and which ones were modified.
20    #[bpaf(short('v'), long("verbose"), switch, fallback(false))]
21    pub verbose: bool,
22
23    /// The level of logging. In order, from the most verbose to the least verbose:
24    /// debug, info, warn, error.
25    #[bpaf(
26        long("log-level"),
27        argument("none|debug|info|warn|error"),
28        fallback(LogLevel::None),
29        display_fallback
30    )]
31    pub log_level: LogLevel,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ColorsArg {
36    Off,
37    Force,
38}
39
40impl core::str::FromStr for ColorsArg {
41    type Err = String;
42    fn from_str(s: &str) -> Result<Self, Self::Err> {
43        match s {
44            "off" => Ok(Self::Off),
45            "force" => Ok(Self::Force),
46            _ => Err(format!("expected 'off' or 'force', got '{s}'")),
47        }
48    }
49}
50
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
52pub enum LogLevel {
53    #[default]
54    None,
55    Debug,
56    Info,
57    Warn,
58    Error,
59}
60
61impl core::str::FromStr for LogLevel {
62    type Err = String;
63    fn from_str(s: &str) -> Result<Self, Self::Err> {
64        match s {
65            "none" => Ok(Self::None),
66            "debug" => Ok(Self::Debug),
67            "info" => Ok(Self::Info),
68            "warn" => Ok(Self::Warn),
69            "error" => Ok(Self::Error),
70            _ => Err(format!(
71                "expected 'none', 'debug', 'info', 'warn', or 'error', got '{s}'"
72            )),
73        }
74    }
75}
76
77impl core::fmt::Display for LogLevel {
78    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
79        match self {
80            Self::None => write!(f, "none"),
81            Self::Debug => write!(f, "debug"),
82            Self::Info => write!(f, "info"),
83            Self::Warn => write!(f, "warn"),
84            Self::Error => write!(f, "error"),
85        }
86    }
87}
88
89// ---------------------------------------------------------------------------
90// Shared cache options
91// ---------------------------------------------------------------------------
92
93#[allow(clippy::needless_pass_by_value)] // bpaf parse() requires owned String
94fn parse_duration(s: String) -> Result<Duration, String> {
95    humantime::parse_duration(&s).map_err(|e| format!("invalid duration '{s}': {e}"))
96}
97
98/// Cache-related CLI flags shared across commands.
99#[derive(Debug, Clone, Bpaf)]
100#[bpaf(generate(cli_cache_options))]
101#[allow(clippy::struct_excessive_bools)]
102pub struct CliCacheOptions {
103    #[bpaf(long("cache-dir"), argument("DIR"))]
104    pub cache_dir: Option<String>,
105
106    /// Schema cache TTL (e.g. "12h", "30m", "1d"); default 12h
107    #[bpaf(long("schema-cache-ttl"), argument::<String>("DURATION"), parse(parse_duration), optional)]
108    pub schema_cache_ttl: Option<Duration>,
109
110    /// Bypass schema cache reads (still writes fetched schemas to cache)
111    #[bpaf(long("force-schema-fetch"), switch)]
112    pub force_schema_fetch: bool,
113
114    /// Bypass validation cache reads (still writes results to cache)
115    #[bpaf(long("force-validation"), switch)]
116    pub force_validation: bool,
117
118    /// Bypass all cache reads (combines --force-schema-fetch and --force-validation)
119    #[bpaf(long("force"), switch)]
120    pub force: bool,
121
122    #[bpaf(long("no-catalog"), switch)]
123    pub no_catalog: bool,
124}
125
126// ---------------------------------------------------------------------------
127// Pager
128// ---------------------------------------------------------------------------
129
130/// Pipe content through a pager (respects `$PAGER`, defaults to `less -R`).
131///
132/// Spawns the pager as a child process and writes `content` to its stdin.
133/// Falls back to printing directly if the pager cannot be spawned.
134pub fn pipe_to_pager(content: &str) {
135    use std::io::Write;
136    use std::process::{Command, Stdio};
137
138    let pager_env = std::env::var("PAGER").unwrap_or_default();
139    let (program, args) = if pager_env.is_empty() {
140        ("less", vec!["-R"])
141    } else {
142        let mut parts: Vec<&str> = pager_env.split_whitespace().collect();
143        let prog = parts.remove(0);
144        // Ensure less gets -R for ANSI color passthrough
145        if prog == "less" && !parts.iter().any(|a| a.contains('R')) {
146            parts.push("-R");
147        }
148        (prog, parts)
149    };
150
151    match Command::new(program)
152        .args(&args)
153        .stdin(Stdio::piped())
154        .spawn()
155    {
156        Ok(mut child) => {
157            if let Some(mut stdin) = child.stdin.take() {
158                // Ignore broken-pipe errors (user quit the pager early)
159                let _ = write!(stdin, "{content}");
160            }
161            let _ = child.wait();
162        }
163        Err(_) => {
164            // Pager unavailable -- print directly
165            print!("{content}");
166        }
167    }
168}
169
170#[cfg(test)]
171#[allow(clippy::unwrap_used)]
172mod tests {
173    use super::*;
174    use bpaf::Parser;
175
176    fn opts() -> bpaf::OptionParser<CLIGlobalOptions> {
177        cli_global_options().to_options()
178    }
179
180    fn cache_opts() -> bpaf::OptionParser<CliCacheOptions> {
181        cli_cache_options().to_options()
182    }
183
184    #[test]
185    fn defaults() {
186        let parsed = opts().run_inner(&[]).unwrap();
187        assert!(!parsed.verbose);
188        assert_eq!(parsed.log_level, LogLevel::None);
189        assert!(parsed.colors.is_none());
190    }
191
192    #[test]
193    fn verbose_short() {
194        let parsed = opts().run_inner(&["-v"]).unwrap();
195        assert!(parsed.verbose);
196    }
197
198    #[test]
199    fn verbose_long() {
200        let parsed = opts().run_inner(&["--verbose"]).unwrap();
201        assert!(parsed.verbose);
202    }
203
204    #[test]
205    fn log_level_debug() {
206        let parsed = opts().run_inner(&["--log-level", "debug"]).unwrap();
207        assert_eq!(parsed.log_level, LogLevel::Debug);
208    }
209
210    #[test]
211    fn log_level_info() {
212        let parsed = opts().run_inner(&["--log-level", "info"]).unwrap();
213        assert_eq!(parsed.log_level, LogLevel::Info);
214    }
215
216    #[test]
217    fn log_level_warn() {
218        let parsed = opts().run_inner(&["--log-level", "warn"]).unwrap();
219        assert_eq!(parsed.log_level, LogLevel::Warn);
220    }
221
222    #[test]
223    fn log_level_error() {
224        let parsed = opts().run_inner(&["--log-level", "error"]).unwrap();
225        assert_eq!(parsed.log_level, LogLevel::Error);
226    }
227
228    #[test]
229    fn log_level_invalid() {
230        assert!(opts().run_inner(&["--log-level", "trace"]).is_err());
231    }
232
233    #[test]
234    fn colors_off() {
235        let parsed = opts().run_inner(&["--colors", "off"]).unwrap();
236        assert_eq!(parsed.colors, Some(ColorsArg::Off));
237    }
238
239    #[test]
240    fn colors_force() {
241        let parsed = opts().run_inner(&["--colors", "force"]).unwrap();
242        assert_eq!(parsed.colors, Some(ColorsArg::Force));
243    }
244
245    #[test]
246    fn colors_invalid() {
247        assert!(opts().run_inner(&["--colors", "auto"]).is_err());
248    }
249
250    #[test]
251    fn combined_flags() {
252        let parsed = opts()
253            .run_inner(&["-v", "--log-level", "debug", "--colors", "force"])
254            .unwrap();
255        assert!(parsed.verbose);
256        assert_eq!(parsed.log_level, LogLevel::Debug);
257        assert_eq!(parsed.colors, Some(ColorsArg::Force));
258    }
259
260    // --- CliCacheOptions tests ---
261
262    #[test]
263    fn cache_defaults() {
264        let parsed = cache_opts().run_inner(&[]).unwrap();
265        assert!(parsed.cache_dir.is_none());
266        assert!(parsed.schema_cache_ttl.is_none());
267        assert!(!parsed.force_schema_fetch);
268        assert!(!parsed.force_validation);
269        assert!(!parsed.force);
270        assert!(!parsed.no_catalog);
271    }
272
273    #[test]
274    fn cache_dir_parsed() {
275        let parsed = cache_opts()
276            .run_inner(&["--cache-dir", "/tmp/cache"])
277            .unwrap();
278        assert_eq!(parsed.cache_dir.as_deref(), Some("/tmp/cache"));
279    }
280
281    #[test]
282    fn schema_cache_ttl_parsed() {
283        let parsed = cache_opts()
284            .run_inner(&["--schema-cache-ttl", "12h"])
285            .unwrap();
286        assert_eq!(
287            parsed.schema_cache_ttl,
288            Some(Duration::from_secs(12 * 3600))
289        );
290    }
291
292    #[test]
293    fn schema_cache_ttl_invalid() {
294        assert!(
295            cache_opts()
296                .run_inner(&["--schema-cache-ttl", "invalid"])
297                .is_err()
298        );
299    }
300
301    #[test]
302    fn force_schema_fetch_flag() {
303        let parsed = cache_opts().run_inner(&["--force-schema-fetch"]).unwrap();
304        assert!(parsed.force_schema_fetch);
305    }
306
307    #[test]
308    fn force_validation_flag() {
309        let parsed = cache_opts().run_inner(&["--force-validation"]).unwrap();
310        assert!(parsed.force_validation);
311    }
312
313    #[test]
314    fn force_flag() {
315        let parsed = cache_opts().run_inner(&["--force"]).unwrap();
316        assert!(parsed.force);
317    }
318
319    #[test]
320    fn no_catalog_flag() {
321        let parsed = cache_opts().run_inner(&["--no-catalog"]).unwrap();
322        assert!(parsed.no_catalog);
323    }
324
325    #[test]
326    fn cache_combined_flags() {
327        let parsed = cache_opts()
328            .run_inner(&[
329                "--cache-dir",
330                "/tmp/cache",
331                "--schema-cache-ttl",
332                "30m",
333                "--force-schema-fetch",
334                "--force-validation",
335                "--no-catalog",
336            ])
337            .unwrap();
338        assert_eq!(parsed.cache_dir.as_deref(), Some("/tmp/cache"));
339        assert_eq!(parsed.schema_cache_ttl, Some(Duration::from_secs(30 * 60)));
340        assert!(parsed.force_schema_fetch);
341        assert!(parsed.force_validation);
342        assert!(parsed.no_catalog);
343    }
344}