1use clap::Parser;
6use std::str::FromStr;
7
8#[derive(Debug, Clone)]
10pub struct Cli {
11 pub format: OutputFormat,
13 pub verbosity: u8,
15 pub color: bool,
17}
18
19impl Default for Cli {
20 fn default() -> Self {
21 Self {
22 format: OutputFormat::Table,
23 verbosity: 1,
24 color: true,
25 }
26 }
27}
28
29impl Cli {
30 pub fn new() -> Self {
32 Self::default()
33 }
34
35 pub fn with_format(mut self, format: OutputFormat) -> Self {
37 self.format = format;
38 self
39 }
40
41 pub fn with_verbosity(mut self, level: u8) -> Self {
43 self.verbosity = level;
44 self
45 }
46
47 pub fn with_color(mut self, enabled: bool) -> Self {
49 self.color = enabled;
50 self
51 }
52
53 pub fn is_quiet(&self) -> bool {
55 self.verbosity == 0
56 }
57
58 pub fn is_verbose(&self) -> bool {
60 self.verbosity >= 2
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum OutputFormat {
67 #[default]
69 Table,
70 Json,
72 Compact,
74}
75
76impl FromStr for OutputFormat {
77 type Err = String;
78
79 fn from_str(s: &str) -> Result<Self, Self::Err> {
80 match s.to_lowercase().as_str() {
81 "table" | "text" => Ok(Self::Table),
82 "json" => Ok(Self::Json),
83 "compact" | "line" => Ok(Self::Compact),
84 _ => Err(format!(
85 "Unknown output format '{s}'. Valid options: table, json, compact"
86 )),
87 }
88 }
89}
90
91impl std::fmt::Display for OutputFormat {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 match self {
94 Self::Table => write!(f, "table"),
95 Self::Json => write!(f, "json"),
96 Self::Compact => write!(f, "compact"),
97 }
98 }
99}
100
101#[derive(Parser, Debug, Clone)]
103pub struct CommonArgs {
104 #[arg(short, long, default_value = "table")]
106 pub format: String,
107
108 #[arg(short, long)]
110 pub quiet: bool,
111
112 #[arg(short, long)]
114 pub verbose: bool,
115
116 #[arg(long)]
118 pub no_color: bool,
119}
120
121impl CommonArgs {
122 pub fn to_cli(&self) -> Cli {
124 let verbosity = if self.quiet {
125 0
126 } else if self.verbose {
127 2
128 } else {
129 1
130 };
131
132 Cli {
133 format: self.format.parse().unwrap_or_default(),
134 verbosity,
135 color: !self.no_color,
136 }
137 }
138}
139
140pub mod styles {
142 pub struct Colors;
144
145 impl Colors {
146 pub const RESET: &'static str = "\x1b[0m";
147 pub const BOLD: &'static str = "\x1b[1m";
148 pub const DIM: &'static str = "\x1b[2m";
149 pub const RED: &'static str = "\x1b[31m";
150 pub const GREEN: &'static str = "\x1b[32m";
151 pub const YELLOW: &'static str = "\x1b[33m";
152 pub const BLUE: &'static str = "\x1b[34m";
153 pub const CYAN: &'static str = "\x1b[36m";
154 }
155
156 pub fn success(msg: &str) -> String {
158 format!("{}✓{} {}", Colors::GREEN, Colors::RESET, msg)
159 }
160
161 pub fn error(msg: &str) -> String {
163 format!("{}✗{} {}", Colors::RED, Colors::RESET, msg)
164 }
165
166 pub fn warning(msg: &str) -> String {
168 format!("{}⚠{} {}", Colors::YELLOW, Colors::RESET, msg)
169 }
170
171 pub fn info(msg: &str) -> String {
173 format!("{}ℹ{} {}", Colors::BLUE, Colors::RESET, msg)
174 }
175
176 pub fn header(msg: &str) -> String {
178 format!("{}{}{}", Colors::BOLD, msg, Colors::RESET)
179 }
180
181 pub fn dim(msg: &str) -> String {
183 format!("{}{}{}", Colors::DIM, msg, Colors::RESET)
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn test_output_format_roundtrip() {
193 for format in [
194 OutputFormat::Table,
195 OutputFormat::Json,
196 OutputFormat::Compact,
197 ] {
198 let s = format.to_string();
199 let parsed: OutputFormat = s.parse().expect("parsing should succeed");
200 assert_eq!(format, parsed);
201 }
202 }
203
204 #[test]
205 fn test_output_format_case_insensitive() {
206 assert_eq!(
207 "JSON"
208 .parse::<OutputFormat>()
209 .expect("parsing should succeed"),
210 OutputFormat::Json
211 );
212 assert_eq!(
213 "Table"
214 .parse::<OutputFormat>()
215 .expect("parsing should succeed"),
216 OutputFormat::Table
217 );
218 assert_eq!(
219 "COMPACT"
220 .parse::<OutputFormat>()
221 .expect("parsing should succeed"),
222 OutputFormat::Compact
223 );
224 }
225
226 #[test]
227 fn test_cli_verbosity_levels() {
228 let quiet = Cli::new().with_verbosity(0);
229 assert!(quiet.is_quiet());
230 assert!(!quiet.is_verbose());
231
232 let normal = Cli::new().with_verbosity(1);
233 assert!(!normal.is_quiet());
234 assert!(!normal.is_verbose());
235
236 let verbose = Cli::new().with_verbosity(2);
237 assert!(!verbose.is_quiet());
238 assert!(verbose.is_verbose());
239 }
240
241 #[test]
242 fn test_styles_include_ansi_codes() {
243 let success = styles::success("done");
244 assert!(success.contains('\x1b'));
245 assert!(success.contains("done"));
246 assert!(success.contains('✓'));
247 }
248
249 #[test]
250 fn test_common_args_to_cli() {
251 let args = CommonArgs {
252 format: "json".to_string(),
253 quiet: false,
254 verbose: true,
255 no_color: true,
256 };
257 let cli = args.to_cli();
258
259 assert_eq!(cli.format, OutputFormat::Json);
260 assert_eq!(cli.verbosity, 2);
261 assert!(!cli.color);
262 }
263
264 #[test]
265 fn test_output_format_aliases() {
266 assert_eq!(
268 "text"
269 .parse::<OutputFormat>()
270 .expect("parsing should succeed"),
271 OutputFormat::Table
272 );
273 assert_eq!(
275 "line"
276 .parse::<OutputFormat>()
277 .expect("parsing should succeed"),
278 OutputFormat::Compact
279 );
280 }
281
282 #[test]
283 fn test_output_format_invalid() {
284 let result = "invalid_format".parse::<OutputFormat>();
285 assert!(result.is_err());
286 let err = result.unwrap_err();
287 assert!(err.contains("Unknown output format"));
288 assert!(err.contains("invalid_format"));
289 }
290
291 #[test]
292 fn test_cli_default_values() {
293 let cli = Cli::default();
294 assert_eq!(cli.format, OutputFormat::Table);
295 assert_eq!(cli.verbosity, 1);
296 assert!(cli.color);
297 assert!(!cli.is_quiet());
298 assert!(!cli.is_verbose());
299 }
300
301 #[test]
302 fn test_cli_builder_pattern() {
303 let cli = Cli::new()
304 .with_format(OutputFormat::Json)
305 .with_verbosity(2)
306 .with_color(false);
307
308 assert_eq!(cli.format, OutputFormat::Json);
309 assert_eq!(cli.verbosity, 2);
310 assert!(!cli.color);
311 }
312
313 #[test]
314 fn test_styles_all_variants() {
315 let success = styles::success("ok");
316 assert!(success.contains('✓'));
317 assert!(success.contains(styles::Colors::GREEN));
318
319 let error = styles::error("fail");
320 assert!(error.contains('✗'));
321 assert!(error.contains(styles::Colors::RED));
322
323 let warning = styles::warning("warn");
324 assert!(warning.contains('⚠'));
325 assert!(warning.contains(styles::Colors::YELLOW));
326
327 let info = styles::info("note");
328 assert!(info.contains('ℹ'));
329 assert!(info.contains(styles::Colors::BLUE));
330
331 let header = styles::header("title");
332 assert!(header.contains("title"));
333 assert!(header.contains(styles::Colors::BOLD));
334
335 let dim = styles::dim("secondary");
336 assert!(dim.contains("secondary"));
337 assert!(dim.contains(styles::Colors::DIM));
338 }
339
340 #[test]
341 fn test_common_args_quiet_mode() {
342 let args = CommonArgs {
343 format: "table".to_string(),
344 quiet: true,
345 verbose: false,
346 no_color: false,
347 };
348 let cli = args.to_cli();
349
350 assert_eq!(cli.verbosity, 0);
351 assert!(cli.is_quiet());
352 }
353
354 #[test]
355 fn test_common_args_default_format_fallback() {
356 let args = CommonArgs {
357 format: "invalid".to_string(),
358 quiet: false,
359 verbose: false,
360 no_color: false,
361 };
362 let cli = args.to_cli();
363
364 assert_eq!(cli.format, OutputFormat::Table);
366 }
367
368 #[test]
369 fn test_verbosity_level_3_is_verbose() {
370 let cli = Cli::new().with_verbosity(3);
371 assert!(cli.is_verbose());
372 assert!(!cli.is_quiet());
373 }
374}