Skip to main content

oxigaf_cli/
json_output.rs

1//! JSON output format for scripting and automation.
2//!
3//! Provides structured JSON output mode that:
4//! - Emits valid JSON only (no extraneous output)
5//! - Includes version, command, status in all outputs
6//! - Tracks artifacts (generated files) with paths and sizes
7//! - Collects warnings and errors
8//! - Supports optional metadata for command-specific data
9
10use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13
14// ---------------------------------------------------------------------------
15// JSON Output Structure
16// ---------------------------------------------------------------------------
17
18/// Top-level JSON output structure for all commands.
19#[derive(Debug, Serialize, Deserialize)]
20pub struct JsonOutput {
21    /// OxiGAF version (from CARGO_PKG_VERSION).
22    pub version: String,
23
24    /// Command name that was executed.
25    pub command: String,
26
27    /// Execution status.
28    pub status: Status,
29
30    /// Command-specific result data (optional).
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub result: Option<serde_json::Value>,
33
34    /// Generated artifacts (files) with paths and sizes.
35    #[serde(skip_serializing_if = "Vec::is_empty", default)]
36    pub artifacts: Vec<Artifact>,
37
38    /// Warning messages (non-fatal issues).
39    #[serde(skip_serializing_if = "Vec::is_empty", default)]
40    pub warnings: Vec<String>,
41
42    /// Error messages (fatal issues).
43    #[serde(skip_serializing_if = "Vec::is_empty", default)]
44    pub errors: Vec<String>,
45
46    /// Additional metadata (command-specific).
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub metadata: Option<serde_json::Value>,
49}
50
51/// Execution status.
52#[derive(Debug, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum Status {
55    /// Command completed successfully.
56    Success,
57    /// Command failed with errors.
58    Error,
59    /// Command completed with warnings.
60    Warning,
61}
62
63/// File artifact (generated output file).
64#[derive(Debug, Serialize, Deserialize)]
65pub struct Artifact {
66    /// Artifact type (e.g., "ply", "checkpoint", "image").
67    #[serde(rename = "type")]
68    pub artifact_type: String,
69
70    /// Path to the artifact file.
71    pub path: PathBuf,
72
73    /// File size in bytes (if available).
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub size_bytes: Option<u64>,
76}
77
78// ---------------------------------------------------------------------------
79// Implementation
80// ---------------------------------------------------------------------------
81
82impl JsonOutput {
83    /// Create a new JSON output with default success status.
84    #[must_use]
85    pub fn new(command: &str) -> Self {
86        Self {
87            version: env!("CARGO_PKG_VERSION").to_string(),
88            command: command.to_string(),
89            status: Status::Success,
90            result: None,
91            artifacts: Vec::new(),
92            warnings: Vec::new(),
93            errors: Vec::new(),
94            metadata: None,
95        }
96    }
97
98    /// Create a success JSON output with result data.
99    #[must_use]
100    pub fn success(command: &str, result: serde_json::Value) -> Self {
101        let mut output = Self::new(command);
102        output.result = Some(result);
103        output
104    }
105
106    /// Create an error JSON output with error message.
107    #[must_use]
108    pub fn error(command: &str, error: String) -> Self {
109        let mut output = Self::new(command);
110        output.status = Status::Error;
111        output.errors.push(error);
112        output
113    }
114
115    /// Add an artifact (generated file) to the output.
116    ///
117    /// Automatically reads file size if the file exists.
118    pub fn add_artifact(&mut self, artifact_type: String, path: PathBuf) {
119        let size_bytes = std::fs::metadata(&path).ok().map(|m| m.len());
120        self.artifacts.push(Artifact {
121            artifact_type,
122            path,
123            size_bytes,
124        });
125    }
126
127    /// Add a warning message.
128    #[allow(dead_code)]
129    pub fn add_warning(&mut self, warning: String) {
130        self.warnings.push(warning);
131        if !self.errors.is_empty() {
132            // Keep error status if we already have errors
133        } else {
134            self.status = Status::Warning;
135        }
136    }
137
138    /// Add an error message.
139    #[allow(dead_code)]
140    pub fn add_error(&mut self, error: String) {
141        self.errors.push(error);
142        self.status = Status::Error;
143    }
144
145    /// Print the JSON output to stdout.
146    ///
147    /// Uses pretty-printing for human readability.
148    /// On serialization error, prints error JSON to stderr.
149    pub fn print(&self) {
150        match serde_json::to_string_pretty(self) {
151            Ok(json) => {
152                println!("{}", json);
153            }
154            Err(e) => {
155                // Fallback error output to stderr
156                eprintln!(
157                    "{{\"error\": \"Failed to serialize JSON: {}\"}}",
158                    e.to_string().replace('"', "\\\"")
159                );
160            }
161        }
162    }
163}
164
165// ---------------------------------------------------------------------------
166// Tests
167// ---------------------------------------------------------------------------
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn test_json_output_new() {
175        let output = JsonOutput::new("test");
176        assert_eq!(output.command, "test");
177        assert_eq!(output.version, env!("CARGO_PKG_VERSION"));
178        assert!(matches!(output.status, Status::Success));
179        assert!(output.result.is_none());
180        assert!(output.artifacts.is_empty());
181        assert!(output.warnings.is_empty());
182        assert!(output.errors.is_empty());
183    }
184
185    #[test]
186    fn test_json_output_success() {
187        let result = serde_json::json!({"iterations": 1000});
188        let output = JsonOutput::success("train", result.clone());
189        assert_eq!(output.command, "train");
190        assert!(matches!(output.status, Status::Success));
191        assert_eq!(output.result, Some(result));
192    }
193
194    #[test]
195    fn test_json_output_error() {
196        let output = JsonOutput::error("export", "File not found".to_string());
197        assert_eq!(output.command, "export");
198        assert!(matches!(output.status, Status::Error));
199        assert_eq!(output.errors.len(), 1);
200        assert_eq!(output.errors[0], "File not found");
201    }
202
203    #[test]
204    fn test_add_warning() {
205        let mut output = JsonOutput::new("test");
206        output.add_warning("Low memory".to_string());
207        assert!(matches!(output.status, Status::Warning));
208        assert_eq!(output.warnings.len(), 1);
209    }
210
211    #[test]
212    fn test_add_error() {
213        let mut output = JsonOutput::new("test");
214        output.add_error("Fatal error".to_string());
215        assert!(matches!(output.status, Status::Error));
216        assert_eq!(output.errors.len(), 1);
217    }
218
219    #[test]
220    #[allow(clippy::expect_used)]
221    fn test_serialization() {
222        let output = JsonOutput::success(
223            "test",
224            serde_json::json!({
225                "key": "value"
226            }),
227        );
228
229        let json = serde_json::to_string(&output).expect("Serialization should succeed");
230        assert!(json.contains("\"command\":\"test\""));
231        assert!(json.contains("\"status\":\"success\""));
232    }
233
234    #[test]
235    #[allow(clippy::expect_used)]
236    fn test_skip_serializing_empty_fields() {
237        let output = JsonOutput::new("test");
238        let json = serde_json::to_string(&output).expect("Serialization should succeed");
239
240        // Empty arrays should be omitted
241        assert!(!json.contains("\"artifacts\""));
242        assert!(!json.contains("\"warnings\""));
243        assert!(!json.contains("\"errors\""));
244        // None fields should be omitted
245        assert!(!json.contains("\"result\""));
246        assert!(!json.contains("\"metadata\""));
247    }
248}