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/// 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;