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/// Context for command execution - holds formatter and logging options
12#[derive(Debug, Clone)]
13#[allow(dead_code)]
14pub struct CliContext {
15    /// Output formatter (JSON/text/quiet mode)
16    pub formatter: OutputFormatter,
17    /// Enable verbose logging
18    pub verbose:   bool,
19    /// Enable debug logging
20    pub debug:     bool,
21}
22
23impl CliContext {
24    /// Create a new CLI context
25    #[allow(
26        dead_code,
27        clippy::too_many_arguments,
28        clippy::fn_params_excessive_bools,
29        clippy::missing_const_for_fn
30    )]
31    pub fn new(json_mode: bool, quiet_mode: bool, verbose: bool, debug: bool) -> Self {
32        Self {
33            formatter: OutputFormatter::new(json_mode, quiet_mode),
34            verbose,
35            debug,
36        }
37    }
38
39    /// Print a result and return the exit code
40    #[allow(dead_code)]
41    pub fn print_result(&self, result: &CommandResult) -> i32 {
42        let output = self.formatter.format(result);
43        if !output.is_empty() {
44            println!("{output}");
45        }
46        result.exit_code
47    }
48}
49
50/// Formats command output in different modes
51#[derive(Debug, Clone)]
52pub struct OutputFormatter {
53    json_mode:  bool,
54    quiet_mode: bool,
55}
56
57impl OutputFormatter {
58    /// Create a new output formatter
59    ///
60    /// # Arguments
61    /// * `json_mode` - If true, output JSON; otherwise output text
62    /// * `quiet_mode` - If true and not in JSON mode, suppress all output
63    pub const fn new(json_mode: bool, quiet_mode: bool) -> Self {
64        Self {
65            json_mode,
66            quiet_mode,
67        }
68    }
69
70    /// Format a command result for output
71    pub fn format(&self, result: &CommandResult) -> String {
72        match (self.json_mode, self.quiet_mode) {
73            // JSON mode always outputs JSON regardless of quiet flag
74            (true, _) => serde_json::to_string(result).unwrap_or_else(|_| {
75                json!({
76                    "status": "error",
77                    "command": "unknown",
78                    "message": "Failed to serialize response"
79                })
80                .to_string()
81            }),
82            // Quiet mode suppresses output
83            (false, true) => String::new(),
84            // Text mode with output
85            (false, false) => Self::format_text(result),
86        }
87    }
88
89    fn format_text(result: &CommandResult) -> String {
90        match result.status.as_str() {
91            "success" => {
92                let mut output = format!("✓ {} succeeded", result.command);
93
94                if !result.warnings.is_empty() {
95                    output.push_str("\n\nWarnings:");
96                    for warning in &result.warnings {
97                        output.push_str(&format!("\n  • {warning}"));
98                    }
99                }
100
101                output
102            },
103            "validation-failed" => {
104                let mut output = format!("✗ {} validation failed", result.command);
105
106                if !result.errors.is_empty() {
107                    output.push_str("\n\nErrors:");
108                    for error in &result.errors {
109                        output.push_str(&format!("\n  • {error}"));
110                    }
111                }
112
113                output
114            },
115            "error" => {
116                let mut output = format!("✗ {} error", result.command);
117
118                if let Some(msg) = &result.message {
119                    output.push_str(&format!("\n  {msg}"));
120                }
121
122                if let Some(code) = &result.code {
123                    output.push_str(&format!("\n  Code: {code}"));
124                }
125
126                output
127            },
128            _ => format!("? {} - unknown status: {}", result.command, result.status),
129        }
130    }
131}
132
133/// Result of a CLI command execution
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct CommandResult {
136    /// Status of the command: "success", "error", "validation-failed"
137    pub status: String,
138
139    /// Name of the command that was executed
140    pub command: String,
141
142    /// Primary data/output from the command
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub data: Option<Value>,
145
146    /// Error message (if status is "error")
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub message: Option<String>,
149
150    /// Error code (if status is "error")
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub code: Option<String>,
153
154    /// Validation errors (if status is "validation-failed")
155    #[serde(skip_serializing_if = "Vec::is_empty")]
156    pub errors: Vec<String>,
157
158    /// Warnings that occurred during execution
159    #[serde(skip_serializing_if = "Vec::is_empty")]
160    pub warnings: Vec<String>,
161
162    /// Exit code for the process: 0=success, 1=error, 2=validation-failed
163    #[serde(skip)]
164    #[allow(dead_code)]
165    pub exit_code: i32,
166}
167
168// ============================================================================
169// AI Agent Introspection Types
170// ============================================================================
171
172/// Complete CLI help information for AI agents
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct CliHelp {
175    /// CLI name
176    pub name: String,
177
178    /// CLI version
179    pub version: String,
180
181    /// CLI description
182    pub about: String,
183
184    /// Global options available on all commands
185    pub global_options: Vec<ArgumentHelp>,
186
187    /// Available subcommands
188    pub subcommands: Vec<CommandHelp>,
189
190    /// Exit codes used by the CLI
191    pub exit_codes: Vec<ExitCodeHelp>,
192}
193
194/// Help information for a single command
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct CommandHelp {
197    /// Command name
198    pub name: String,
199
200    /// Command description
201    pub about: String,
202
203    /// Positional arguments
204    pub arguments: Vec<ArgumentHelp>,
205
206    /// Optional flags and options
207    pub options: Vec<ArgumentHelp>,
208
209    /// Nested subcommands (if any)
210    #[serde(skip_serializing_if = "Vec::is_empty")]
211    pub subcommands: Vec<CommandHelp>,
212
213    /// Example invocations
214    #[serde(skip_serializing_if = "Vec::is_empty")]
215    pub examples: Vec<String>,
216}
217
218/// Help information for a single argument or option
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct ArgumentHelp {
221    /// Argument name
222    pub name: String,
223
224    /// Short flag (e.g., "-v")
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub short: Option<String>,
227
228    /// Long flag (e.g., "--verbose")
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub long: Option<String>,
231
232    /// Help text
233    pub help: String,
234
235    /// Whether this argument is required
236    pub required: bool,
237
238    /// Default value if any
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub default_value: Option<String>,
241
242    /// Whether this option takes a value
243    pub takes_value: bool,
244
245    /// Possible values (for enums/choices)
246    #[serde(skip_serializing_if = "Vec::is_empty")]
247    pub possible_values: Vec<String>,
248}
249
250/// Exit code documentation
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct ExitCodeHelp {
253    /// Numeric exit code
254    pub code: i32,
255
256    /// Name/identifier for the code
257    pub name: String,
258
259    /// Description of when this code is returned
260    pub description: String,
261}
262
263/// Output schema for a command (JSON Schema format)
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct OutputSchema {
266    /// Command this schema applies to
267    pub command: String,
268
269    /// Schema version
270    pub schema_version: String,
271
272    /// Output format (always "json")
273    pub format: String,
274
275    /// Schema for successful response
276    pub success: serde_json::Value,
277
278    /// Schema for error response
279    pub error: serde_json::Value,
280}
281
282/// Summary of a command for listing
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct CommandSummary {
285    /// Command name
286    pub name: String,
287
288    /// Brief description
289    pub description: String,
290
291    /// Whether this command has subcommands
292    pub has_subcommands: bool,
293}
294
295/// Get the standard exit codes used by the CLI
296pub fn get_exit_codes() -> Vec<ExitCodeHelp> {
297    vec![
298        ExitCodeHelp {
299            code:        0,
300            name:        "success".to_string(),
301            description: "Command completed successfully".to_string(),
302        },
303        ExitCodeHelp {
304            code:        1,
305            name:        "error".to_string(),
306            description: "Command failed with an error".to_string(),
307        },
308        ExitCodeHelp {
309            code:        2,
310            name:        "validation_failed".to_string(),
311            description: "Validation failed (schema or input invalid)".to_string(),
312        },
313    ]
314}
315
316impl CommandResult {
317    /// Create a successful command result with data
318    pub fn success(command: &str, data: Value) -> Self {
319        Self {
320            status:    "success".to_string(),
321            command:   command.to_string(),
322            data:      Some(data),
323            message:   None,
324            code:      None,
325            errors:    Vec::new(),
326            warnings:  Vec::new(),
327            exit_code: 0,
328        }
329    }
330
331    /// Create a successful command result with warnings
332    pub fn success_with_warnings(command: &str, data: Value, warnings: Vec<String>) -> Self {
333        Self {
334            status: "success".to_string(),
335            command: command.to_string(),
336            data: Some(data),
337            message: None,
338            code: None,
339            errors: Vec::new(),
340            warnings,
341            exit_code: 0,
342        }
343    }
344
345    /// Create an error result
346    pub fn error(command: &str, message: &str, code: &str) -> Self {
347        Self {
348            status:    "error".to_string(),
349            command:   command.to_string(),
350            data:      None,
351            message:   Some(message.to_string()),
352            code:      Some(code.to_string()),
353            errors:    Vec::new(),
354            warnings:  Vec::new(),
355            exit_code: 1,
356        }
357    }
358
359    /// Create a validation failure result
360    #[allow(dead_code)]
361    pub fn validation_failed(command: &str, errors: Vec<String>) -> Self {
362        Self {
363            status: "validation-failed".to_string(),
364            command: command.to_string(),
365            data: None,
366            message: None,
367            code: None,
368            errors,
369            warnings: Vec::new(),
370            exit_code: 2,
371        }
372    }
373
374    /// Create an error result from an anyhow::Error
375    #[allow(dead_code)]
376    pub fn from_error(command: &str, error: anyhow::Error) -> Self {
377        Self::error(command, &error.to_string(), "INTERNAL_ERROR")
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn test_output_formatter_json_mode_success() {
387        let formatter = OutputFormatter::new(true, false);
388
389        let result = CommandResult::success(
390            "compile",
391            json!({
392                "files_compiled": 2,
393                "output_file": "schema.compiled.json"
394            }),
395        );
396
397        let output = formatter.format(&result);
398        assert!(!output.is_empty());
399
400        // Verify it's valid JSON
401        let parsed: serde_json::Value =
402            serde_json::from_str(&output).expect("Output must be valid JSON");
403        assert_eq!(parsed["status"], "success");
404        assert_eq!(parsed["command"], "compile");
405    }
406
407    #[test]
408    fn test_output_formatter_text_mode_success() {
409        let formatter = OutputFormatter::new(false, false);
410
411        let result = CommandResult::success("compile", json!({}));
412        let output = formatter.format(&result);
413
414        assert!(!output.is_empty());
415        assert!(output.contains("compile"));
416        assert!(output.contains("✓"));
417    }
418
419    #[test]
420    fn test_output_formatter_quiet_mode() {
421        let formatter = OutputFormatter::new(false, true);
422
423        let result = CommandResult::success("compile", json!({}));
424        let output = formatter.format(&result);
425
426        assert_eq!(output, "");
427    }
428
429    #[test]
430    fn test_output_formatter_json_mode_error() {
431        let formatter = OutputFormatter::new(true, false);
432
433        let result = CommandResult::error("compile", "Parse error", "PARSE_ERROR");
434
435        let output = formatter.format(&result);
436        assert!(!output.is_empty());
437
438        let parsed: serde_json::Value =
439            serde_json::from_str(&output).expect("Output must be valid JSON");
440        assert_eq!(parsed["status"], "error");
441        assert_eq!(parsed["command"], "compile");
442        assert_eq!(parsed["code"], "PARSE_ERROR");
443    }
444
445    #[test]
446    fn test_output_formatter_validation_failure() {
447        let formatter = OutputFormatter::new(true, false);
448
449        let result = CommandResult::validation_failed(
450            "validate",
451            vec![
452                "Invalid type: User".to_string(),
453                "Missing field: id".to_string(),
454            ],
455        );
456
457        let output = formatter.format(&result);
458
459        let parsed: serde_json::Value =
460            serde_json::from_str(&output).expect("Output must be valid JSON");
461        assert_eq!(parsed["status"], "validation-failed");
462        assert!(parsed["errors"].is_array());
463        assert_eq!(parsed["errors"].as_array().unwrap().len(), 2);
464    }
465
466    #[test]
467    fn test_command_result_preserves_data() {
468        let data = json!({
469            "count": 42,
470            "nested": {
471                "value": "test"
472            }
473        });
474
475        let result = CommandResult::success("test", data.clone());
476
477        // Data should be preserved exactly
478        assert_eq!(result.data, Some(data));
479    }
480
481    #[test]
482    fn test_output_formatter_with_warnings() {
483        let formatter = OutputFormatter::new(true, false);
484
485        let result = CommandResult::success_with_warnings(
486            "compile",
487            json!({ "status": "ok" }),
488            vec!["Optimization opportunity: add index to User.id".to_string()],
489        );
490
491        let output = formatter.format(&result);
492        let parsed: serde_json::Value = serde_json::from_str(&output).expect("Valid JSON");
493
494        assert_eq!(parsed["status"], "success");
495        assert!(parsed["warnings"].is_array());
496    }
497
498    #[test]
499    fn test_text_mode_shows_status() {
500        let formatter = OutputFormatter::new(false, false);
501
502        let result = CommandResult::success("compile", json!({}));
503        let output = formatter.format(&result);
504
505        // Should contain some indication of success
506        assert!(output.to_lowercase().contains("success") || output.contains("✓"));
507    }
508
509    #[test]
510    fn test_text_mode_shows_error() {
511        let formatter = OutputFormatter::new(false, false);
512
513        let result = CommandResult::error("compile", "File not found", "FILE_NOT_FOUND");
514        let output = formatter.format(&result);
515
516        assert!(
517            output.to_lowercase().contains("error")
518                || output.contains("✗")
519                || output.contains("file")
520        );
521    }
522
523    #[test]
524    fn test_quiet_mode_suppresses_all_output() {
525        let formatter = OutputFormatter::new(false, true);
526
527        let success = CommandResult::success("compile", json!({}));
528        let error = CommandResult::error("validate", "Invalid", "INVALID");
529
530        assert_eq!(formatter.format(&success), "");
531        assert_eq!(formatter.format(&error), "");
532    }
533
534    #[test]
535    fn test_json_mode_ignores_quiet_flag() {
536        // JSON mode should always output JSON, even with quiet=true
537        let formatter = OutputFormatter::new(true, true);
538
539        let result = CommandResult::success("compile", json!({}));
540        let output = formatter.format(&result);
541
542        // Should still produce JSON
543        let parsed: serde_json::Value =
544            serde_json::from_str(&output).expect("Should be valid JSON");
545        assert_eq!(parsed["status"], "success");
546    }
547
548    #[test]
549    fn test_command_result_from_anyhow_error() {
550        let error = anyhow::anyhow!("Database connection failed");
551        let result = CommandResult::from_error("serve", error);
552
553        assert_eq!(result.status, "error");
554        assert_eq!(result.command, "serve");
555    }
556
557    #[test]
558    fn test_validation_failed_exit_code() {
559        let result = CommandResult::validation_failed("validate", vec!["Error 1".to_string()]);
560
561        // Validation failures should have a specific exit code
562        assert_eq!(result.exit_code, 2);
563    }
564
565    #[test]
566    fn test_error_exit_code() {
567        let result = CommandResult::error("compile", "Failed", "FAILED");
568
569        assert_eq!(result.exit_code, 1);
570    }
571
572    #[test]
573    fn test_success_exit_code() {
574        let result = CommandResult::success("compile", json!({}));
575
576        assert_eq!(result.exit_code, 0);
577    }
578}