Skip to main content

lintel_cli_common/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use core::time::Duration;
4
5use bpaf::{Bpaf, ShellComp};
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"), complete_shell(ShellComp::Dir { mask: None }))]
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
126impl CLIGlobalOptions {
127    /// Determine whether to use color output based on `--colors` and TTY detection.
128    pub fn use_color(&self, is_tty: bool) -> bool {
129        match self.colors {
130            Some(ColorsArg::Force) => true,
131            Some(ColorsArg::Off) => false,
132            None => is_tty,
133        }
134    }
135}
136
137/// Get the current terminal width, falling back to `$COLUMNS` or 80.
138pub fn terminal_width() -> usize {
139    terminal_size::terminal_size()
140        .map(|(w, _)| w.0 as usize)
141        .or_else(|| std::env::var("COLUMNS").ok()?.parse().ok())
142        .unwrap_or(80)
143}
144
145// ---------------------------------------------------------------------------
146// Pager
147// ---------------------------------------------------------------------------
148
149/// Pipe content through a pager (respects `$PAGER`, defaults to `less -R`).
150///
151/// Spawns the pager as a child process and writes `content` to its stdin.
152/// Falls back to printing directly if the pager cannot be spawned.
153pub fn pipe_to_pager(content: &str) {
154    use std::io::Write;
155    use std::process::{Command, Stdio};
156
157    let pager_env = std::env::var("PAGER").unwrap_or_default();
158    let (program, args) = if pager_env.is_empty() {
159        ("less", vec!["-R"])
160    } else {
161        let mut parts: Vec<&str> = pager_env.split_whitespace().collect();
162        let prog = parts.remove(0);
163        // Ensure less gets -R for ANSI color passthrough
164        if prog == "less" && !parts.iter().any(|a| a.contains('R')) {
165            parts.push("-R");
166        }
167        (prog, parts)
168    };
169
170    match Command::new(program)
171        .args(&args)
172        .stdin(Stdio::piped())
173        .spawn()
174    {
175        Ok(mut child) => {
176            if let Some(mut stdin) = child.stdin.take() {
177                // Ignore broken-pipe errors (user quit the pager early)
178                let _ = write!(stdin, "{content}");
179            }
180            let _ = child.wait();
181        }
182        Err(_) => {
183            // Pager unavailable -- print directly
184            print!("{content}");
185        }
186    }
187}
188
189#[cfg(test)]
190#[allow(clippy::unwrap_used)]
191mod tests {
192    use super::*;
193    use bpaf::Parser;
194
195    fn opts() -> bpaf::OptionParser<CLIGlobalOptions> {
196        cli_global_options().to_options()
197    }
198
199    fn cache_opts() -> bpaf::OptionParser<CliCacheOptions> {
200        cli_cache_options().to_options()
201    }
202
203    #[test]
204    fn defaults() {
205        let parsed = opts().run_inner(&[]).unwrap();
206        assert!(!parsed.verbose);
207        assert_eq!(parsed.log_level, LogLevel::None);
208        assert!(parsed.colors.is_none());
209    }
210
211    #[test]
212    fn verbose_short() {
213        let parsed = opts().run_inner(&["-v"]).unwrap();
214        assert!(parsed.verbose);
215    }
216
217    #[test]
218    fn verbose_long() {
219        let parsed = opts().run_inner(&["--verbose"]).unwrap();
220        assert!(parsed.verbose);
221    }
222
223    #[test]
224    fn log_level_debug() {
225        let parsed = opts().run_inner(&["--log-level", "debug"]).unwrap();
226        assert_eq!(parsed.log_level, LogLevel::Debug);
227    }
228
229    #[test]
230    fn log_level_info() {
231        let parsed = opts().run_inner(&["--log-level", "info"]).unwrap();
232        assert_eq!(parsed.log_level, LogLevel::Info);
233    }
234
235    #[test]
236    fn log_level_warn() {
237        let parsed = opts().run_inner(&["--log-level", "warn"]).unwrap();
238        assert_eq!(parsed.log_level, LogLevel::Warn);
239    }
240
241    #[test]
242    fn log_level_error() {
243        let parsed = opts().run_inner(&["--log-level", "error"]).unwrap();
244        assert_eq!(parsed.log_level, LogLevel::Error);
245    }
246
247    #[test]
248    fn log_level_invalid() {
249        assert!(opts().run_inner(&["--log-level", "trace"]).is_err());
250    }
251
252    #[test]
253    fn colors_off() {
254        let parsed = opts().run_inner(&["--colors", "off"]).unwrap();
255        assert_eq!(parsed.colors, Some(ColorsArg::Off));
256    }
257
258    #[test]
259    fn colors_force() {
260        let parsed = opts().run_inner(&["--colors", "force"]).unwrap();
261        assert_eq!(parsed.colors, Some(ColorsArg::Force));
262    }
263
264    #[test]
265    fn colors_invalid() {
266        assert!(opts().run_inner(&["--colors", "auto"]).is_err());
267    }
268
269    #[test]
270    fn combined_flags() {
271        let parsed = opts()
272            .run_inner(&["-v", "--log-level", "debug", "--colors", "force"])
273            .unwrap();
274        assert!(parsed.verbose);
275        assert_eq!(parsed.log_level, LogLevel::Debug);
276        assert_eq!(parsed.colors, Some(ColorsArg::Force));
277    }
278
279    // --- CliCacheOptions tests ---
280
281    #[test]
282    fn cache_defaults() {
283        let parsed = cache_opts().run_inner(&[]).unwrap();
284        assert!(parsed.cache_dir.is_none());
285        assert!(parsed.schema_cache_ttl.is_none());
286        assert!(!parsed.force_schema_fetch);
287        assert!(!parsed.force_validation);
288        assert!(!parsed.force);
289        assert!(!parsed.no_catalog);
290    }
291
292    #[test]
293    fn cache_dir_parsed() {
294        let parsed = cache_opts()
295            .run_inner(&["--cache-dir", "/tmp/cache"])
296            .unwrap();
297        assert_eq!(parsed.cache_dir.as_deref(), Some("/tmp/cache"));
298    }
299
300    #[test]
301    fn schema_cache_ttl_parsed() {
302        let parsed = cache_opts()
303            .run_inner(&["--schema-cache-ttl", "12h"])
304            .unwrap();
305        assert_eq!(
306            parsed.schema_cache_ttl,
307            Some(Duration::from_secs(12 * 3600))
308        );
309    }
310
311    #[test]
312    fn schema_cache_ttl_invalid() {
313        assert!(
314            cache_opts()
315                .run_inner(&["--schema-cache-ttl", "invalid"])
316                .is_err()
317        );
318    }
319
320    #[test]
321    fn force_schema_fetch_flag() {
322        let parsed = cache_opts().run_inner(&["--force-schema-fetch"]).unwrap();
323        assert!(parsed.force_schema_fetch);
324    }
325
326    #[test]
327    fn force_validation_flag() {
328        let parsed = cache_opts().run_inner(&["--force-validation"]).unwrap();
329        assert!(parsed.force_validation);
330    }
331
332    #[test]
333    fn force_flag() {
334        let parsed = cache_opts().run_inner(&["--force"]).unwrap();
335        assert!(parsed.force);
336    }
337
338    #[test]
339    fn no_catalog_flag() {
340        let parsed = cache_opts().run_inner(&["--no-catalog"]).unwrap();
341        assert!(parsed.no_catalog);
342    }
343
344    #[test]
345    fn cache_combined_flags() {
346        let parsed = cache_opts()
347            .run_inner(&[
348                "--cache-dir",
349                "/tmp/cache",
350                "--schema-cache-ttl",
351                "30m",
352                "--force-schema-fetch",
353                "--force-validation",
354                "--no-catalog",
355            ])
356            .unwrap();
357        assert_eq!(parsed.cache_dir.as_deref(), Some("/tmp/cache"));
358        assert_eq!(parsed.schema_cache_ttl, Some(Duration::from_secs(30 * 60)));
359        assert!(parsed.force_schema_fetch);
360        assert!(parsed.force_validation);
361        assert!(parsed.no_catalog);
362    }
363}