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    /// Format a command result for output
32    pub fn format(&self, result: &CommandResult) -> String {
33        match (self.json_mode, self.quiet_mode) {
34            // JSON mode always outputs JSON regardless of quiet flag
35            (true, _) => serde_json::to_string(result).unwrap_or_else(|_| {
36                json!({
37                    "status": "error",
38                    "command": "unknown",
39                    "message": "Failed to serialize response"
40                })
41                .to_string()
42            }),
43            // Quiet mode suppresses output
44            (false, true) => String::new(),
45            // Text mode with output
46            (false, false) => Self::format_text(result),
47        }
48    }
49
50    fn format_text(result: &CommandResult) -> String {
51        match result.status.as_str() {
52            "success" => {
53                let mut output = format!("✓ {} succeeded", result.command);
54
55                if !result.warnings.is_empty() {
56                    output.push_str("\n\nWarnings:");
57                    for warning in &result.warnings {
58                        output.push_str(&format!("\n  • {warning}"));
59                    }
60                }
61
62                output
63            },
64            "validation-failed" => {
65                let mut output = format!("✗ {} validation failed", result.command);
66
67                if !result.errors.is_empty() {
68                    output.push_str("\n\nErrors:");
69                    for error in &result.errors {
70                        output.push_str(&format!("\n  • {error}"));
71                    }
72                }
73
74                output
75            },
76            "error" => {
77                let mut output = format!("✗ {} error", result.command);
78
79                if let Some(msg) = &result.message {
80                    output.push_str(&format!("\n  {msg}"));
81                }
82
83                if let Some(code) = &result.code {
84                    output.push_str(&format!("\n  Code: {code}"));
85                }
86
87                output
88            },
89            _ => format!("? {} - unknown status: {}", result.command, result.status),
90        }
91    }
92}
93
94/// Result of a CLI command execution
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct CommandResult {
97    /// Status of the command: "success", "error", "validation-failed"
98    pub status: String,
99
100    /// Name of the command that was executed
101    pub command: String,
102
103    /// Primary data/output from the command
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub data: Option<Value>,
106
107    /// Error message (if status is "error")
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub message: Option<String>,
110
111    /// Error code (if status is "error")
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub code: Option<String>,
114
115    /// Validation errors (if status is "validation-failed")
116    #[serde(skip_serializing_if = "Vec::is_empty")]
117    pub errors: Vec<String>,
118
119    /// Warnings that occurred during execution
120    #[serde(skip_serializing_if = "Vec::is_empty")]
121    pub warnings: Vec<String>,
122}
123
124// ============================================================================
125// AI Agent Introspection Types
126// ============================================================================
127
128/// Complete CLI help information for AI agents
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct CliHelp {
131    /// CLI name
132    pub name: String,
133
134    /// CLI version
135    pub version: String,
136
137    /// CLI description
138    pub about: String,
139
140    /// Global options available on all commands
141    pub global_options: Vec<ArgumentHelp>,
142
143    /// Available subcommands
144    pub subcommands: Vec<CommandHelp>,
145
146    /// Exit codes used by the CLI
147    pub exit_codes: Vec<ExitCodeHelp>,
148}
149
150/// Help information for a single command
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct CommandHelp {
153    /// Command name
154    pub name: String,
155
156    /// Command description
157    pub about: String,
158
159    /// Positional arguments
160    pub arguments: Vec<ArgumentHelp>,
161
162    /// Optional flags and options
163    pub options: Vec<ArgumentHelp>,
164
165    /// Nested subcommands (if any)
166    #[serde(skip_serializing_if = "Vec::is_empty")]
167    pub subcommands: Vec<CommandHelp>,
168
169    /// Example invocations
170    #[serde(skip_serializing_if = "Vec::is_empty")]
171    pub examples: Vec<String>,
172}
173
174/// Help information for a single argument or option
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct ArgumentHelp {
177    /// Argument name
178    pub name: String,
179
180    /// Short flag (e.g., "-v")
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub short: Option<String>,
183
184    /// Long flag (e.g., "--verbose")
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub long: Option<String>,
187
188    /// Help text
189    pub help: String,
190
191    /// Whether this argument is required
192    pub required: bool,
193
194    /// Default value if any
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub default_value: Option<String>,
197
198    /// Whether this option takes a value
199    pub takes_value: bool,
200
201    /// Possible values (for enums/choices)
202    #[serde(skip_serializing_if = "Vec::is_empty")]
203    pub possible_values: Vec<String>,
204}
205
206/// Exit code documentation
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct ExitCodeHelp {
209    /// Numeric exit code
210    pub code: i32,
211
212    /// Name/identifier for the code
213    pub name: String,
214
215    /// Description of when this code is returned
216    pub description: String,
217}
218
219/// Output schema for a command (JSON Schema format)
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct OutputSchema {
222    /// Command this schema applies to
223    pub command: String,
224
225    /// Schema version
226    pub schema_version: String,
227
228    /// Output format (always "json")
229    pub format: String,
230
231    /// Schema for successful response
232    pub success: serde_json::Value,
233
234    /// Schema for error response
235    pub error: serde_json::Value,
236}
237
238/// Summary of a command for listing
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct CommandSummary {
241    /// Command name
242    pub name: String,
243
244    /// Brief description
245    pub description: String,
246
247    /// Whether this command has subcommands
248    pub has_subcommands: bool,
249}
250
251/// Get the standard exit codes used by the CLI
252pub fn get_exit_codes() -> Vec<ExitCodeHelp> {
253    vec![
254        ExitCodeHelp {
255            code:        0,
256            name:        "success".to_string(),
257            description: "Command completed successfully".to_string(),
258        },
259        ExitCodeHelp {
260            code:        1,
261            name:        "error".to_string(),
262            description: "Command failed with an error".to_string(),
263        },
264        ExitCodeHelp {
265            code:        2,
266            name:        "validation_failed".to_string(),
267            description: "Validation failed (schema or input invalid)".to_string(),
268        },
269    ]
270}
271
272impl CommandResult {
273    /// Create a successful command result with data
274    pub fn success(command: &str, data: Value) -> Self {
275        Self {
276            status:   "success".to_string(),
277            command:  command.to_string(),
278            data:     Some(data),
279            message:  None,
280            code:     None,
281            errors:   Vec::new(),
282            warnings: Vec::new(),
283        }
284    }
285
286    /// Create a successful command result with warnings
287    pub fn success_with_warnings(command: &str, data: Value, warnings: Vec<String>) -> Self {
288        Self {
289            status: "success".to_string(),
290            command: command.to_string(),
291            data: Some(data),
292            message: None,
293            code: None,
294            errors: Vec::new(),
295            warnings,
296        }
297    }
298
299    /// Create an error result
300    pub fn error(command: &str, message: &str, code: &str) -> Self {
301        Self {
302            status:   "error".to_string(),
303            command:  command.to_string(),
304            data:     None,
305            message:  Some(message.to_string()),
306            code:     Some(code.to_string()),
307            errors:   Vec::new(),
308            warnings: Vec::new(),
309        }
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn test_output_formatter_json_mode_success() {
319        let formatter = OutputFormatter::new(true, false);
320
321        let result = CommandResult::success(
322            "compile",
323            json!({
324                "files_compiled": 2,
325                "output_file": "schema.compiled.json"
326            }),
327        );
328
329        let output = formatter.format(&result);
330        assert!(!output.is_empty());
331
332        // Verify it's valid JSON
333        let parsed: serde_json::Value =
334            serde_json::from_str(&output).expect("Output must be valid JSON");
335        assert_eq!(parsed["status"], "success");
336        assert_eq!(parsed["command"], "compile");
337    }
338
339    #[test]
340    fn test_output_formatter_text_mode_success() {
341        let formatter = OutputFormatter::new(false, false);
342
343        let result = CommandResult::success("compile", json!({}));
344        let output = formatter.format(&result);
345
346        assert!(!output.is_empty());
347        assert!(output.contains("compile"));
348        assert!(output.contains("✓"));
349    }
350
351    #[test]
352    fn test_output_formatter_quiet_mode() {
353        let formatter = OutputFormatter::new(false, true);
354
355        let result = CommandResult::success("compile", json!({}));
356        let output = formatter.format(&result);
357
358        assert_eq!(output, "");
359    }
360
361    #[test]
362    fn test_output_formatter_json_mode_error() {
363        let formatter = OutputFormatter::new(true, false);
364
365        let result = CommandResult::error("compile", "Parse error", "PARSE_ERROR");
366
367        let output = formatter.format(&result);
368        assert!(!output.is_empty());
369
370        let parsed: serde_json::Value =
371            serde_json::from_str(&output).expect("Output must be valid JSON");
372        assert_eq!(parsed["status"], "error");
373        assert_eq!(parsed["command"], "compile");
374        assert_eq!(parsed["code"], "PARSE_ERROR");
375    }
376
377    #[test]
378    fn test_command_result_preserves_data() {
379        let data = json!({
380            "count": 42,
381            "nested": {
382                "value": "test"
383            }
384        });
385
386        let result = CommandResult::success("test", data.clone());
387
388        // Data should be preserved exactly
389        assert_eq!(result.data, Some(data));
390    }
391
392    #[test]
393    fn test_output_formatter_with_warnings() {
394        let formatter = OutputFormatter::new(true, false);
395
396        let result = CommandResult::success_with_warnings(
397            "compile",
398            json!({ "status": "ok" }),
399            vec!["Optimization opportunity: add index to User.id".to_string()],
400        );
401
402        let output = formatter.format(&result);
403        let parsed: serde_json::Value = serde_json::from_str(&output).expect("Valid JSON");
404
405        assert_eq!(parsed["status"], "success");
406        assert!(parsed["warnings"].is_array());
407    }
408
409    #[test]
410    fn test_text_mode_shows_status() {
411        let formatter = OutputFormatter::new(false, false);
412
413        let result = CommandResult::success("compile", json!({}));
414        let output = formatter.format(&result);
415
416        // Should contain some indication of success
417        assert!(output.to_lowercase().contains("success") || output.contains("✓"));
418    }
419
420    #[test]
421    fn test_text_mode_shows_error() {
422        let formatter = OutputFormatter::new(false, false);
423
424        let result = CommandResult::error("compile", "File not found", "FILE_NOT_FOUND");
425        let output = formatter.format(&result);
426
427        assert!(
428            output.to_lowercase().contains("error")
429                || output.contains("✗")
430                || output.contains("file")
431        );
432    }
433
434    #[test]
435    fn test_quiet_mode_suppresses_all_output() {
436        let formatter = OutputFormatter::new(false, true);
437
438        let success = CommandResult::success("compile", json!({}));
439        let error = CommandResult::error("validate", "Invalid", "INVALID");
440
441        assert_eq!(formatter.format(&success), "");
442        assert_eq!(formatter.format(&error), "");
443    }
444
445    #[test]
446    fn test_json_mode_ignores_quiet_flag() {
447        // JSON mode should always output JSON, even with quiet=true
448        let formatter = OutputFormatter::new(true, true);
449
450        let result = CommandResult::success("compile", json!({}));
451        let output = formatter.format(&result);
452
453        // Should still produce JSON
454        let parsed: serde_json::Value =
455            serde_json::from_str(&output).expect("Should be valid JSON");
456        assert_eq!(parsed["status"], "success");
457    }
458}