Skip to main content

ignition_core/client/
diagnostics.rs

1//! Diagnostics-bundle capability (09-05, EXT-02; 09-07 Invalid
2//! capture) — the support-bundle slice of
3//! `GET/POST /data/api/v1/diagnostics/bundle/*`, wire-shaped STRICTLY
4//! from the live captures (09-LIVE-CAPTURES §5 — the Pitfall-2
5//! capture: observed, never guessed).
6//!
7//! **The state vocabulary is capture-locked — THREE states observed**
8//! across the 09-02 rigs and the 2026-09-07 UAT (09-UAT.md Gap 3):
9//! `Generating` (mid-generation, both rigs), `Valid` (terminal/ready,
10//! both rigs), and `Invalid` (TERMINAL steady state — the gateway's
11//! steady-state "no current bundle" answer). PascalCase throughout;
12//! lowercase guesses would have shipped wrong. `Invalid`'s provenance
13//! (8.3.6 rig ign-p9-836, 2026-09-07): a `Valid` bundle decays to
14//! `Invalid` within ~2 minutes UNPROMPTED under passive status-only
15//! polling (TTL-probe evidence: Valid at min 4-5, Invalid at min 6)
16//! and STAYS `Invalid` — only a fresh generate changes it, so the
17//! wait action exits immediately on it (`BUNDLE_UNAVAILABLE_STATES`,
18//! exit 6 `bundle_not_available`). Any string OUTSIDE the captured
19//! set is UNKNOWN: it rides passthrough, never refuses, and is
20//! treated as NOT terminal by the wait action (honest unknowns —
21//! keep polling to the deadline).
22//!
23//! **Gateway-side wire quirk (observation, NOT behavior):** during
24//! the same UAT the one `Valid` bundle reported `fileSize` 64708 on
25//! one poll and 73889 on others — the bundle file appears non-static
26//! while `Valid`. The CLI reports the value faithfully at every hop;
27//! this is recorded as a gateway-side fact only, encoded nowhere as
28//! behavior.
29//!
30//! **`fileSize` units (Decisions 2):** ABSENT while generating (not
31//! null, not 0 — the key is missing), present integer **bytes** when
32//! `Valid`, equal to the download's `Content-Length` exactly.
33
34use std::collections::BTreeMap;
35use std::time::Duration;
36
37use serde::{Deserialize, Serialize};
38
39/// POST path that starts bundle generation (no body).
40pub const DIAGNOSTICS_GENERATE_PATH: &str = "/data/api/v1/diagnostics/bundle/generate";
41
42/// GET path of the bundle status poll.
43pub const DIAGNOSTICS_STATUS_PATH: &str = "/data/api/v1/diagnostics/bundle/status";
44
45/// GET path of the bundle download (a ZIP, streamed).
46pub const DIAGNOSTICS_DOWNLOAD_PATH: &str = "/data/api/v1/diagnostics/bundle/download";
47
48/// The states that mean "still generating" — EXACTLY the captured
49/// mid-generation strings, one member on the captured evidence
50/// (09-LIVE-CAPTURES §5 Decisions 1). Case-SENSITIVE membership: the
51/// gateway owns the vocabulary (PascalCase); this CLI never guesses.
52pub const BUNDLE_GENERATING_STATES: &[&str] = &[
53    // 8.3.6 rig A + 8.3.3 rig B: the POST generate answer and every
54    // pre-completion status poll (09-LIVE-CAPTURES §5).
55    "Generating",
56];
57
58/// The full CAPTURED state vocabulary — every state the rigs ever
59/// answered (09-LIVE-CAPTURES §5 + the 2026-09-07 UAT, 09-UAT.md Gap
60/// 3). A string OUTSIDE this set is UNKNOWN: the wait action keeps
61/// polling (honest unknowns — a state the captures never saw must not
62/// be declared terminal). Unobserved members still round-trip through
63/// the wire model's passthrough (version tolerance).
64pub const BUNDLE_CAPTURED_STATES: &[&str] = &[
65    // 8.3.6 rig A + 8.3.3 rig B (09-LIVE-CAPTURES §5).
66    "Generating",
67    // Terminal/ready on both rigs — `fileSize` present, download
68    // repeatable, ready state persists after download.
69    "Valid",
70    // 8.3.6 rig ign-p9-836, 2026-09-07 UAT (09-UAT.md Gap 3): Valid decays to Invalid within ~2 minutes UNPROMPTED (passive status-only polling; TTL probe) and stays Invalid — the steady-state "no current bundle" answer. TERMINAL.
71    "Invalid",
72];
73
74/// The captured states meaning "no bundle is available" — TERMINAL
75/// steady states the wait action refuses on IMMEDIATELY (exit 6,
76/// `bundle_not_available`): only a fresh `generate` changes them,
77/// polling cannot. Case-SENSITIVE membership, same shape as
78/// [`is_generating`]. A subset of [`BUNDLE_CAPTURED_STATES`] by
79/// construction (unit-pinned below).
80pub const BUNDLE_UNAVAILABLE_STATES: &[&str] = &[
81    // 8.3.6 rig ign-p9-836, 2026-09-07 UAT (09-UAT.md Gap 3): Valid decays to Invalid within ~2 minutes UNPROMPTED (passive status-only polling; TTL probe) and stays Invalid — the steady-state "no current bundle" answer. TERMINAL.
82    "Invalid",
83];
84
85/// `true` when `state` is in the CAPTURED generating set — a
86/// case-sensitive exact match (`BUNDLE_GENERATING_STATES` membership).
87/// Unknown states are deliberately NOT generating (they are not
88/// anything we have seen).
89pub fn is_generating(state: &str) -> bool {
90    BUNDLE_GENERATING_STATES.contains(&state)
91}
92
93/// `true` when `state` is in the CAPTURED unavailable set — a
94/// case-sensitive exact match (`BUNDLE_UNAVAILABLE_STATES`
95/// membership). Unknown states are deliberately NOT unavailable
96/// (they are not anything we have seen).
97pub fn is_bundle_unavailable(state: &str) -> bool {
98    BUNDLE_UNAVAILABLE_STATES.contains(&state)
99}
100
101/// Per-request timeout override for the bundle download (Pitfall 8):
102/// the 30 s client default would TRUNCATE MB-sized bundles mid-stream.
103/// Same class as the logs-download 120 s / project-export 120 s /
104/// gwbk 300 s overrides — this ride goes through
105/// `download_to_file`'s `RequestBuilder::timeout`, never a second
106/// client. Pinned at 300 s by the unit test below (the REQUIRED
107/// deterministic timeout-override check lives here, where the
108/// constant is born).
109pub const BUNDLE_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300);
110
111/// One bundle status body (`POST …/generate`'s 200 answer IS the same
112/// shape — live capture: `{"state":"Generating"}`). `state` is a
113/// String BY DECISION (Pitfall 2: never an enum from guessed values —
114/// an unobserved future state must ride, never refuse the parse).
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct BundleStatusWire {
117    /// The captured vocabulary is `Generating` (mid-generation) /
118    /// `Valid` (terminal/ready) / `Invalid` (terminal steady state —
119    /// "no current bundle") — PascalCase, live-proven
120    /// (09-LIVE-CAPTURES §5 + 09-UAT.md Gap 3). Anything else is an
121    /// UNOBSERVED state riding passthrough: never an enum, never a
122    /// refusal.
123    #[serde(default)]
124    pub state: String,
125    /// Bundle size — **bytes** (Decisions 2: `fileSize` ==
126    /// download `Content-Length` exactly). ABSENT while generating
127    /// (the key is missing, not null) → `Option`; present when
128    /// `Valid`.
129    #[serde(
130        rename = "fileSize",
131        alias = "file_size",
132        default,
133        skip_serializing_if = "Option::is_none"
134    )]
135    pub file_size: Option<u64>,
136    /// Unknown keys round-trip (version tolerance).
137    #[serde(flatten)]
138    pub extra: BTreeMap<String, serde_json::Value>,
139}
140
141#[cfg(test)]
142mod tests {
143    use std::time::Duration;
144
145    use super::{
146        BUNDLE_CAPTURED_STATES, BUNDLE_DOWNLOAD_TIMEOUT, BundleStatusWire, is_bundle_unavailable,
147        is_generating,
148    };
149
150    /// THE 8.3.6 captures (rig A, 09-LIVE-CAPTURES §5): the generate
151    /// answer and the generating poll (no fileSize key), then the
152    /// terminal `Valid` poll with `fileSize` = 61053 (== the download
153    /// Content-Length).
154    #[test]
155    fn bundle_status_parses_the_live_8_3_6_captures() {
156        let generating: BundleStatusWire =
157            serde_json::from_str(r#"{"state":"Generating"}"#).expect("generating capture parses");
158        assert_eq!(generating.state, "Generating");
159        assert_eq!(
160            generating.file_size, None,
161            "fileSize is ABSENT while generating (not null, not 0)"
162        );
163
164        let valid: BundleStatusWire = serde_json::from_str(r#"{"state":"Valid","fileSize":61053}"#)
165            .expect("terminal capture parses");
166        assert_eq!(valid.state, "Valid");
167        assert_eq!(valid.file_size, Some(61_053), "bytes == Content-Length");
168
169        // Wire-faithful round-trip: the gateway-native camelCase key on
170        // the way out, absent while generating.
171        let round = serde_json::to_value(&generating).expect("serialize");
172        assert!(round.get("fileSize").is_none(), "absent stays absent");
173        let round = serde_json::to_value(&valid).expect("serialize");
174        assert_eq!(round["fileSize"], 61_053);
175    }
176
177    /// THE 8.3.3 capture (rig B, 09-LIVE-CAPTURES §5): same shape,
178    /// `fileSize` = 55281 (no point-release drift).
179    #[test]
180    fn bundle_status_parses_the_live_8_3_3_capture() {
181        let valid: BundleStatusWire = serde_json::from_str(r#"{"state":"Valid","fileSize":55281}"#)
182            .expect("terminal capture parses");
183        assert_eq!(valid.state, "Valid");
184        assert_eq!(valid.file_size, Some(55_281));
185    }
186
187    /// The generating-set membership is exact and case-sensitive: the
188    /// captured `Generating` is in; `Valid` (captured terminal) is
189    /// NOT; an unknown state is NOT generating (it is not anything we
190    /// have seen — honest unknowns).
191    #[test]
192    fn generating_membership_is_exact() {
193        assert!(is_generating("Generating"));
194        assert!(!is_generating("Valid"));
195        // Pitfall 2 honesty: an unobserved state is not "generating"
196        // either — the vocabulary is the gateway's, never ours.
197        assert!(!is_generating("Failed"));
198        assert!(!is_generating("generating"), "case-sensitive");
199        assert!(!is_generating("RUNNING"), "no guessed vocabulary");
200        assert!(!is_generating(""));
201    }
202
203    /// The captured vocabulary is exactly the three observed states
204    /// (Generating / Valid / Invalid — 09-LIVE-CAPTURES §5 + the
205    /// 2026-09-07 UAT) and the generating set is its single-member
206    /// subset (Decisions 1 + 09-07 Gap 3).
207    #[test]
208    fn captured_vocabulary_is_the_three_observed_states() {
209        assert_eq!(BUNDLE_CAPTURED_STATES, &["Generating", "Valid", "Invalid"]);
210        assert_eq!(super::BUNDLE_GENERATING_STATES, &["Generating"]);
211        assert_eq!(super::BUNDLE_UNAVAILABLE_STATES, &["Invalid"]);
212    }
213
214    /// THE 09-07 Gap-3 encoding: `Invalid` is a captured TERMINAL
215    /// steady state — it is in the captured vocabulary, it is NOT
216    /// generating (not pending), and it IS unavailable (the
217    /// immediate-exit class). `Valid` stays terminal-SUCCESS (never
218    /// unavailable), and a genuinely unknown state is neither.
219    #[test]
220    fn invalid_is_a_captured_terminal_unavailable_state() {
221        assert!(
222            BUNDLE_CAPTURED_STATES.contains(&"Invalid"),
223            "Invalid is in the captured vocabulary"
224        );
225        assert!(
226            !is_generating("Invalid"),
227            "Invalid is terminal, not mid-generation"
228        );
229        assert!(
230            is_bundle_unavailable("Invalid"),
231            "Invalid is the unavailable (immediate-exit) class"
232        );
233        assert!(
234            !is_bundle_unavailable("Valid"),
235            "Valid stays terminal success"
236        );
237        assert!(
238            !is_generating("Mystery") && !is_bundle_unavailable("Mystery"),
239            "a genuinely unknown state is neither — honest unknowns keep polling"
240        );
241    }
242
243    /// THE deterministic timeout-override pin (Pitfall 8): the bundle
244    /// download's per-request timeout is 300 s — the 30 s client
245    /// default would truncate MB-sized bundles. This unit assertion is
246    /// the REQUIRED check for the phase (it lives where the constant
247    /// is born; the download ride through `download_to_file`'s
248    /// `.timeout()` is pinned by construction + the rg review check).
249    #[test]
250    fn bundle_download_timeout_is_300_seconds() {
251        assert_eq!(BUNDLE_DOWNLOAD_TIMEOUT, Duration::from_secs(300));
252    }
253
254    /// Unknown keys ride the flatten passthrough (version tolerance).
255    #[test]
256    fn unknown_keys_round_trip() {
257        let wire: BundleStatusWire = serde_json::from_value(serde_json::json!({
258            "state": "Valid",
259            "fileSize": 61053,
260            "zzFuturePointReleaseKey": {"future": true}
261        }))
262        .expect("unknown keys never refuse");
263        assert_eq!(
264            wire.extra.get("zzFuturePointReleaseKey"),
265            Some(&serde_json::json!({"future": true})),
266            "unknown keys ride passthrough"
267        );
268        assert_eq!(wire.state, "Valid");
269    }
270
271    /// BUNDLE_CAPTURED_STATES shapes the wait semantics: a captured
272    /// NON-generating state is terminal; both special sets stay
273    /// subsets of the captured vocabulary by construction (generating
274    /// ⊆ captured, unavailable ⊆ captured).
275    #[test]
276    fn generating_set_is_subset_of_captured_vocabulary() {
277        for state in super::BUNDLE_GENERATING_STATES {
278            assert!(
279                BUNDLE_CAPTURED_STATES.contains(state),
280                "every generating state is captured: {state}"
281            );
282        }
283        for state in super::BUNDLE_UNAVAILABLE_STATES {
284            assert!(
285                BUNDLE_CAPTURED_STATES.contains(state),
286                "every unavailable state is captured: {state}"
287            );
288        }
289    }
290}