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