ignition_core/client/version.rs
1//! Gateway-info model + the minimum-version gate (CORE-08).
2//!
3//! Field names match the **live 8.3.6 gateway** exactly (captured
4//! 2026-08-21, 02-RESEARCH §Status/info + the gateway's own openapi
5//! schema): the version field is `ignitionVersion` — Phase 1's `version`
6//! name failed deserialization against every real gateway, fixed here
7//! with an alias that tolerates any 8.3.x still shipping the old name.
8//! `state`/`uptime` do NOT exist on this payload (running state and
9//! uptime come from `/overview` + `/StatusPing` in 02-02) — the model
10//! stays truthful to what the endpoint returns.
11
12use serde::{Deserialize, Serialize};
13
14/// Minimum gateway version `ign` supports (CORE-08). Appears in the
15/// `gateway_too_old` envelope and its hint.
16pub const MIN_GATEWAY: &str = "8.3.1";
17
18/// `/data/api/v1/gateway-info` response — field names match the gateway.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct GatewayInfo {
21 /// Gateway display name, when reported.
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub name: Option<String>,
24 /// Redundancy role (`"Independent"` / `"Master"` / `"Backup"`), when
25 /// reported. Configuration only: the active node is decided by
26 /// `activityLevel` (`client::redundancy::active_node`), not by role.
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub redundancy_role: Option<String>,
29 /// Edition (`"standard"` / `"maker"` / …), when reported.
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub edition: Option<String>,
32 /// Version + build revision in one string, e.g.
33 /// `"8.3.6 (b2026042713)"`. Serialized under the gateway-native key
34 /// (`ignitionVersion`, passthrough-shaped `--json` data); the alias
35 /// tolerates any 8.3.x still shipping the Phase-1-era `version` name.
36 #[serde(rename = "ignitionVersion", alias = "version")]
37 pub ignition_version: String,
38 /// JVM version passthrough, when reported.
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub jvm_version: Option<String>,
41 /// License summary, when reported.
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub license: Option<LicenseInfo>,
44 /// The request URL (populated by the client, never serialized) so
45 /// error variants built from this info can carry `endpoint` (CORE-05).
46 #[serde(skip, default)]
47 pub endpoint: Option<String>,
48}
49
50/// License block of gateway-info (`license: {mode, expirationDate, …}`).
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub struct LicenseInfo {
53 /// `"Trial"` / `"Licensed"` / …
54 pub mode: String,
55 /// Trial/license expiration, when reported. Serialized under the
56 /// gateway-native key (camelCase, like `ignitionVersion`); the
57 /// snake_case alias tolerates non-gateway shapers. (02-02 found the
58 /// missing rename silently DROPPED the gateway's `expirationDate`
59 /// on parse — the 02-01 fix covered `ignitionVersion` only.)
60 #[serde(
61 rename = "expirationDate",
62 alias = "expiration_date",
63 default,
64 skip_serializing_if = "Option::is_none"
65 )]
66 pub expiration_date: Option<String>,
67}
68
69/// Is `raw` below [`MIN_GATEWAY`]? Suffix- and short-form tolerant;
70/// unparseable → `true` — refuse safely rather than guess (CORE-08).
71///
72/// Note: the comparison target is the plain three-component semver
73/// `8.3.1` — `semver::Version::parse` is strict, so a four-component
74/// literal like `"8.3.1.0"` would NOT parse (the research sketch's
75/// `.0`-appended constant would have made EVERY comparison fail).
76pub fn below_minimum(raw: &str) -> bool {
77 // Gateway versions are dotted triples, sometimes with a "-SNAPSHOT…"
78 // or space suffix — compare the leading numeric part only.
79 let clean = raw.trim().split(['-', ' ']).next().unwrap_or(raw);
80 // Tolerate the short "8.3" form by appending a patch component.
81 let normalized = if clean.matches('.').count() == 1 {
82 format!("{clean}.0")
83 } else {
84 clean.to_string()
85 };
86 match semver::Version::parse(&normalized) {
87 Ok(version) => {
88 version < semver::Version::parse(MIN_GATEWAY).expect("MIN_GATEWAY is valid semver")
89 }
90 Err(_) => true,
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::{GatewayInfo, LicenseInfo, MIN_GATEWAY, below_minimum};
97
98 /// CORE-08 boundary table — every row of the locked comparison matrix,
99 /// including the EXACT live strings captured from 8.3.6 (02-RESEARCH).
100 #[test]
101 fn below_minimum_matrix() {
102 // (raw, expected below minimum?)
103 let cases = [
104 ("8.3.1", false), // exactly the minimum
105 ("8.3.2", false), // above
106 ("8.3.0", true), // one patch below
107 ("8.1.10", true), // older minor line
108 ("8.3", true), // short form → 8.3.0
109 ("8.3.1-SNAPSHOT.20260801", false), // suffix stripped → equal
110 (" 8.3.2 ", false), // surrounding space tolerated
111 ("garbage", true), // unparseable → refuse
112 ("", true), // empty → refuse
113 ("9.0.0", false), // future major
114 // Live-capture rows (8.3.6, b-build suffix in parens).
115 ("8.3.6 (b2026042713)", false), // the exact live string
116 ("8.3.6", false), // its bare prefix
117 ];
118 for (raw, expected) in cases {
119 assert_eq!(
120 below_minimum(raw),
121 expected,
122 "below_minimum({raw:?}) must be {expected}"
123 );
124 }
125 assert_eq!(MIN_GATEWAY, "8.3.1");
126 }
127
128 /// The live-shape regression at the model level: the field is
129 /// `ignitionVersion` (camelCase from the gateway) and the Phase-1-era
130 /// `version` name still parses via the alias — both must deserialize.
131 #[test]
132 fn gateway_info_parses_live_and_legacy_field_names() {
133 let live = serde_json::json!({
134 "name": "ign-live",
135 "redundancyRole": "Independent",
136 "edition": "standard",
137 "ignitionVersion": "8.3.6 (b2026042713)",
138 "jvmVersion": "17.0.11",
139 "license": {"mode": "Trial", "expirationDate": "2026-08-21"}
140 });
141 let info: GatewayInfo =
142 serde_json::from_value(live).expect("live ignitionVersion shape parses");
143 assert_eq!(info.ignition_version, "8.3.6 (b2026042713)");
144 assert_eq!(info.name.as_deref(), Some("ign-live"));
145 assert_eq!(info.license.as_ref().expect("license").mode, "Trial");
146
147 let legacy = serde_json::json!({"version": "8.3.2"});
148 let info: GatewayInfo =
149 serde_json::from_value(legacy).expect("legacy `version` name still parses (alias)");
150 assert_eq!(info.ignition_version, "8.3.2");
151 }
152
153 /// The `--json` data key is the gateway-native `ignitionVersion`
154 /// (passthrough shape; LOCKED additive field).
155 #[test]
156 fn gateway_info_serializes_under_the_gateway_native_key() {
157 let info = GatewayInfo {
158 name: Some("ign-live-rig".into()),
159 redundancy_role: Some("Independent".into()),
160 edition: Some("standard".into()),
161 ignition_version: "8.3.6 (b2026042713)".into(),
162 jvm_version: None,
163 license: Some(LicenseInfo {
164 mode: "Trial".into(),
165 expiration_date: None,
166 }),
167 endpoint: None,
168 };
169 let json = serde_json::to_value(&info).expect("serialize");
170 assert_eq!(json["ignitionVersion"], "8.3.6 (b2026042713)");
171 assert_eq!(json["name"], "ign-live-rig");
172 assert_eq!(json["license"]["mode"], "Trial");
173 assert!(
174 json.get("endpoint").is_none(),
175 "endpoint is never serialized (CORE-05 skip)"
176 );
177 }
178}