Skip to main content

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' | ... | 'transaction-failed';
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}
87
88/// What:
89///  The wire spelling of a failure code.
90///       `&'static str` borrows text compiled into the executable for its whole run.
91/// Why:
92///   Events print exactly the spellings the incumbent emitted.
93///
94/// In TS you'd write (pseudocode):
95/// ```ts
96/// function engineFailureCodeName(code: EngineFailureCode): string;
97/// ```
98pub fn engine_failure_code_name(code: EngineFailureCode) -> &'static str {
99    // What: `match` picks the arm for the variant; the compiler rejects a missing one.
100    // Why:  A new code cannot be added without giving it a wire spelling.
101    //
102    // In TS you'd write (pseudocode):
103    // ```ts
104    // switch (code) { case 'ConfigInvalid': return 'config-invalid'; ... }
105    // ```
106    match code {
107        EngineFailureCode::ConfigInvalid => return "config-invalid",
108        EngineFailureCode::CoreIncomplete => return "core-incomplete",
109        EngineFailureCode::ContentUnavailable => return "content-unavailable",
110        EngineFailureCode::PolicyIncomplete => return "policy-incomplete",
111        EngineFailureCode::PatchInvalid => return "patch-invalid",
112        EngineFailureCode::PatchConflict => return "patch-conflict",
113        EngineFailureCode::FixCycle => return "fix-cycle",
114        EngineFailureCode::FixPassLimit => return "fix-pass-limit",
115        EngineFailureCode::TransactionFailed => return "transaction-failed",
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    return render_engine_failure_with_hint(sequence, code, message, engine_failure_hint(engine_failure_code_name(code)));
197}
198
199/// What:
200///  Render one engine-failure event with the hint the caller chooses,
201///  or with none.
202/// Why:
203///   A hint is a guess about the goal that depends on the code alone.
204///  A message that already says what the person can do (a retained transaction names the file to remove or restore,
205///  and what the wrapper then does) must not carry the code's generic hint, which may not apply to it.
206///
207/// In TS you'd write (pseudocode):
208/// ```ts
209/// function renderEngineFailureWithHint(sequence: number, code: EngineFailureCode, message: string, hint?: string): string;
210/// ```
211pub fn render_engine_failure_with_hint(
212    sequence: u64,
213    code: EngineFailureCode,
214    message: &str,
215    hint: Option<&str>,
216) -> String {
217    // `format!` builds the owned line; `{{` and `}}` are literal braces.
218    let name: &str = engine_failure_code_name(code);
219    let mut line: String = format!(
220        "{{\"schemaVersion\":{SCHEMA_VERSION},\"sequence\":{sequence},\"type\":\"engine-failure\",\"code\":{},\"message\":{}",
221        json_string(name),
222        json_string(message)
223    );
224    push_hint_field(&mut line, hint);
225    line.push_str("}\n");
226    return line;
227}
228
229/// What:
230///  Render one configuration-warning event as a line-terminated JSON object.
231/// Why:
232///   A warning about configuration itself (a legacy file left beside the JSONC
233///       file) belongs on the machine stream as an event,
234///  never as free prose there.
235///       Unlike the incumbent's per-policy `warn-unsafe` warning it has no policy or
236///       trigger,
237///  so it carries a path instead.
238///
239/// In TS you'd write (pseudocode):
240/// ```ts
241/// function renderConfigurationWarning(sequence: number, code: string, message: string, path: string): string;
242/// ```
243pub fn render_configuration_warning(
244    sequence: u64,
245    code: &str,
246    message: &str,
247    path: &str,
248) -> String {
249    let mut line: String = format!(
250        "{{\"schemaVersion\":{SCHEMA_VERSION},\"sequence\":{sequence},\"type\":\"configuration-warning\",\"code\":{},\"message\":{},\"path\":{}",
251        json_string(code),
252        json_string(message),
253        json_string(path)
254    );
255    push_hint_field(&mut line, engine_failure_hint(code));
256    line.push_str("}\n");
257    return line;
258}
259
260/// Encoding and field-order controls stay out of the release executable.
261#[cfg(test)]
262#[path = "diagnostics_tests.rs"]
263mod tests;