sqlite_graphrag/output/error_envelope.rs
1//! Failure envelopes on stdout, with a hand-rolled fallback.
2//!
3//! The stdout JSON contract holds on error paths too: a machine consumer must
4//! be able to parse the failure, not just read a stderr line. That is why each
5//! emitter here has a second path built with `writeln!` — if `serde_json`
6//! itself fails, the caller still receives a parseable envelope rather than
7//! empty stdout.
8//!
9//! These envelopes carry `error: true`, which [`crate::agent_surface`] treats
10//! as pass-through. A `--filter` can never suppress a failure.
11
12use super::envelope::emit_json;
13
14/// Escapes a string for inclusion in a hand-built JSON string literal.
15///
16/// Only the two characters that can break out of a quoted literal are
17/// handled, which is exactly what the fallback paths below need: the inputs
18/// are localized messages and suggestions, never arbitrary binary. A control
19/// character would produce technically invalid JSON, but the alternative —
20/// pulling in a serializer on the path taken *because* serialization failed —
21/// defeats the point of having a fallback at all.
22fn escape(value: &str) -> String {
23 value.replace('\\', "\\\\").replace('"', "\\\"")
24}
25
26/// The one failure envelope every emitter below renders.
27///
28/// `error_class` and `retryable` carry no `skip_serializing_if`: an agent needs
29/// the retry verdict on EVERY failure, and a field that vanishes when it is
30/// `false` forces the reader to distinguish "not retryable" from "this build
31/// does not report it". Only `suggestion` is optional, because a variant whose
32/// message is already self-remediating has nothing to add.
33#[derive(serde::Serialize)]
34struct ErrorEnvelope<'a> {
35 error: bool,
36 code: i32,
37 message: &'a str,
38 error_class: &'a str,
39 retryable: bool,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 suggestion: Option<&'a str>,
42}
43
44/// Emits the failure envelope, with a hand-rolled fallback.
45///
46/// Single rendering point: the fallback path used to exist twice, once per
47/// public emitter, so a field added to one envelope silently skipped the other.
48///
49/// Shape: `{"error": true, "code": <exit>, "message": "...", "error_class":
50/// "transient|permanent|ambiguous", "retryable": <bool>, "suggestion": "..."}`.
51/// A `BrokenPipe` is silenced so piping to an early-closing consumer does not
52/// surface a secondary error.
53#[cold]
54#[inline(never)]
55pub fn emit_error_envelope(
56 code: i32,
57 message: &str,
58 error_class: &str,
59 retryable: bool,
60 suggestion: Option<&str>,
61) {
62 let envelope = ErrorEnvelope {
63 error: true,
64 code,
65 message,
66 error_class,
67 retryable,
68 suggestion,
69 };
70 if emit_json(&envelope).is_err() {
71 use std::io::Write;
72 let escaped = escape(message);
73 let esc_class = escape(error_class);
74 let head = format!(
75 r#"{{"error":true,"code":{code},"message":"{escaped}","error_class":"{esc_class}","retryable":{retryable}"#
76 );
77 let line = match suggestion {
78 Some(s) => format!(r#"{head},"suggestion":"{}"}}"#, escape(s)),
79 None => format!("{head}}}"),
80 };
81 let _ = writeln!(std::io::stdout().lock(), "{line}");
82 }
83}
84
85/// Emits a configuration failure, which is permanent by construction.
86///
87/// Bootstrap failures — a missing provider key, an unreadable XDG file, a model
88/// the catalogue rejects — cannot be fixed by trying again, so they are always
89/// `permanent` / `retryable: false`. Callers holding a real [`AppError`] must
90/// use [`emit_error_json_with_suggestion`] instead, which reads the verdict off
91/// the variant.
92///
93/// [`AppError`]: crate::errors::AppError
94#[cold]
95#[inline(never)]
96pub fn emit_error_json(code: i32, message: &str) {
97 emit_error_envelope(code, message, "permanent", false, None);
98}
99
100/// GAP-SG-39: emits the actionable failure envelope for a classified error.
101///
102/// The `suggestion` tells the operator HOW to recover instead of leaving an exit
103/// code without guidance, and `error_class` / `retryable` tell an agent whether
104/// recovering is even possible — which is what makes a write rejection
105/// observable, fixable, and safe to automate.
106#[cold]
107#[inline(never)]
108pub fn emit_error_json_with_suggestion(
109 code: i32,
110 message: &str,
111 error_class: &str,
112 retryable: bool,
113 suggestion: Option<&str>,
114) {
115 emit_error_envelope(code, message, error_class, retryable, suggestion);
116}
117
118#[cfg(test)]
119mod tests {
120 use super::escape;
121
122 #[test]
123 fn escape_leaves_plain_text_untouched() {
124 assert_eq!(escape("database is malformed"), "database is malformed");
125 }
126
127 #[test]
128 fn escape_protects_quotes_and_backslashes() {
129 assert_eq!(escape(r#"say "hi""#), r#"say \"hi\""#);
130 assert_eq!(escape(r"C:\path"), r"C:\\path");
131 }
132
133 #[test]
134 fn escape_orders_backslash_before_quote() {
135 // Escaping the quote first would then double the backslash it just
136 // introduced, producing `\\"` and breaking out of the literal.
137 assert_eq!(escape(r#"\""#), r#"\\\""#);
138 }
139}