1use std::io::{self, Write};
14
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
23#[clap(rename_all = "lower")]
24pub enum OutputFormat {
25 #[default]
27 Text,
28 Json,
30}
31
32impl OutputFormat {
33 pub fn is_json(self) -> bool {
35 matches!(self, Self::Json)
36 }
37
38 pub fn is_text(self) -> bool {
40 matches!(self, Self::Text)
41 }
42}
43
44#[derive(Debug, Clone, Deserialize, Serialize)]
54pub struct Envelope<T: Serialize> {
55 pub schema_version: String,
56 pub ok: bool,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub data: Option<T>,
59 #[serde(skip_serializing_if = "Vec::is_empty", default)]
60 pub warnings: Vec<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub error: Option<EnvelopeError>,
63}
64
65impl<T: Serialize> Envelope<T> {
66 pub fn success(schema_version: impl Into<String>, data: T) -> Self {
68 Self {
69 schema_version: schema_version.into(),
70 ok: true,
71 data: Some(data),
72 warnings: Vec::new(),
73 error: None,
74 }
75 }
76
77 pub fn failure(schema_version: impl Into<String>, error: EnvelopeError) -> Self {
79 Self {
80 schema_version: schema_version.into(),
81 ok: false,
82 data: None,
83 warnings: Vec::new(),
84 error: Some(error),
85 }
86 }
87
88 pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
90 self.warnings.push(warning.into());
91 self
92 }
93
94 pub fn with_warnings<I, S>(mut self, warnings: I) -> Self
96 where
97 I: IntoIterator<Item = S>,
98 S: Into<String>,
99 {
100 self.warnings.extend(warnings.into_iter().map(|w| w.into()));
101 self
102 }
103}
104
105#[derive(Debug, Clone, Deserialize, Serialize)]
107pub struct EnvelopeError {
108 pub code: String,
109 pub message: String,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub hint: Option<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub details: Option<serde_json::Value>,
114}
115
116impl EnvelopeError {
117 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
119 Self {
120 code: code.into(),
121 message: message.into(),
122 hint: None,
123 details: None,
124 }
125 }
126
127 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
129 self.hint = Some(hint.into());
130 self
131 }
132
133 pub fn with_details(mut self, details: serde_json::Value) -> Self {
135 self.details = Some(details);
136 self
137 }
138}
139
140pub fn schema_version_for(binary: &str, command: &str, version: u32) -> String {
142 format!("cli.{binary}.{command}.v{version}")
143}
144
145pub mod exit {
149 pub const SUCCESS: i32 = 0;
151 pub const RUNTIME: i32 = 1;
153 pub const USAGE: i32 = 64;
155 pub const DATA: i32 = 65;
157 pub const UNAVAILABLE: i32 = 69;
159 pub const SOFTWARE: i32 = 70;
161}
162
163pub fn emit_parse_error(binary: &str, format: OutputFormat, code: &str, message: &str) -> i32 {
170 emit_parse_error_to(
171 &mut io::stdout().lock(),
172 &mut io::stderr().lock(),
173 binary,
174 format,
175 code,
176 message,
177 )
178}
179
180pub fn emit_parse_error_to<W1: Write, W2: Write>(
182 stdout: &mut W1,
183 stderr: &mut W2,
184 binary: &str,
185 format: OutputFormat,
186 code: &str,
187 message: &str,
188) -> i32 {
189 match format {
190 OutputFormat::Json => {
191 let envelope: Envelope<()> = Envelope::failure(
192 schema_version_for(binary, "error", 1),
193 EnvelopeError::new(code, message),
194 );
195 let serialized =
197 serde_json::to_string(&envelope).unwrap_or_else(|_| String::from("{\"ok\":false}"));
198 let _ = writeln!(stdout, "{serialized}");
199 }
200 OutputFormat::Text => {
201 let _ = writeln!(stderr, "error: {message}");
202 }
203 }
204 exit::USAGE
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use clap::ValueEnum;
211 use pretty_assertions::assert_eq;
212
213 #[test]
214 fn output_format_round_trips_through_value_enum() {
215 let text = OutputFormat::from_str("text", false).expect("text variant");
216 let json = OutputFormat::from_str("json", false).expect("json variant");
217 assert_eq!(text, OutputFormat::Text);
218 assert_eq!(json, OutputFormat::Json);
219 assert!(json.is_json());
220 assert!(text.is_text());
221 assert_eq!(OutputFormat::default(), OutputFormat::Text);
222 }
223
224 #[test]
225 fn envelope_success_serializes_snake_case() {
226 #[derive(Serialize)]
227 struct Payload {
228 item_count: u32,
229 }
230 let envelope = Envelope::success(
231 schema_version_for("cli-template", "status", 1),
232 Payload { item_count: 3 },
233 );
234 let json = serde_json::to_string(&envelope).expect("serialize envelope");
235 assert_eq!(
236 json,
237 "{\"schema_version\":\"cli.cli-template.status.v1\",\"ok\":true,\"data\":{\"item_count\":3}}"
238 );
239 }
240
241 #[test]
242 fn envelope_success_includes_warnings_when_present() {
243 let envelope: Envelope<()> = Envelope {
244 schema_version: schema_version_for("memo", "apply", 1),
245 ok: true,
246 data: None,
247 warnings: Vec::new(),
248 error: None,
249 }
250 .with_warning("entry-42 skipped: missing body");
251 let json = serde_json::to_string(&envelope).expect("serialize envelope");
252 assert_eq!(
253 json,
254 "{\"schema_version\":\"cli.memo.apply.v1\",\"ok\":true,\"warnings\":[\"entry-42 skipped: missing body\"]}"
255 );
256 }
257
258 #[test]
259 fn envelope_failure_serializes_error_only() {
260 let envelope: Envelope<()> = Envelope::failure(
261 schema_version_for("cli-template", "error", 1),
262 EnvelopeError::new("parse-error", "missing required argument <name>")
263 .with_hint("see --help"),
264 );
265 let json = serde_json::to_string(&envelope).expect("serialize envelope");
266 assert_eq!(
267 json,
268 "{\"schema_version\":\"cli.cli-template.error.v1\",\"ok\":false,\"error\":{\"code\":\"parse-error\",\"message\":\"missing required argument <name>\",\"hint\":\"see --help\"}}"
269 );
270 }
271
272 #[test]
273 fn envelope_deserialization_accepts_additive_metadata() {
274 let envelope: Envelope<serde_json::Value> = serde_json::from_str(
275 r#"{
276 "schema_version":"cli.agent-hook.setup.v1",
277 "ok":true,
278 "data":{"product":"codex","future_result_metadata":true},
279 "warnings":[],
280 "error":null,
281 "future_envelope_metadata":{"source":"newer-producer"}
282 }"#,
283 )
284 .expect("same-version additive metadata remains compatible");
285
286 assert!(envelope.ok);
287 assert_eq!(envelope.data.expect("data")["product"], "codex");
288 }
289
290 #[test]
291 fn exit_constants_match_bsd_sysexits() {
292 assert_eq!(exit::SUCCESS, 0);
293 assert_eq!(exit::RUNTIME, 1);
294 assert_eq!(exit::USAGE, 64);
295 assert_eq!(exit::DATA, 65);
296 assert_eq!(exit::UNAVAILABLE, 69);
297 assert_eq!(exit::SOFTWARE, 70);
298 }
299
300 #[test]
301 fn schema_version_for_builds_canonical_string() {
302 assert_eq!(schema_version_for("memo", "list", 1), "cli.memo.list.v1");
303 assert_eq!(
304 schema_version_for("cli-template", "status", 2),
305 "cli.cli-template.status.v2"
306 );
307 }
308}