Skip to main content

xtask/rmcp_release_monitor/
diagnostics.rs

1//! Diagnosis of malformed JSON payloads fetched in CI. Split out of
2//! `rmcp_release_monitor.rs` to stay under the PATTERNS.md module size hard
3//! limit.
4
5/// Enough to identify what the fetch actually wrote - an ANSI escape run, an
6/// HTML error page, a GitHub API error object - without dumping a multi-hundred
7/// kilobyte payload into the CI log.
8pub(super) const JSON_PREVIEW_BYTES: usize = 200;
9
10/// Builds the `anyhow` context for a failed `serde_json` parse of a fetched
11/// payload.
12///
13/// A bare serde message ("expected value at line 1 column 1") throws away the
14/// only evidence that matters: what the fetch actually wrote. The scheduled
15/// monitor failed opaquely for weeks because of it - the real payload was
16/// ANSI-colorized JSON, which is indistinguishable from "empty file" or "API
17/// error object" once the bytes are discarded. Report the size, a diagnosis
18/// when the payload has a recognizable shape, and an escaped head.
19pub(super) fn json_parse_context(label: &str, payload: &str) -> String {
20    let mut message = format!("failed to parse {label}; {} bytes", payload.len());
21    if let Some(diagnosis) = diagnose_json_payload(payload) {
22        message.push_str("; ");
23        message.push_str(diagnosis);
24    }
25    message.push_str("; payload head: ");
26    message.push_str(&escaped_head(payload, JSON_PREVIEW_BYTES));
27    message
28}
29
30/// Recognizes the payload shapes that actually show up when a JSON fetch goes
31/// wrong in CI, and names the fix.
32pub(super) fn diagnose_json_payload(payload: &str) -> Option<&'static str> {
33    if payload.starts_with('\u{feff}') {
34        return Some("payload starts with a UTF-8 byte-order mark");
35    }
36    let trimmed = payload.trim_start();
37    if trimmed.is_empty() {
38        return Some("payload is empty - the upstream fetch wrote nothing");
39    }
40    if trimmed.contains("\"message\"") && trimmed.contains("\"documentation_url\"") {
41        return Some(
42            "payload looks like a GitHub API error object - check the token, its permissions, \
43             and the rate limit",
44        );
45    }
46    match trimmed.chars().next()? {
47        '\u{1b}' => Some(
48            "payload starts with an ANSI escape - a CLI wrote colorized output instead of raw \
49             JSON. `gh` forces color when CLICOLOR_FORCE is set to anything but 0, even with \
50             stdout redirected to a file, and NO_COLOR does not override it; set \
51             CLICOLOR_FORCE=0 on the fetch step",
52        ),
53        '<' => Some("payload starts with '<' - this looks like HTML, not JSON"),
54        _ => None,
55    }
56}
57
58/// Renders the first `max_bytes` of `payload` as a quoted, escaped, pure-ASCII
59/// string so control characters (notably ESC) are visible in the CI log.
60pub(super) fn escaped_head(payload: &str, max_bytes: usize) -> String {
61    let mut end = payload.len().min(max_bytes);
62    while !payload.is_char_boundary(end) {
63        end -= 1;
64    }
65    let escaped: String = payload[..end]
66        .chars()
67        .flat_map(char::escape_debug)
68        .collect();
69    if end < payload.len() {
70        format!("\"{escaped}\"...")
71    } else {
72        format!("\"{escaped}\"")
73    }
74}