Skip to main content

foundry_cli/
json.rs

1//! Shared JSON output primitives for Foundry CLIs.
2
3use eyre::Result;
4use serde::{Deserialize, Serialize};
5use serde_json::{Value, to_string};
6
7/// The current version of Foundry's top-level JSON output envelope.
8pub const JSON_SCHEMA_VERSION: u32 = 1;
9
10/// Stable top-level envelope for complete machine-readable command output.
11///
12/// This envelope represents a terminal command outcome. Long-running commands
13/// that stream intermediate records should use a separate event type and reserve
14/// this shape for final, complete results.
15#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
16pub struct JsonEnvelope<T> {
17    /// Version of the envelope schema.
18    pub schema_version: u32,
19    /// Whether the command completed successfully.
20    ///
21    /// Only meaningful for a complete/terminal command outcome.
22    pub success: bool,
23    /// Command-specific payload.
24    pub data: Option<T>,
25    /// Structured errors emitted by the command.
26    pub errors: Vec<JsonMessage>,
27    /// Structured warnings emitted by the command.
28    pub warnings: Vec<JsonMessage>,
29}
30
31impl<T> JsonEnvelope<T> {
32    /// Creates a successful envelope with command-specific data.
33    pub const fn success(data: T) -> Self {
34        Self {
35            schema_version: JSON_SCHEMA_VERSION,
36            success: true,
37            data: Some(data),
38            errors: Vec::new(),
39            warnings: Vec::new(),
40        }
41    }
42
43    /// Creates a successful envelope with command-specific data and warnings.
44    pub const fn success_with_warnings(data: T, warnings: Vec<JsonMessage>) -> Self {
45        Self {
46            schema_version: JSON_SCHEMA_VERSION,
47            success: true,
48            data: Some(data),
49            errors: Vec::new(),
50            warnings,
51        }
52    }
53}
54
55impl JsonEnvelope<()> {
56    /// Creates a failed envelope with one structured error.
57    pub fn error(error: JsonMessage) -> Self {
58        Self::failure(vec![error])
59    }
60
61    /// Creates a failed envelope with structured errors.
62    pub const fn failure(errors: Vec<JsonMessage>) -> Self {
63        Self {
64            schema_version: JSON_SCHEMA_VERSION,
65            success: false,
66            data: None,
67            errors,
68            warnings: Vec::new(),
69        }
70    }
71}
72
73/// Severity level for a structured JSON diagnostic.
74///
75/// These levels classify diagnostics attached to an envelope. Progress,
76/// informational, and debug records should be modeled as command output data or
77/// stream events instead.
78#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum JsonMessageLevel {
81    /// Error message.
82    Error,
83    /// Warning message.
84    Warning,
85}
86
87/// Structured diagnostic entry for JSON output.
88///
89/// Diagnostics describe errors and warnings associated with command output. They
90/// are not intended for progress, informational, or debug events.
91#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
92pub struct JsonMessage {
93    /// Diagnostic severity level.
94    pub level: JsonMessageLevel,
95    /// Stable machine-readable diagnostic code.
96    pub code: String,
97    /// Human-readable diagnostic message.
98    pub message: String,
99    /// Optional structured context for the diagnostic.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub details: Option<Value>,
102}
103
104impl JsonMessage {
105    /// Creates a structured error without details.
106    pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
107        Self {
108            level: JsonMessageLevel::Error,
109            code: code.into(),
110            message: message.into(),
111            details: None,
112        }
113    }
114
115    /// Creates a structured warning without details.
116    pub fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
117        Self {
118            level: JsonMessageLevel::Warning,
119            code: code.into(),
120            message: message.into(),
121            details: None,
122        }
123    }
124
125    /// Adds structured details to the diagnostic.
126    pub fn with_details(mut self, details: Value) -> Self {
127        self.details = Some(details);
128        self
129    }
130}
131
132/// Prints a value as compact, single-line JSON to stdout.
133///
134/// The trailing newline makes this suitable for NDJSON streams when each call
135/// emits one self-contained JSON record.
136pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
137    sh_println!("{}", to_string(value)?)?;
138    Ok(())
139}
140
141/// Prints a successful JSON envelope to stdout.
142pub fn print_json_success<T: Serialize>(data: T) -> Result<()> {
143    print_json(&JsonEnvelope::success(data))
144}
145
146/// Prints a successful JSON envelope with warnings to stdout.
147pub fn print_json_success_with_warnings<T: Serialize>(
148    data: T,
149    warnings: Vec<JsonMessage>,
150) -> Result<()> {
151    print_json(&JsonEnvelope::success_with_warnings(data, warnings))
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use serde_json::{json, to_value};
158
159    #[derive(Serialize)]
160    struct BuildData {
161        contracts: usize,
162    }
163
164    #[test]
165    fn success_envelope_serializes_all_top_level_fields() {
166        let envelope = JsonEnvelope::success(BuildData { contracts: 2 });
167
168        let json = to_string(&envelope).unwrap();
169
170        assert_eq!(
171            json,
172            r#"{"schema_version":1,"success":true,"data":{"contracts":2},"errors":[],"warnings":[]}"#
173        );
174    }
175
176    #[test]
177    fn warning_details_are_structured() {
178        let warning = JsonMessage::warning("compiler.remappings", "auto-detected remappings")
179            .with_details(json!({ "count": 3 }));
180        let envelope =
181            JsonEnvelope::success_with_warnings(BuildData { contracts: 1 }, vec![warning]);
182
183        let value = to_value(&envelope).unwrap();
184
185        assert_eq!(value["success"], true);
186        assert_eq!(value["warnings"][0]["level"], "warning");
187        assert_eq!(value["warnings"][0]["code"], "compiler.remappings");
188        assert_eq!(value["warnings"][0]["details"]["count"], 3);
189    }
190
191    #[test]
192    fn failure_envelope_serializes_null_data_and_structured_errors() {
193        let error = JsonMessage::error("config.invalid", "invalid foundry.toml")
194            .with_details(json!({ "path": "foundry.toml" }));
195        let envelope = JsonEnvelope::error(error);
196
197        let value = to_value(&envelope).unwrap();
198
199        assert_eq!(value["schema_version"], JSON_SCHEMA_VERSION);
200        assert_eq!(value["success"], false);
201        assert!(value["data"].is_null());
202        assert_eq!(value["errors"][0]["level"], "error");
203        assert_eq!(value["errors"][0]["code"], "config.invalid");
204        assert_eq!(value["errors"][0]["details"]["path"], "foundry.toml");
205        assert_eq!(value["warnings"], json!([]));
206    }
207}