1#![doc = include_str!("../README.md")]
2
3use core::time::Duration;
4
5use bpaf::Bpaf;
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"))]
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
126pub 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 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 let _ = write!(stdin, "{content}");
160 }
161 let _ = child.wait();
162 }
163 Err(_) => {
164 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 #[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}