Skip to main content

fraiseql_cli/output/
mod.rs

1//! Output formatting for CLI commands
2//!
3//! Supports three output modes:
4//! - JSON: Machine-readable structured output for agents
5//! - Text: Human-readable formatted output
6//! - Quiet: No output (exit code only)
7
8use serde::{Deserialize, Serialize};
9use serde_json::{Value, json};
10
11/// Formats command output in different modes
12#[derive(Debug, Clone)]
13pub struct OutputFormatter {
14    json_mode:  bool,
15    quiet_mode: bool,
16}
17
18impl OutputFormatter {
19    /// Create a new output formatter
20    ///
21    /// # Arguments
22    /// * `json_mode` - If true, output JSON; otherwise output text
23    /// * `quiet_mode` - If true and not in JSON mode, suppress all output
24    pub const fn new(json_mode: bool, quiet_mode: bool) -> Self {
25        Self {
26            json_mode,
27            quiet_mode,
28        }
29    }
30
31    /// Whether output is JSON (the `--json` flag).
32    #[must_use]
33    pub const fn is_json(&self) -> bool {
34        self.json_mode
35    }
36
37    /// Format a command result for output
38    pub fn format(&self, result: &CommandResult) -> String {
39        match (self.json_mode, self.quiet_mode) {
40            // JSON mode always outputs JSON regardless of quiet flag
41            (true, _) => serde_json::to_string(result).unwrap_or_else(|_| {
42                json!({
43                    "status": "error",
44                    "command": "unknown",
45                    "message": "Failed to serialize response"
46                })
47                .to_string()
48            }),
49            // Quiet mode suppresses output
50            (false, true) => String::new(),
51            // Text mode with output
52            (false, false) => Self::format_text(result),
53        }
54    }
55
56    /// Print a progress message to stderr.
57    ///
58    /// Goes to stderr (not stdout) so it does not pollute data pipelines.
59    /// Suppressed in quiet mode and JSON mode.
60    pub fn progress(&self, msg: &str) {
61        if !self.quiet_mode && !self.json_mode {
62            eprintln!("{msg}");
63        }
64    }
65
66    /// Print a section header to stderr.
67    pub fn section(&self, title: &str) {
68        self.progress(&format!("==> {title}"));
69    }
70
71    fn format_text(result: &CommandResult) -> String {
72        match result.status.as_str() {
73            "success" => {
74                let mut output = format!("✓ {} succeeded", result.command);
75
76                if !result.warnings.is_empty() {
77                    output.push_str("\n\nWarnings:");
78                    for warning in &result.warnings {
79                        output.push_str(&format!("\n  • {warning}"));
80                    }
81                }
82
83                output
84            },
85            "validation-failed" => {
86                let mut output = format!("✗ {} validation failed", result.command);
87
88                if !result.errors.is_empty() {
89                    output.push_str("\n\nErrors:");
90                    for error in &result.errors {
91                        output.push_str(&format!("\n  • {error}"));
92                    }
93                }
94
95                output
96            },
97            "error" => {
98                let mut output = format!("✗ {} error", result.command);
99
100                if let Some(msg) = &result.message {
101                    output.push_str(&format!("\n  {msg}"));
102                }
103
104                if let Some(code) = &result.code {
105                    output.push_str(&format!("\n  Code: {code}"));
106                }
107
108                output
109            },
110            _ => format!("? {} - unknown status: {}", result.command, result.status),
111        }
112    }
113}
114
115/// Result of a CLI command execution
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct CommandResult {
118    /// Status of the command: "success", "error", "validation-failed"
119    pub status: String,
120
121    /// Name of the command that was executed
122    pub command: String,
123
124    /// Primary data/output from the command
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub data: Option<Value>,
127
128    /// Error message (if status is "error")
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub message: Option<String>,
131
132    /// Error code (if status is "error")
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub code: Option<String>,
135
136    /// Validation errors (if status is "validation-failed")
137    #[serde(skip_serializing_if = "Vec::is_empty")]
138    pub errors: Vec<String>,
139
140    /// Warnings that occurred during execution
141    #[serde(skip_serializing_if = "Vec::is_empty")]
142    pub warnings: Vec<String>,
143}
144
145// ============================================================================
146// AI Agent Introspection Types
147// ============================================================================
148
149/// Complete CLI help information for AI agents
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct CliHelp {
152    /// CLI name
153    pub name: String,
154
155    /// CLI version
156    pub version: String,
157
158    /// CLI description
159    pub about: String,
160
161    /// Global options available on all commands
162    pub global_options: Vec<ArgumentHelp>,
163
164    /// Available subcommands
165    pub subcommands: Vec<CommandHelp>,
166
167    /// Exit codes used by the CLI
168    pub exit_codes: Vec<ExitCodeHelp>,
169}
170
171/// Help information for a single command
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct CommandHelp {
174    /// Command name
175    pub name: String,
176
177    /// Command description
178    pub about: String,
179
180    /// Positional arguments
181    pub arguments: Vec<ArgumentHelp>,
182
183    /// Optional flags and options
184    pub options: Vec<ArgumentHelp>,
185
186    /// Nested subcommands (if any)
187    #[serde(skip_serializing_if = "Vec::is_empty")]
188    pub subcommands: Vec<CommandHelp>,
189
190    /// Example invocations
191    #[serde(skip_serializing_if = "Vec::is_empty")]
192    pub examples: Vec<String>,
193}
194
195/// Help information for a single argument or option
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct ArgumentHelp {
198    /// Argument name
199    pub name: String,
200
201    /// Short flag (e.g., "-v")
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub short: Option<String>,
204
205    /// Long flag (e.g., "--verbose")
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub long: Option<String>,
208
209    /// Help text
210    pub help: String,
211
212    /// Whether this argument is required
213    pub required: bool,
214
215    /// Default value if any
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub default_value: Option<String>,
218
219    /// Whether this option takes a value
220    pub takes_value: bool,
221
222    /// Possible values (for enums/choices)
223    #[serde(skip_serializing_if = "Vec::is_empty")]
224    pub possible_values: Vec<String>,
225}
226
227/// Exit code documentation
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct ExitCodeHelp {
230    /// Numeric exit code
231    pub code: i32,
232
233    /// Name/identifier for the code
234    pub name: String,
235
236    /// Description of when this code is returned
237    pub description: String,
238}
239
240/// Output schema for a command (JSON Schema format)
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct OutputSchema {
243    /// Command this schema applies to
244    pub command: String,
245
246    /// Schema version
247    pub schema_version: String,
248
249    /// Output format (always "json")
250    pub format: String,
251
252    /// Schema for successful response
253    pub success: serde_json::Value,
254
255    /// Schema for error response
256    pub error: serde_json::Value,
257}
258
259/// Summary of a command for listing
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct CommandSummary {
262    /// Command name
263    pub name: String,
264
265    /// Brief description
266    pub description: String,
267
268    /// Whether this command has subcommands
269    pub has_subcommands: bool,
270}
271
272/// Get the standard exit codes used by the CLI
273pub fn get_exit_codes() -> Vec<ExitCodeHelp> {
274    vec![
275        ExitCodeHelp {
276            code:        0,
277            name:        "success".to_string(),
278            description: "Command completed successfully".to_string(),
279        },
280        ExitCodeHelp {
281            code:        1,
282            name:        "error".to_string(),
283            description: "Command failed with an error".to_string(),
284        },
285        ExitCodeHelp {
286            code:        2,
287            name:        "validation_failed".to_string(),
288            description: "Validation failed (schema or input invalid)".to_string(),
289        },
290    ]
291}
292
293impl CommandResult {
294    /// Create a successful command result with data
295    pub fn success(command: &str, data: Value) -> Self {
296        Self {
297            status:   "success".to_string(),
298            command:  command.to_string(),
299            data:     Some(data),
300            message:  None,
301            code:     None,
302            errors:   Vec::new(),
303            warnings: Vec::new(),
304        }
305    }
306
307    /// Create a successful command result with warnings
308    pub fn success_with_warnings(command: &str, data: Value, warnings: Vec<String>) -> Self {
309        Self {
310            status: "success".to_string(),
311            command: command.to_string(),
312            data: Some(data),
313            message: None,
314            code: None,
315            errors: Vec::new(),
316            warnings,
317        }
318    }
319
320    /// Create an error result
321    pub fn error(command: &str, message: &str, code: &str) -> Self {
322        Self {
323            status:   "error".to_string(),
324            command:  command.to_string(),
325            data:     None,
326            message:  Some(message.to_string()),
327            code:     Some(code.to_string()),
328            errors:   Vec::new(),
329            warnings: Vec::new(),
330        }
331    }
332
333    /// Create a validation-failure result.
334    ///
335    /// Use this when the command ran successfully but the artifact it checked
336    /// failed its gate (e.g. a lint quality gate or a federation composition
337    /// check). The runner maps this status to exit code 2 — distinct from
338    /// operational errors (exit 1). See `crate::runner` for the contract.
339    pub fn validation_failed(command: &str, errors: Vec<String>, code: &str) -> Self {
340        Self {
341            status: "validation-failed".to_string(),
342            command: command.to_string(),
343            data: None,
344            message: None,
345            code: Some(code.to_string()),
346            errors,
347            warnings: Vec::new(),
348        }
349    }
350}
351
352#[cfg(test)]
353mod tests;