1use std::io::{self, Write};
14
15use serde::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, Serialize)]
51pub struct Envelope<T: Serialize> {
52 pub schema_version: String,
53 pub ok: bool,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub data: Option<T>,
56 #[serde(skip_serializing_if = "Vec::is_empty", default)]
57 pub warnings: Vec<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub error: Option<EnvelopeError>,
60}
61
62impl<T: Serialize> Envelope<T> {
63 pub fn success(schema_version: impl Into<String>, data: T) -> Self {
65 Self {
66 schema_version: schema_version.into(),
67 ok: true,
68 data: Some(data),
69 warnings: Vec::new(),
70 error: None,
71 }
72 }
73
74 pub fn failure(schema_version: impl Into<String>, error: EnvelopeError) -> Self {
76 Self {
77 schema_version: schema_version.into(),
78 ok: false,
79 data: None,
80 warnings: Vec::new(),
81 error: Some(error),
82 }
83 }
84
85 pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
87 self.warnings.push(warning.into());
88 self
89 }
90
91 pub fn with_warnings<I, S>(mut self, warnings: I) -> Self
93 where
94 I: IntoIterator<Item = S>,
95 S: Into<String>,
96 {
97 self.warnings.extend(warnings.into_iter().map(|w| w.into()));
98 self
99 }
100}
101
102#[derive(Debug, Clone, Serialize)]
104pub struct EnvelopeError {
105 pub code: String,
106 pub message: String,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub hint: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub details: Option<serde_json::Value>,
111}
112
113impl EnvelopeError {
114 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
116 Self {
117 code: code.into(),
118 message: message.into(),
119 hint: None,
120 details: None,
121 }
122 }
123
124 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
126 self.hint = Some(hint.into());
127 self
128 }
129
130 pub fn with_details(mut self, details: serde_json::Value) -> Self {
132 self.details = Some(details);
133 self
134 }
135}
136
137pub fn schema_version_for(binary: &str, command: &str, version: u32) -> String {
139 format!("cli.{binary}.{command}.v{version}")
140}
141
142pub mod exit {
146 pub const SUCCESS: i32 = 0;
148 pub const RUNTIME: i32 = 1;
150 pub const USAGE: i32 = 64;
152 pub const DATA: i32 = 65;
154 pub const UNAVAILABLE: i32 = 69;
156 pub const SOFTWARE: i32 = 70;
158}
159
160pub fn emit_parse_error(binary: &str, format: OutputFormat, code: &str, message: &str) -> i32 {
167 emit_parse_error_to(
168 &mut io::stdout().lock(),
169 &mut io::stderr().lock(),
170 binary,
171 format,
172 code,
173 message,
174 )
175}
176
177pub fn emit_parse_error_to<W1: Write, W2: Write>(
179 stdout: &mut W1,
180 stderr: &mut W2,
181 binary: &str,
182 format: OutputFormat,
183 code: &str,
184 message: &str,
185) -> i32 {
186 match format {
187 OutputFormat::Json => {
188 let envelope: Envelope<()> = Envelope::failure(
189 schema_version_for(binary, "error", 1),
190 EnvelopeError::new(code, message),
191 );
192 let serialized =
194 serde_json::to_string(&envelope).unwrap_or_else(|_| String::from("{\"ok\":false}"));
195 let _ = writeln!(stdout, "{serialized}");
196 }
197 OutputFormat::Text => {
198 let _ = writeln!(stderr, "error: {message}");
199 }
200 }
201 exit::USAGE
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use clap::ValueEnum;
208 use pretty_assertions::assert_eq;
209
210 #[test]
211 fn output_format_round_trips_through_value_enum() {
212 let text = OutputFormat::from_str("text", false).expect("text variant");
213 let json = OutputFormat::from_str("json", false).expect("json variant");
214 assert_eq!(text, OutputFormat::Text);
215 assert_eq!(json, OutputFormat::Json);
216 assert!(json.is_json());
217 assert!(text.is_text());
218 assert_eq!(OutputFormat::default(), OutputFormat::Text);
219 }
220
221 #[test]
222 fn envelope_success_serializes_snake_case() {
223 #[derive(Serialize)]
224 struct Payload {
225 item_count: u32,
226 }
227 let envelope = Envelope::success(
228 schema_version_for("cli-template", "status", 1),
229 Payload { item_count: 3 },
230 );
231 let json = serde_json::to_string(&envelope).expect("serialize envelope");
232 assert_eq!(
233 json,
234 "{\"schema_version\":\"cli.cli-template.status.v1\",\"ok\":true,\"data\":{\"item_count\":3}}"
235 );
236 }
237
238 #[test]
239 fn envelope_success_includes_warnings_when_present() {
240 let envelope: Envelope<()> = Envelope {
241 schema_version: schema_version_for("memo", "apply", 1),
242 ok: true,
243 data: None,
244 warnings: Vec::new(),
245 error: None,
246 }
247 .with_warning("entry-42 skipped: missing body");
248 let json = serde_json::to_string(&envelope).expect("serialize envelope");
249 assert_eq!(
250 json,
251 "{\"schema_version\":\"cli.memo.apply.v1\",\"ok\":true,\"warnings\":[\"entry-42 skipped: missing body\"]}"
252 );
253 }
254
255 #[test]
256 fn envelope_failure_serializes_error_only() {
257 let envelope: Envelope<()> = Envelope::failure(
258 schema_version_for("cli-template", "error", 1),
259 EnvelopeError::new("parse-error", "missing required argument <name>")
260 .with_hint("see --help"),
261 );
262 let json = serde_json::to_string(&envelope).expect("serialize envelope");
263 assert_eq!(
264 json,
265 "{\"schema_version\":\"cli.cli-template.error.v1\",\"ok\":false,\"error\":{\"code\":\"parse-error\",\"message\":\"missing required argument <name>\",\"hint\":\"see --help\"}}"
266 );
267 }
268
269 #[test]
270 fn exit_constants_match_bsd_sysexits() {
271 assert_eq!(exit::SUCCESS, 0);
272 assert_eq!(exit::RUNTIME, 1);
273 assert_eq!(exit::USAGE, 64);
274 assert_eq!(exit::DATA, 65);
275 assert_eq!(exit::UNAVAILABLE, 69);
276 assert_eq!(exit::SOFTWARE, 70);
277 }
278
279 #[test]
280 fn schema_version_for_builds_canonical_string() {
281 assert_eq!(schema_version_for("memo", "list", 1), "cli.memo.list.v1");
282 assert_eq!(
283 schema_version_for("cli-template", "status", 2),
284 "cli.cli-template.status.v2"
285 );
286 }
287}