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' | ... | '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;