Skip to main content

blz_cli/args/
output.rs

1//! Output format argument groups for CLI commands.
2//!
3//! This module provides reusable output format arguments that can be composed
4//! across multiple commands using clap's `#[command(flatten)]` attribute.
5//!
6//! # Examples
7//!
8//! ```bash
9//! blz search "async" --format json
10//! blz search "async" --json        # Shorthand
11//! blz list --format text
12//! ```
13
14use clap::{Args, ValueEnum};
15use is_terminal::IsTerminal;
16use serde::{Deserialize, Serialize};
17
18/// Output format for CLI results.
19///
20/// Determines how command output is formatted and displayed.
21#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum)]
22#[serde(rename_all = "lowercase")]
23pub enum OutputFormat {
24    /// Human-readable formatted text (default for terminals).
25    #[default]
26    Text,
27    /// JSON format for machine consumption (default for pipes).
28    Json,
29    /// JSON Lines format (one JSON object per line).
30    #[value(alias = "ndjson")]
31    Jsonl,
32    /// Raw content without any formatting.
33    Raw,
34}
35
36impl OutputFormat {
37    /// Check if this format is machine-readable (JSON or JSONL).
38    #[must_use]
39    pub const fn is_machine_readable(self) -> bool {
40        matches!(self, Self::Json | Self::Jsonl)
41    }
42
43    /// Check if this format is human-readable (Text).
44    #[must_use]
45    pub const fn is_human_readable(self) -> bool {
46        matches!(self, Self::Text)
47    }
48
49    /// Detect the best format based on terminal status.
50    ///
51    /// Returns `Text` for interactive terminals, `Json` for pipes/redirects.
52    #[must_use]
53    pub fn detect() -> Self {
54        if std::io::stdout().is_terminal() {
55            Self::Text
56        } else {
57            Self::Json
58        }
59    }
60}
61
62impl std::fmt::Display for OutputFormat {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Self::Text => write!(f, "text"),
66            Self::Json => write!(f, "json"),
67            Self::Jsonl => write!(f, "jsonl"),
68            Self::Raw => write!(f, "raw"),
69        }
70    }
71}
72
73/// Shared output format arguments for commands that produce formatted output.
74///
75/// This group provides consistent format selection across commands with
76/// automatic TTY detection for sensible defaults.
77///
78/// # Default Behavior
79///
80/// When no format is explicitly specified:
81/// - Interactive terminal: `Text` (human-readable)
82/// - Piped/redirected output: `Json` (machine-readable)
83///
84/// # Usage
85///
86/// Flatten into command structs:
87///
88/// ```ignore
89/// #[derive(Args)]
90/// struct SearchArgs {
91///     #[command(flatten)]
92///     output: OutputArgs,
93///     // ... other args
94/// }
95/// ```
96///
97/// Then resolve to an `OutputFormat`:
98///
99/// ```ignore
100/// let format = args.output.resolve();
101/// ```
102#[derive(Args, Clone, Debug, Default, PartialEq, Eq)]
103pub struct OutputArgs {
104    /// Output format (text, json, jsonl, raw).
105    ///
106    /// Defaults to text for terminals, json for pipes.
107    #[arg(
108        short = 'f',
109        long = "format",
110        value_enum,
111        env = "BLZ_OUTPUT_FORMAT",
112        display_order = 44
113    )]
114    pub format: Option<OutputFormat>,
115
116    /// Output as JSON (shorthand for --format json).
117    #[arg(long, conflicts_with = "format", display_order = 40)]
118    pub json: bool,
119
120    /// Output as JSON Lines (shorthand for --format jsonl).
121    #[arg(long, conflicts_with_all = ["format", "json"], display_order = 41)]
122    pub jsonl: bool,
123
124    /// Output as plain text (shorthand for --format text).
125    #[arg(long, conflicts_with_all = ["format", "json", "jsonl"], display_order = 42)]
126    pub text: bool,
127}
128
129impl OutputArgs {
130    /// Create output args with a specific format.
131    #[must_use]
132    pub const fn with_format(format: OutputFormat) -> Self {
133        Self {
134            format: Some(format),
135            json: false,
136            jsonl: false,
137            text: false,
138        }
139    }
140
141    /// Resolve the output arguments to a concrete format.
142    ///
143    /// Priority order:
144    /// 1. Shorthand flags (--json, --jsonl, --text)
145    /// 2. Explicit --format flag
146    /// 3. Automatic TTY detection
147    #[must_use]
148    pub fn resolve(&self) -> OutputFormat {
149        // Shorthand flags take priority
150        if self.json {
151            return OutputFormat::Json;
152        }
153        if self.jsonl {
154            return OutputFormat::Jsonl;
155        }
156        if self.text {
157            return OutputFormat::Text;
158        }
159
160        // Explicit format flag
161        if let Some(format) = self.format {
162            return format;
163        }
164
165        // Automatic TTY detection
166        OutputFormat::detect()
167    }
168
169    /// Check if any format was explicitly specified.
170    #[must_use]
171    pub const fn is_explicit(&self) -> bool {
172        self.format.is_some() || self.json || self.jsonl || self.text
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    mod output_format {
181        use super::*;
182
183        #[test]
184        fn test_default_is_text() {
185            assert_eq!(OutputFormat::default(), OutputFormat::Text);
186        }
187
188        #[test]
189        fn test_is_machine_readable() {
190            assert!(OutputFormat::Json.is_machine_readable());
191            assert!(OutputFormat::Jsonl.is_machine_readable());
192            assert!(!OutputFormat::Text.is_machine_readable());
193            assert!(!OutputFormat::Raw.is_machine_readable());
194        }
195
196        #[test]
197        fn test_is_human_readable() {
198            assert!(OutputFormat::Text.is_human_readable());
199            assert!(!OutputFormat::Json.is_human_readable());
200            assert!(!OutputFormat::Jsonl.is_human_readable());
201            assert!(!OutputFormat::Raw.is_human_readable());
202        }
203
204        #[test]
205        fn test_display() {
206            assert_eq!(OutputFormat::Text.to_string(), "text");
207            assert_eq!(OutputFormat::Json.to_string(), "json");
208            assert_eq!(OutputFormat::Jsonl.to_string(), "jsonl");
209            assert_eq!(OutputFormat::Raw.to_string(), "raw");
210        }
211    }
212
213    mod output_args {
214        use super::*;
215
216        #[test]
217        fn test_default() {
218            let args = OutputArgs::default();
219            assert_eq!(args.format, None);
220            assert!(!args.json);
221            assert!(!args.jsonl);
222            assert!(!args.text);
223            assert!(!args.is_explicit());
224        }
225
226        #[test]
227        fn test_with_format() {
228            let args = OutputArgs::with_format(OutputFormat::Json);
229            assert_eq!(args.format, Some(OutputFormat::Json));
230            assert!(args.is_explicit());
231        }
232
233        #[test]
234        fn test_resolve_json_flag() {
235            let args = OutputArgs {
236                format: None,
237                json: true,
238                jsonl: false,
239                text: false,
240            };
241            assert_eq!(args.resolve(), OutputFormat::Json);
242        }
243
244        #[test]
245        fn test_resolve_jsonl_flag() {
246            let args = OutputArgs {
247                format: None,
248                json: false,
249                jsonl: true,
250                text: false,
251            };
252            assert_eq!(args.resolve(), OutputFormat::Jsonl);
253        }
254
255        #[test]
256        fn test_resolve_text_flag() {
257            let args = OutputArgs {
258                format: None,
259                json: false,
260                jsonl: false,
261                text: true,
262            };
263            assert_eq!(args.resolve(), OutputFormat::Text);
264        }
265
266        #[test]
267        fn test_resolve_explicit_format() {
268            let args = OutputArgs {
269                format: Some(OutputFormat::Raw),
270                json: false,
271                jsonl: false,
272                text: false,
273            };
274            assert_eq!(args.resolve(), OutputFormat::Raw);
275        }
276
277        #[test]
278        fn test_json_flag_takes_precedence_over_format() {
279            // This shouldn't happen due to conflicts_with, but test the logic
280            let args = OutputArgs {
281                format: Some(OutputFormat::Text),
282                json: true,
283                jsonl: false,
284                text: false,
285            };
286            // Shorthand flags take priority
287            assert_eq!(args.resolve(), OutputFormat::Json);
288        }
289    }
290}