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}