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
168impl CommandResult {
169    /// Create a successful command result with data
170    pub fn success(command: &str, data: Value) -> Self {
171        Self {
172            status:    "success".to_string(),
173            command:   command.to_string(),
174            data:      Some(data),
175            message:   None,
176            code:      None,
177            errors:    Vec::new(),
178            warnings:  Vec::new(),
179            exit_code: 0,
180        }
181    }
182
183    /// Create a successful command result with warnings
184    pub fn success_with_warnings(command: &str, data: Value, warnings: Vec<String>) -> Self {
185        Self {
186            status: "success".to_string(),
187            command: command.to_string(),
188            data: Some(data),
189            message: None,
190            code: None,
191            errors: Vec::new(),
192            warnings,
193            exit_code: 0,
194        }
195    }
196
197    /// Create an error result
198    pub fn error(command: &str, message: &str, code: &str) -> Self {
199        Self {
200            status:    "error".to_string(),
201            command:   command.to_string(),
202            data:      None,
203            message:   Some(message.to_string()),
204            code:      Some(code.to_string()),
205            errors:    Vec::new(),
206            warnings:  Vec::new(),
207            exit_code: 1,
208        }
209    }
210
211    /// Create a validation failure result
212    #[allow(dead_code)]
213    pub fn validation_failed(command: &str, errors: Vec<String>) -> Self {
214        Self {
215            status: "validation-failed".to_string(),
216            command: command.to_string(),
217            data: None,
218            message: None,
219            code: None,
220            errors,
221            warnings: Vec::new(),
222            exit_code: 2,
223        }
224    }
225
226    /// Create an error result from an anyhow::Error
227    #[allow(dead_code)]
228    pub fn from_error(command: &str, error: anyhow::Error) -> Self {
229        Self::error(command, &error.to_string(), "INTERNAL_ERROR")
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn test_output_formatter_json_mode_success() {
239        let formatter = OutputFormatter::new(true, false);
240
241        let result = CommandResult::success(
242            "compile",
243            json!({
244                "files_compiled": 2,
245                "output_file": "schema.compiled.json"
246            }),
247        );
248
249        let output = formatter.format(&result);
250        assert!(!output.is_empty());
251
252        // Verify it's valid JSON
253        let parsed: serde_json::Value =
254            serde_json::from_str(&output).expect("Output must be valid JSON");
255        assert_eq!(parsed["status"], "success");
256        assert_eq!(parsed["command"], "compile");
257    }
258
259    #[test]
260    fn test_output_formatter_text_mode_success() {
261        let formatter = OutputFormatter::new(false, false);
262
263        let result = CommandResult::success("compile", json!({}));
264        let output = formatter.format(&result);
265
266        assert!(!output.is_empty());
267        assert!(output.contains("compile"));
268        assert!(output.contains("✓"));
269    }
270
271    #[test]
272    fn test_output_formatter_quiet_mode() {
273        let formatter = OutputFormatter::new(false, true);
274
275        let result = CommandResult::success("compile", json!({}));
276        let output = formatter.format(&result);
277
278        assert_eq!(output, "");
279    }
280
281    #[test]
282    fn test_output_formatter_json_mode_error() {
283        let formatter = OutputFormatter::new(true, false);
284
285        let result = CommandResult::error("compile", "Parse error", "PARSE_ERROR");
286
287        let output = formatter.format(&result);
288        assert!(!output.is_empty());
289
290        let parsed: serde_json::Value =
291            serde_json::from_str(&output).expect("Output must be valid JSON");
292        assert_eq!(parsed["status"], "error");
293        assert_eq!(parsed["command"], "compile");
294        assert_eq!(parsed["code"], "PARSE_ERROR");
295    }
296
297    #[test]
298    fn test_output_formatter_validation_failure() {
299        let formatter = OutputFormatter::new(true, false);
300
301        let result = CommandResult::validation_failed(
302            "validate",
303            vec![
304                "Invalid type: User".to_string(),
305                "Missing field: id".to_string(),
306            ],
307        );
308
309        let output = formatter.format(&result);
310
311        let parsed: serde_json::Value =
312            serde_json::from_str(&output).expect("Output must be valid JSON");
313        assert_eq!(parsed["status"], "validation-failed");
314        assert!(parsed["errors"].is_array());
315        assert_eq!(parsed["errors"].as_array().unwrap().len(), 2);
316    }
317
318    #[test]
319    fn test_command_result_preserves_data() {
320        let data = json!({
321            "count": 42,
322            "nested": {
323                "value": "test"
324            }
325        });
326
327        let result = CommandResult::success("test", data.clone());
328
329        // Data should be preserved exactly
330        assert_eq!(result.data, Some(data));
331    }
332
333    #[test]
334    fn test_output_formatter_with_warnings() {
335        let formatter = OutputFormatter::new(true, false);
336
337        let result = CommandResult::success_with_warnings(
338            "compile",
339            json!({ "status": "ok" }),
340            vec!["Optimization opportunity: add index to User.id".to_string()],
341        );
342
343        let output = formatter.format(&result);
344        let parsed: serde_json::Value = serde_json::from_str(&output).expect("Valid JSON");
345
346        assert_eq!(parsed["status"], "success");
347        assert!(parsed["warnings"].is_array());
348    }
349
350    #[test]
351    fn test_text_mode_shows_status() {
352        let formatter = OutputFormatter::new(false, false);
353
354        let result = CommandResult::success("compile", json!({}));
355        let output = formatter.format(&result);
356
357        // Should contain some indication of success
358        assert!(output.to_lowercase().contains("success") || output.contains("✓"));
359    }
360
361    #[test]
362    fn test_text_mode_shows_error() {
363        let formatter = OutputFormatter::new(false, false);
364
365        let result = CommandResult::error("compile", "File not found", "FILE_NOT_FOUND");
366        let output = formatter.format(&result);
367
368        assert!(
369            output.to_lowercase().contains("error")
370                || output.contains("✗")
371                || output.contains("file")
372        );
373    }
374
375    #[test]
376    fn test_quiet_mode_suppresses_all_output() {
377        let formatter = OutputFormatter::new(false, true);
378
379        let success = CommandResult::success("compile", json!({}));
380        let error = CommandResult::error("validate", "Invalid", "INVALID");
381
382        assert_eq!(formatter.format(&success), "");
383        assert_eq!(formatter.format(&error), "");
384    }
385
386    #[test]
387    fn test_json_mode_ignores_quiet_flag() {
388        // JSON mode should always output JSON, even with quiet=true
389        let formatter = OutputFormatter::new(true, true);
390
391        let result = CommandResult::success("compile", json!({}));
392        let output = formatter.format(&result);
393
394        // Should still produce JSON
395        let parsed: serde_json::Value =
396            serde_json::from_str(&output).expect("Should be valid JSON");
397        assert_eq!(parsed["status"], "success");
398    }
399
400    #[test]
401    fn test_command_result_from_anyhow_error() {
402        let error = anyhow::anyhow!("Database connection failed");
403        let result = CommandResult::from_error("serve", error);
404
405        assert_eq!(result.status, "error");
406        assert_eq!(result.command, "serve");
407    }
408
409    #[test]
410    fn test_validation_failed_exit_code() {
411        let result = CommandResult::validation_failed("validate", vec!["Error 1".to_string()]);
412
413        // Validation failures should have a specific exit code
414        assert_eq!(result.exit_code, 2);
415    }
416
417    #[test]
418    fn test_error_exit_code() {
419        let result = CommandResult::error("compile", "Failed", "FAILED");
420
421        assert_eq!(result.exit_code, 1);
422    }
423
424    #[test]
425    fn test_success_exit_code() {
426        let result = CommandResult::success("compile", json!({}));
427
428        assert_eq!(result.exit_code, 0);
429    }
430}