1#![doc = include_str!("../README.md")]
2
3use core::time::Duration;
4
5use bpaf::{Bpaf, ShellComp};
6
7#[derive(Debug, Clone, Bpaf)]
9#[bpaf(generate(cli_global_options))]
10#[allow(clippy::upper_case_acronyms)]
11pub struct CLIGlobalOptions {
12 #[bpaf(long("colors"), argument("off|force"))]
16 pub colors: Option<ColorsArg>,
17
18 #[bpaf(short('v'), long("verbose"), switch, fallback(false))]
21 pub verbose: bool,
22
23 #[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#[allow(clippy::needless_pass_by_value)] fn parse_duration(s: String) -> Result<Duration, String> {
95 humantime::parse_duration(&s).map_err(|e| format!("invalid duration '{s}': {e}"))
96}
97
98#[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 #[bpaf(long("schema-cache-ttl"), argument::<String>("DURATION"), parse(parse_duration), optional)]
108 pub schema_cache_ttl: Option<Duration>,
109
110 #[bpaf(long("force-schema-fetch"), switch)]
112 pub force_schema_fetch: bool,
113
114 #[bpaf(long("force-validation"), switch)]
116 pub force_validation: bool,
117
118 #[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 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
137pub 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
145pub 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 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 let _ = write!(stdin, "{content}");
179 }
180 let _ = child.wait();
181 }
182 Err(_) => {
183 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 #[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}