fallow_output/error_envelope.rs
1//! The structured error envelope emitted on stdout for `--format json`.
2
3use serde::Serialize;
4
5/// Structured JSON error emitted on stdout when `--format json` is active and a
6/// command fails. It carries no `kind` discriminator: it is distinguished from
7/// the kind-tagged success envelopes by the required `error: true` field, and is
8/// a document-root branch alongside `FallowOutput` and `CodeClimateOutput` in
9/// `docs/output-schema.json`. Agents that pass `--format json` and observe a
10/// non-zero exit code parse this shape from stdout.
11#[derive(Debug, Clone, Serialize)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13pub struct ErrorOutput {
14 /// Always `true`. The discriminator that separates an error document from a
15 /// success envelope (which instead carries a `kind`).
16 pub error: bool,
17 /// Human-readable error message.
18 pub message: String,
19 /// The process exit code the CLI returns alongside this document.
20 pub exit_code: u8,
21 /// Stable machine-readable code such as `FALLOW_INVALID_COVERAGE_PATH`,
22 /// when the failure has one. Present so an agent can branch on the reason
23 /// without pattern-matching the human message.
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub code: Option<String>,
26 /// Remediation hint for the caller, when the failure has one.
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub help: Option<String>,
29}
30
31impl ErrorOutput {
32 /// Build an error envelope for the given message and exit code.
33 #[must_use]
34 pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
35 Self {
36 error: true,
37 message: message.into(),
38 exit_code,
39 code: None,
40 help: None,
41 }
42 }
43
44 /// Attach a stable machine-readable code.
45 #[must_use]
46 pub fn with_code(mut self, code: Option<String>) -> Self {
47 self.code = code;
48 self
49 }
50
51 /// Attach a remediation hint.
52 #[must_use]
53 pub fn with_help(mut self, help: Option<String>) -> Self {
54 self.help = help;
55 self
56 }
57}
58
59#[cfg(test)]
60mod tests {
61 use super::ErrorOutput;
62
63 /// `code` and `help` are additive-optional: an envelope without them must
64 /// serialize exactly as it did before the fields existed, so a consumer
65 /// pinned to the old shape sees no new keys.
66 #[test]
67 fn absent_code_and_help_add_no_keys() {
68 let json = serde_json::to_value(ErrorOutput::new("boom", 2)).expect("serializes");
69 let object = json.as_object().expect("object");
70 assert_eq!(object.len(), 3, "{json}");
71 assert!(!object.contains_key("code"));
72 assert!(!object.contains_key("help"));
73 }
74
75 #[test]
76 fn code_and_help_reach_the_wire_when_present() {
77 let json = serde_json::to_value(
78 ErrorOutput::new("boom", 2)
79 .with_code(Some("FALLOW_BOOM".to_owned()))
80 .with_help(Some("try harder".to_owned())),
81 )
82 .expect("serializes");
83 assert_eq!(json["code"], "FALLOW_BOOM");
84 assert_eq!(json["help"], "try harder");
85 }
86}