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