Skip to main content

entrenar_common/
cli.rs

1//! CLI framework and styling (Standardized Work principle).
2//!
3//! Provides consistent CLI patterns across all entrenar tools.
4
5use clap::Parser;
6use std::str::FromStr;
7
8/// Common CLI configuration shared across all entrenar tools.
9#[derive(Debug, Clone)]
10pub struct Cli {
11    /// Output format
12    pub format: OutputFormat,
13    /// Verbosity level (0 = quiet, 1 = normal, 2 = verbose)
14    pub verbosity: u8,
15    /// Whether colors are enabled
16    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    /// Create a new CLI config with default settings.
31    pub fn new() -> Self {
32        Self::default()
33    }
34
35    /// Set output format.
36    pub fn with_format(mut self, format: OutputFormat) -> Self {
37        self.format = format;
38        self
39    }
40
41    /// Set verbosity level.
42    pub fn with_verbosity(mut self, level: u8) -> Self {
43        self.verbosity = level;
44        self
45    }
46
47    /// Enable or disable colors.
48    pub fn with_color(mut self, enabled: bool) -> Self {
49        self.color = enabled;
50        self
51    }
52
53    /// Check if quiet mode is enabled.
54    pub fn is_quiet(&self) -> bool {
55        self.verbosity == 0
56    }
57
58    /// Check if verbose mode is enabled.
59    pub fn is_verbose(&self) -> bool {
60        self.verbosity >= 2
61    }
62}
63
64/// Output format for CLI commands.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum OutputFormat {
67    /// Human-readable table format (default).
68    #[default]
69    Table,
70    /// JSON format for machine parsing.
71    Json,
72    /// Compact single-line format.
73    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/// Common CLI arguments that can be mixed into any command.
102#[derive(Parser, Debug, Clone)]
103pub struct CommonArgs {
104    /// Output format: table, json, or compact
105    #[arg(short, long, default_value = "table")]
106    pub format: String,
107
108    /// Suppress non-essential output
109    #[arg(short, long)]
110    pub quiet: bool,
111
112    /// Enable verbose output
113    #[arg(short, long)]
114    pub verbose: bool,
115
116    /// Disable colored output
117    #[arg(long)]
118    pub no_color: bool,
119}
120
121impl CommonArgs {
122    /// Convert to Cli config.
123    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
140/// Terminal styling helpers.
141pub mod styles {
142    /// ANSI color codes for consistent styling.
143    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    /// Format a success message.
157    pub fn success(msg: &str) -> String {
158        format!("{}✓{} {}", Colors::GREEN, Colors::RESET, msg)
159    }
160
161    /// Format an error message.
162    pub fn error(msg: &str) -> String {
163        format!("{}✗{} {}", Colors::RED, Colors::RESET, msg)
164    }
165
166    /// Format a warning message.
167    pub fn warning(msg: &str) -> String {
168        format!("{}⚠{} {}", Colors::YELLOW, Colors::RESET, msg)
169    }
170
171    /// Format an info message.
172    pub fn info(msg: &str) -> String {
173        format!("{}ℹ{} {}", Colors::BLUE, Colors::RESET, msg)
174    }
175
176    /// Format a header/title.
177    pub fn header(msg: &str) -> String {
178        format!("{}{}{}", Colors::BOLD, msg, Colors::RESET)
179    }
180
181    /// Format a dim/secondary message.
182    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        // "text" is alias for Table
267        assert_eq!(
268            "text"
269                .parse::<OutputFormat>()
270                .expect("parsing should succeed"),
271            OutputFormat::Table
272        );
273        // "line" is alias for Compact
274        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        // Should fallback to default (Table) on parse error
365        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}