git_policy_cli/diagnostics.rs
1//! What:
2//! Render wrapper diagnostics as the JSONL events callers already parse.
3//! Why:
4//! Agents and hooks read cli-git's event stream line by line;
5//! every wrapper-made
6//! event must be one compact JSON object per line with stable field order.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! // process.stderr.write(JSON.stringify({ schemaVersion: 1, sequence: 0, type: 'engine-failure', ... }) + '\n');
11//! ```
12
13/// What:
14/// The event schema version every line carries.
15/// `u32` is an unsigned 32-bit integer (siblings `u8`,
16/// `u64`,
17/// `usize`).
18/// Why:
19/// Consumers branch on this number;
20/// `u32` is ample for a version counter.
21///
22/// In TS you'd write (pseudocode):
23/// ```ts
24/// const SCHEMA_VERSION = 1;
25/// ```
26pub const SCHEMA_VERSION: u32 = 1;
27
28/// Code of the warning for a legacy executable configuration left beside a JSONC file.
29pub const LEGACY_CONFIG_IGNORED_CODE: &str = "legacy-config-ignored";
30
31/// What:
32/// The stable causes of an engine failure this foundation and its successors report.
33/// `#[derive(...)]` generates copying,
34/// debug printing and `==`.
35/// Why:
36/// A closed set keeps the wire spelling in one place.
37/// Codes that described
38/// plugins and trust are not carried over.
39/// A shipped policy that could not finish
40/// is reported by cause:
41/// `ContentUnavailable` when something it had to read could
42/// not be read,
43/// `PolicyIncomplete` when its own machinery failed.
44///
45/// In TS you'd write (pseudocode):
46/// ```ts
47/// type EngineFailureCode = 'config-invalid' | 'core-incomplete' | ... | 'index-lock-unproven-owner';
48/// ```
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub enum EngineFailureCode {
51 /// Configuration could not be read or validated,
52 /// including legacy files needing migration.
53 ConfigInvalid,
54 /// A fixed transform failed unexpectedly.
55 CoreIncomplete,
56 /// Something a policy or lifecycle had to read could not be read:
57 /// a candidate's bytes,
58 /// a repository fact asked of Git,
59 /// or transaction state.
60 /// The policy itself is intact;
61 /// its input is missing.
62 ContentUnavailable,
63 /// A shipped policy's own machinery failed,
64 /// so the policy could not finish although
65 /// its input was readable:
66 /// its rules file could not be loaded,
67 /// its linter could not
68 /// start,
69 /// or it hit an internal error.
70 /// An unreadable candidate or repository fact is
71 /// `ContentUnavailable` instead.
72 PolicyIncomplete,
73 /// A proposed patch was not valid.
74 PatchInvalid,
75 /// A proposed patch no longer applied.
76 PatchConflict,
77 /// Fixing revisited an earlier state.
78 FixCycle,
79 /// Fixing did not settle within the pass limit.
80 FixPassLimit,
81 /// A commit transaction could not complete.
82 TransactionFailed,
83 /// A foreign `index.lock` with an unproven owner outlasted the wait budget.
84 IndexLockUnprovenOwner,
85}
86
87/// What:
88/// The wire spelling of a failure code.
89/// `&'static str` borrows text compiled into the executable for its whole run.
90/// Why:
91/// Events print exactly the spellings the incumbent emitted.
92///
93/// In TS you'd write (pseudocode):
94/// ```ts
95/// function engineFailureCodeName(code: EngineFailureCode): string;
96/// ```
97pub fn engine_failure_code_name(code: EngineFailureCode) -> &'static str {
98 // What: `match` picks the arm for the variant; the compiler rejects a missing one.
99 // Why: A new code cannot be added without giving it a wire spelling.
100 //
101 // In TS you'd write (pseudocode):
102 // ```ts
103 // switch (code) { case 'ConfigInvalid': return 'config-invalid'; ... }
104 // ```
105 match code {
106 EngineFailureCode::ConfigInvalid => return "config-invalid",
107 EngineFailureCode::CoreIncomplete => return "core-incomplete",
108 EngineFailureCode::ContentUnavailable => return "content-unavailable",
109 EngineFailureCode::PolicyIncomplete => return "policy-incomplete",
110 EngineFailureCode::PatchInvalid => return "patch-invalid",
111 EngineFailureCode::PatchConflict => return "patch-conflict",
112 EngineFailureCode::FixCycle => return "fix-cycle",
113 EngineFailureCode::FixPassLimit => return "fix-pass-limit",
114 EngineFailureCode::TransactionFailed => return "transaction-failed",
115 EngineFailureCode::IndexLockUnprovenOwner => return "index-lock-unproven-owner",
116 }
117}
118
119/// What:
120/// Encode text as a JSON string literal,
121/// exactly as `JSON.stringify` would.
122/// `&str` borrows the text;
123/// `String` is the owned,
124/// quoted result.
125/// Why:
126/// Messages contain file paths,
127/// quotes and user-written keys.
128/// Each character is
129/// encoded at this final step so no value can break out of its JSON string or
130/// its line:
131/// quotes,
132/// backslashes and every control character are escaped.
133///
134/// In TS you'd write (pseudocode):
135/// ```ts
136/// function jsonString(text: string): string { return JSON.stringify(text); }
137/// ```
138pub fn json_string(text: &str) -> String {
139 // What: `String::with_capacity(n)` is empty owned text with room for `n` bytes;
140 // `mut` allows appending.
141 // Why: Most text needs only its two surrounding quotes added.
142 //
143 // In TS you'd write (pseudocode):
144 // ```ts
145 // let result = '"';
146 // ```
147 let mut result: String = String::with_capacity(text.len() + 2);
148 result.push('"');
149 // `.chars()` yields each Unicode character of the text in order.
150 for character in text.chars() {
151 if character == '"' {
152 result.push_str("\\\"");
153 } else if character == '\\' {
154 result.push_str("\\\\");
155 } else if character == '\n' {
156 result.push_str("\\n");
157 } else if character == '\r' {
158 result.push_str("\\r");
159 } else if character == '\t' {
160 result.push_str("\\t");
161 } else if character == '\u{8}' {
162 result.push_str("\\b");
163 } else if character == '\u{c}' {
164 result.push_str("\\f");
165 } else if character < ' ' {
166 // What: `u32::from(character)` is the character's code point number;
167 // `{:04x}` prints it as four lowercase hexadecimal digits.
168 // Why: The remaining control characters have no short escape.
169 //
170 // In TS you'd write (pseudocode):
171 // ```ts
172 // result += '\\u' + code.toString(16).padStart(4, '0');
173 // ```
174 result.push_str(format!("\\u{:04x}", u32::from(character)).as_str());
175 } else {
176 result.push(character);
177 }
178 }
179 result.push('"');
180 return result;
181}
182
183/// What:
184/// Render one engine-failure event as a line-terminated JSON object.
185/// `u64` is an unsigned 64-bit integer for the per-invocation event number.
186/// Why:
187/// Field order and spelling match the incumbent's `JSON.stringify` output,
188/// so
189/// existing consumers parse native events unchanged.
190///
191/// In TS you'd write (pseudocode):
192/// ```ts
193/// function renderEngineFailure(sequence: number, code: EngineFailureCode, message: string): string;
194/// ```
195pub fn render_engine_failure(sequence: u64, code: EngineFailureCode, message: &str) -> String {
196 // `format!` builds the owned line; `{{` and `}}` are literal braces.
197 return format!(
198 "{{\"schemaVersion\":{SCHEMA_VERSION},\"sequence\":{sequence},\"type\":\"engine-failure\",\"code\":{},\"message\":{}}}\n",
199 json_string(engine_failure_code_name(code)),
200 json_string(message)
201 );
202}
203
204/// What:
205/// Render one configuration-warning event as a line-terminated JSON object.
206/// Why:
207/// A warning about configuration itself (a legacy file left beside the JSONC
208/// file) belongs on the machine stream as an event,
209/// never as free prose there.
210/// Unlike the incumbent's per-policy `warn-unsafe` warning it has no policy or
211/// trigger,
212/// so it carries a path instead.
213///
214/// In TS you'd write (pseudocode):
215/// ```ts
216/// function renderConfigurationWarning(sequence: number, code: string, message: string, path: string): string;
217/// ```
218pub fn render_configuration_warning(
219 sequence: u64,
220 code: &str,
221 message: &str,
222 path: &str,
223) -> String {
224 return format!(
225 "{{\"schemaVersion\":{SCHEMA_VERSION},\"sequence\":{sequence},\"type\":\"configuration-warning\",\"code\":{},\"message\":{},\"path\":{}}}\n",
226 json_string(code),
227 json_string(message),
228 json_string(path)
229 );
230}
231
232/// Encoding and field-order controls stay out of the release executable.
233#[cfg(test)]
234#[path = "diagnostics_tests.rs"]
235mod tests;