Skip to main content

mrapids/core/
output.rs

1//! Unified output module for agent-friendly CLI responses
2//!
3//! This module provides consistent JSON output formatting for all mrapids commands,
4//! making it easy for agents and automation tools to parse responses.
5
6#![allow(dead_code)]
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::process;
11
12/// Standard exit codes for mrapids CLI
13pub mod exit_codes {
14    pub const SUCCESS: i32 = 0;
15    pub const GENERAL_ERROR: i32 = 1;
16    pub const INVALID_ARGUMENTS: i32 = 2;
17    pub const NETWORK_ERROR: i32 = 3;
18    pub const AUTH_ERROR: i32 = 4;
19    pub const VALIDATION_ERROR: i32 = 5;
20    pub const NOT_FOUND: i32 = 6;
21    pub const TIMEOUT: i32 = 7;
22    pub const PARTIAL_SUCCESS: i32 = 10; // For batch operations
23}
24
25/// Environment variable names for mrapids configuration
26pub mod env_vars {
27    pub const DB_PATH: &str = "MRAPIDS_DB_PATH";
28    pub const CONFIG_PATH: &str = "MRAPIDS_CONFIG_PATH";
29    pub const SPEC_PATH: &str = "MRAPIDS_SPEC_PATH";
30    pub const OUTPUT_FORMAT: &str = "MRAPIDS_OUTPUT";
31    pub const AUTH_TOKEN: &str = "MRAPIDS_AUTH_TOKEN";
32    pub const BASE_URL: &str = "MRAPIDS_BASE_URL";
33    pub const LOG_LEVEL: &str = "MRAPIDS_LOG_LEVEL";
34    pub const NO_COLOR: &str = "MRAPIDS_NO_COLOR";
35    pub const MACHINE_MODE: &str = "MRAPIDS_MACHINE";
36}
37
38/// Unified response envelope for all CLI commands
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ResponseEnvelope<T: Serialize> {
41    /// Whether the command succeeded
42    pub success: bool,
43
44    /// The command that was executed
45    pub command: String,
46
47    /// The actual response data
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub data: Option<T>,
50
51    /// Metadata about the execution
52    pub metadata: ResponseMetadata,
53
54    /// Any errors that occurred
55    #[serde(skip_serializing_if = "Vec::is_empty")]
56    pub errors: Vec<ErrorDetail>,
57
58    /// Any warnings
59    #[serde(skip_serializing_if = "Vec::is_empty")]
60    pub warnings: Vec<String>,
61}
62
63/// Metadata about the command execution
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ResponseMetadata {
66    /// Run ID (for run command)
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub run_id: Option<String>,
69
70    /// Request ID (for run command)
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub request_id: Option<String>,
73
74    /// Execution duration in milliseconds
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub duration_ms: Option<f64>,
77
78    /// CLI version
79    pub version: String,
80
81    /// Timestamp of execution
82    pub timestamp: DateTime<Utc>,
83
84    /// Exit code that will be used
85    pub exit_code: i32,
86}
87
88/// Detailed error information
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ErrorDetail {
91    /// Error code (e.g., "NETWORK_ERROR", "AUTH_FAILED")
92    pub code: String,
93
94    /// Human-readable error message
95    pub message: String,
96
97    /// Additional context
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub context: Option<String>,
100
101    /// Suggested fix
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub suggestion: Option<String>,
104}
105
106/// Response specifically for the run command
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct RunResponse {
109    /// The operation that was executed
110    pub operation: String,
111
112    /// HTTP method used
113    pub method: String,
114
115    /// Full URL that was called
116    pub url: String,
117
118    /// HTTP status code
119    pub status_code: u16,
120
121    /// Status text
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub status_text: Option<String>,
124
125    /// Response headers
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub headers: Option<serde_json::Value>,
128
129    /// Response body
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub body: Option<serde_json::Value>,
132
133    /// Response body as raw string (if not JSON)
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub body_raw: Option<String>,
136
137    /// Response size in bytes
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub body_size_bytes: Option<usize>,
140
141    /// Request details (for debugging)
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub request: Option<RequestDetails>,
144}
145
146/// Request details for debugging
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct RequestDetails {
149    /// Request headers sent
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub headers: Option<serde_json::Value>,
152
153    /// Query parameters
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub query_params: Option<serde_json::Value>,
156
157    /// Path parameters
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub path_params: Option<serde_json::Value>,
160
161    /// Request body sent
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub body: Option<serde_json::Value>,
164}
165
166impl<T: Serialize> ResponseEnvelope<T> {
167    /// Create a successful response
168    pub fn success(command: &str, data: T) -> Self {
169        Self {
170            success: true,
171            command: command.to_string(),
172            data: Some(data),
173            metadata: ResponseMetadata::new(exit_codes::SUCCESS),
174            errors: vec![],
175            warnings: vec![],
176        }
177    }
178
179    /// Create a successful response with run metadata
180    pub fn success_with_run(
181        command: &str,
182        data: T,
183        run_id: String,
184        request_id: Option<String>,
185        duration_ms: f64,
186    ) -> Self {
187        let mut envelope = Self::success(command, data);
188        envelope.metadata.run_id = Some(run_id);
189        envelope.metadata.request_id = request_id;
190        envelope.metadata.duration_ms = Some(duration_ms);
191        envelope
192    }
193
194    /// Add a warning
195    pub fn with_warning(mut self, warning: &str) -> Self {
196        self.warnings.push(warning.to_string());
197        self
198    }
199
200    /// Output as JSON and exit
201    pub fn output_and_exit(self) -> ! {
202        let exit_code = self.metadata.exit_code;
203        println!(
204            "{}",
205            serde_json::to_string_pretty(&self).unwrap_or_else(|_| "{}".to_string())
206        );
207        process::exit(exit_code);
208    }
209
210    /// Output as JSON string
211    pub fn to_json(&self) -> String {
212        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
213    }
214
215    /// Output as compact JSON string (single line)
216    pub fn to_json_compact(&self) -> String {
217        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
218    }
219}
220
221/// Create an error response (without generic data type)
222pub fn error_response(
223    command: &str,
224    code: &str,
225    message: &str,
226    exit_code: i32,
227) -> ResponseEnvelope<serde_json::Value> {
228    ResponseEnvelope {
229        success: false,
230        command: command.to_string(),
231        data: None,
232        metadata: ResponseMetadata::new(exit_code),
233        errors: vec![ErrorDetail {
234            code: code.to_string(),
235            message: message.to_string(),
236            context: None,
237            suggestion: None,
238        }],
239        warnings: vec![],
240    }
241}
242
243/// Create an error response with suggestion
244pub fn error_with_suggestion(
245    command: &str,
246    code: &str,
247    message: &str,
248    suggestion: &str,
249    exit_code: i32,
250) -> ResponseEnvelope<serde_json::Value> {
251    ResponseEnvelope {
252        success: false,
253        command: command.to_string(),
254        data: None,
255        metadata: ResponseMetadata::new(exit_code),
256        errors: vec![ErrorDetail {
257            code: code.to_string(),
258            message: message.to_string(),
259            context: None,
260            suggestion: Some(suggestion.to_string()),
261        }],
262        warnings: vec![],
263    }
264}
265
266impl ResponseMetadata {
267    pub fn new(exit_code: i32) -> Self {
268        Self {
269            run_id: None,
270            request_id: None,
271            duration_ms: None,
272            version: env!("CARGO_PKG_VERSION").to_string(),
273            timestamp: Utc::now(),
274            exit_code,
275        }
276    }
277}
278
279impl ErrorDetail {
280    pub fn new(code: &str, message: &str) -> Self {
281        Self {
282            code: code.to_string(),
283            message: message.to_string(),
284            context: None,
285            suggestion: None,
286        }
287    }
288
289    pub fn with_context(mut self, context: &str) -> Self {
290        self.context = Some(context.to_string());
291        self
292    }
293
294    pub fn with_suggestion(mut self, suggestion: &str) -> Self {
295        self.suggestion = Some(suggestion.to_string());
296        self
297    }
298}
299
300/// Global output configuration
301#[derive(Debug, Clone, Default)]
302pub struct OutputConfig {
303    /// Output as JSON
304    pub json: bool,
305    /// Machine mode (no colors, no decorations)
306    pub machine: bool,
307    /// Quiet mode (errors only)
308    pub quiet: bool,
309    /// Verbose mode
310    pub verbose: bool,
311    /// No color output
312    pub no_color: bool,
313}
314
315impl OutputConfig {
316    /// Load from environment variables
317    pub fn from_env() -> Self {
318        Self {
319            json: std::env::var("MRAPIDS_JSON")
320                .map(|v| v == "1" || v.to_lowercase() == "true")
321                .unwrap_or(false)
322                || std::env::var(env_vars::OUTPUT_FORMAT)
323                    .map(|v| v.to_lowercase() == "json")
324                    .unwrap_or(false),
325            machine: std::env::var(env_vars::MACHINE_MODE)
326                .map(|v| v == "1" || v.to_lowercase() == "true")
327                .unwrap_or(false),
328            quiet: std::env::var("MRAPIDS_QUIET")
329                .map(|v| v == "1" || v.to_lowercase() == "true")
330                .unwrap_or(false),
331            verbose: std::env::var("MRAPIDS_VERBOSE")
332                .map(|v| v == "1" || v.to_lowercase() == "true")
333                .unwrap_or(false),
334            no_color: std::env::var(env_vars::NO_COLOR)
335                .map(|v| v == "1" || v.to_lowercase() == "true")
336                .unwrap_or(false),
337        }
338    }
339
340    /// Check if we should output decorations (banners, spinners)
341    pub fn show_decorations(&self) -> bool {
342        !self.machine && !self.json && !self.quiet
343    }
344
345    /// Check if colors should be used
346    pub fn use_colors(&self) -> bool {
347        !self.no_color && !self.machine && !self.json
348    }
349}
350
351/// Check if JSON output mode is enabled globally
352/// This checks environment variables set by main.rs from CLI args
353pub fn is_json_mode() -> bool {
354    std::env::var("MRAPIDS_JSON")
355        .map(|v| v == "1" || v.to_lowercase() == "true")
356        .unwrap_or(false)
357        || std::env::var("MRAPIDS_OUTPUT")
358            .map(|v| v.to_lowercase() == "json")
359            .unwrap_or(false)
360}
361
362/// Check if machine mode is enabled globally
363pub fn is_machine_mode() -> bool {
364    std::env::var("MRAPIDS_MACHINE")
365        .map(|v| v == "1" || v.to_lowercase() == "true")
366        .unwrap_or(false)
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn test_success_response() {
375        let response = ResponseEnvelope::success("run", serde_json::json!({"status": "ok"}));
376        assert!(response.success);
377        assert_eq!(response.command, "run");
378        assert_eq!(response.metadata.exit_code, exit_codes::SUCCESS);
379    }
380
381    #[test]
382    fn test_error_response() {
383        let response = error_response(
384            "run",
385            "NETWORK_ERROR",
386            "Connection failed",
387            exit_codes::NETWORK_ERROR,
388        );
389        assert!(!response.success);
390        assert_eq!(response.errors.len(), 1);
391        assert_eq!(response.errors[0].code, "NETWORK_ERROR");
392        assert_eq!(response.metadata.exit_code, exit_codes::NETWORK_ERROR);
393    }
394
395    #[test]
396    fn test_success_with_run() {
397        let response = ResponseEnvelope::success_with_run(
398            "run",
399            serde_json::json!({"data": "test"}),
400            "abc123".to_string(),
401            Some("req_xyz".to_string()),
402            150.5,
403        );
404        assert!(response.success);
405        assert_eq!(response.metadata.run_id, Some("abc123".to_string()));
406        assert_eq!(response.metadata.request_id, Some("req_xyz".to_string()));
407        assert_eq!(response.metadata.duration_ms, Some(150.5));
408    }
409
410    #[test]
411    fn test_json_serialization() {
412        let response = ResponseEnvelope::success("test", serde_json::json!({"key": "value"}));
413        let json = response.to_json();
414        assert!(json.contains("\"success\": true"));
415        assert!(json.contains("\"command\": \"test\""));
416    }
417
418    #[test]
419    fn test_output_config_from_env() {
420        // Default config
421        let config = OutputConfig::default();
422        assert!(!config.json);
423        assert!(!config.machine);
424        assert!(config.show_decorations());
425    }
426
427    #[test]
428    fn test_exit_codes() {
429        assert_eq!(exit_codes::SUCCESS, 0);
430        assert_eq!(exit_codes::GENERAL_ERROR, 1);
431        assert_eq!(exit_codes::NETWORK_ERROR, 3);
432        assert_eq!(exit_codes::PARTIAL_SUCCESS, 10);
433    }
434}