ignition_core/client/license.rs
1//! License capability model (09-04, EXT-02) — `GET /data/api/v1/licenses`,
2//! field names + nesting exactly as **live-captured on both rigs**
3//! (09-LIVE-CAPTURES §1: 8.3.6 rig A + 8.3.3 rig B, 2026-09-07). The
4//! version-tolerance recipe is TrialWire's (client/trial.rs): every
5//! `rename` + snake_case `alias` + `default`, unknown keys never
6//! refuse the parse, and the remainder rides
7//! `#[serde(flatten)]`.
8//!
9//! **The partial-curated split (capture-locked):** the capture shows a
10//! flat top level — 5 arrays (`cloud`/`certificate`/`hardware`/
11//! `embedded`/`leased`), one `effective` object, two scalars — with NO
12//! deeper nesting to curate past. The arrays were EMPTY on both fresh
13//! rigs (no activations exist), so array ELEMENT shapes are NOT
14//! capture-proven: elements stay passthrough (`serde_json::Value`)
15//! except `hardware`, whose `key`+`items` skeleton is the render's
16//! per-key rows (modeled leniently, all-defaulted). `details` is
17//! point-release-variable by the postman sample — never fully modeled,
18//! `Option<serde_json::Value>` forever.
19//!
20//! The morning-check MODE does not live on this payload at all — the
21//! trial companion (`/data/api/v1/trial`, [`TrialWire`]) owns
22//! `licenseMode` (09-LIVE-CAPTURES §2: zero drift, both rigs). The
23//! [`actions::license`][crate::actions::license] merge is where the two
24//! reads become one command.
25
26use std::collections::BTreeMap;
27
28use serde::{Deserialize, Serialize};
29
30/// GET path of the license capability (83-api collection + live capture).
31pub const LICENSES_PATH: &str = "/data/api/v1/licenses";
32
33/// GET `/data/api/v1/licenses` — the license inventory. Live-captured
34/// 200 on BOTH rigs (09-LIVE-CAPTURES §1); the two bodies differ in
35/// VALUES only, never shape.
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct LicenseStatusWire {
38 /// Cloud-license entries — `[]` on both fresh-rig captures (no
39 /// activations exist); element shape NOT capture-proven → elements
40 /// ride as opaque values (the known-limitation passthrough).
41 #[serde(default)]
42 pub cloud: Vec<serde_json::Value>,
43 /// Certificate-license entries — `[]` on both captures; passthrough
44 /// elements for the same reason.
45 #[serde(default)]
46 pub certificate: Vec<serde_json::Value>,
47 /// Per-key hardware activations — `[]` on both captures; the
48 /// `key`+`items` skeleton below is modeled leniently (all-defaulted)
49 /// so a populated shape can never fail the parse.
50 #[serde(default)]
51 pub hardware: Vec<LicenseHardware>,
52 /// Embedded-license entries — `[]` on both captures; passthrough
53 /// elements.
54 #[serde(default)]
55 pub embedded: Vec<serde_json::Value>,
56 /// Leased-license entries — `[]` on both captures; passthrough
57 /// elements (the Postman sample's deep lease objects are
58 /// doc-generated placeholders, NOT captures — never modeled deeper).
59 #[serde(default)]
60 pub leased: Vec<serde_json::Value>,
61 /// The effective-license stamp — present on both captures
62 /// (`{"lastUpdated": 1788748551614i64}`); `default` so an absent key
63 /// on a future shape still parses.
64 #[serde(default)]
65 pub effective: LicenseEffective,
66 /// Activation-unactivate grace window — `7500` on both captures;
67 /// name says MILLISECONDS (capital MS suffix), the doc comment is
68 /// the capture citation. Option because only two point releases have
69 /// ever been seen.
70 #[serde(
71 rename = "leasedUnactivateTimeoutMS",
72 alias = "leased_unactivate_timeout_ms",
73 default,
74 skip_serializing_if = "Option::is_none"
75 )]
76 pub leased_unactivate_timeout_ms: Option<i64>,
77 /// Server mid-mutation flag — `false` on both captures (a read can
78 /// race a license write; `true` tells the caller the inventory is
79 /// in flux).
80 #[serde(default)]
81 pub busy: bool,
82 /// Unknown keys round-trip (passthrough-shaped `--json`; the
83 /// version-tolerance directive — point releases may add keys).
84 #[serde(flatten)]
85 pub extra: BTreeMap<String, serde_json::Value>,
86}
87
88impl LicenseStatusWire {
89 /// The morning-check count: total licensed ITEMS across every
90 /// hardware key (`0` on a fresh trial rig — both captures answered
91 /// empty arrays).
92 pub fn item_count(&self) -> usize {
93 self.hardware.iter().map(|entry| entry.items.len()).sum()
94 }
95}
96
97/// One `hardware` entry (per license key). Element shapes were NOT
98/// capturable on the fresh rigs — this skeleton is modeled LENIENTLY
99/// (everything defaulted, everything flattened) exactly so a populated
100/// shape parses without a second live capture. Doc comments cite the
101/// lenient-modeling rule, not a capture.
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103pub struct LicenseHardware {
104 /// The license key identifier (postman sample shows a string; kept
105 /// Option + defaulted — an element whose shape drifts must still
106 /// parse, the TrialWire recipe).
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub key: Option<String>,
109 /// Licensed items under this key — lenient modeling (see module
110 /// doc): fresh-rig captures answered `[]`, so item shapes ride the
111 /// postman-sample skeleton with every field defaulted.
112 #[serde(default)]
113 pub items: Vec<LicenseItem>,
114 /// Unknown keys round-trip.
115 #[serde(flatten)]
116 pub extra: BTreeMap<String, serde_json::Value>,
117}
118
119/// One licensed item (`name`/`title`/`version` from the postman-sample
120/// skeleton). `details` stays `Option<serde_json::Value>` FOREVER — it
121/// is the point-release-variable deep tree the version-tolerance
122/// directive exists for.
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct LicenseItem {
125 /// Module/item name (sample: e.g. `"ignition"`); Option so drift
126 /// never fails the parse (render shows `-`).
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub name: Option<String>,
129 /// Display title (sample: `"Ignition"`); same lenient Option.
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub title: Option<String>,
132 /// Version string (sample: `"8.3.6"`); same lenient Option.
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub version: Option<String>,
135 /// The deep, point-release-variable remainder — NEVER fully
136 /// modeled (the roadmap's version-tolerance directive; the
137 /// module-doc known-limitation note). Passthrough verbatim.
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub details: Option<serde_json::Value>,
140 /// Unknown keys round-trip.
141 #[serde(flatten)]
142 pub extra: BTreeMap<String, serde_json::Value>,
143}
144
145/// `effective` — the license-state stamp. Capture: a one-key object on
146/// both rigs.
147#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
148pub struct LicenseEffective {
149 /// Epoch **MILLISECONDS** — capture-proven by magnitude cross-check
150 /// on BOTH rigs: `1788748551614` = ×1000 of the capture moment's
151 /// epoch seconds (09-LIVE-CAPTURES §1 shape notes). Option +
152 /// default: only two point releases observed.
153 #[serde(
154 rename = "lastUpdated",
155 alias = "last_updated_ms",
156 default,
157 skip_serializing_if = "Option::is_none"
158 )]
159 pub last_updated_ms: Option<i64>,
160 /// Unknown keys round-trip.
161 #[serde(flatten)]
162 pub extra: BTreeMap<String, serde_json::Value>,
163}
164
165#[cfg(test)]
166mod tests {
167 use super::{LicenseHardware, LicenseStatusWire};
168
169 /// THE 8.3.6 live capture (rig A, 09-LIVE-CAPTURES §1) — verbatim
170 /// body; the fresh-trial shape every model field is doc-cited to.
171 #[test]
172 fn license_parses_the_live_8_3_6_capture() {
173 let wire: LicenseStatusWire = serde_json::from_value(serde_json::json!({
174 "cloud": [],
175 "certificate": [],
176 "hardware": [],
177 "embedded": [],
178 "leased": [],
179 "effective": { "lastUpdated": 1788748551614i64 },
180 "leasedUnactivateTimeoutMS": 7500,
181 "busy": false
182 }))
183 .expect("the live 8.3.6 shape must parse");
184 assert!(wire.cloud.is_empty());
185 assert!(wire.hardware.is_empty());
186 assert_eq!(
187 wire.effective.last_updated_ms,
188 Some(1_788_748_551_614),
189 "epoch MILLISECONDS (magnitude cross-check)"
190 );
191 assert_eq!(wire.leased_unactivate_timeout_ms, Some(7500));
192 assert!(!wire.busy);
193 assert_eq!(wire.item_count(), 0, "fresh trial rig: zero items");
194 }
195
196 /// THE 8.3.3 live capture (rig B, 09-LIVE-CAPTURES §1) — same
197 /// shape, different values: no point-release drift.
198 #[test]
199 fn license_parses_the_live_8_3_3_capture() {
200 let wire: LicenseStatusWire = serde_json::from_value(serde_json::json!({
201 "cloud": [],
202 "certificate": [],
203 "hardware": [],
204 "embedded": [],
205 "leased": [],
206 "effective": { "lastUpdated": 1788748731641i64 },
207 "leasedUnactivateTimeoutMS": 7500,
208 "busy": false
209 }))
210 .expect("the live 8.3.3 shape must parse");
211 assert_eq!(wire.effective.last_updated_ms, Some(1_788_748_731_641));
212 }
213
214 /// A POPULATED hardware entry parses through the lenient skeleton
215 /// (postman-sample shape, all fields defaulted, details passthrough)
216 /// — the array-element shapes were NOT capture-proven, so populated
217 /// drift must ride, never refuse.
218 #[test]
219 fn license_parses_a_populated_hardware_entry_leniently() {
220 let wire: LicenseStatusWire = serde_json::from_value(serde_json::json!({
221 "hardware": [{
222 "key": "0123456789",
223 "items": [{
224 "name": "ignition",
225 "title": "Ignition",
226 "version": "8.3.6",
227 "details": { "state": "Active", "expirationDate": null,
228 "whateverFuturePoint": {"nested": true} }
229 }]
230 }]
231 }))
232 .expect("a populated hardware entry must parse leniently");
233 assert_eq!(wire.item_count(), 1);
234 let entry: &LicenseHardware = &wire.hardware[0];
235 assert_eq!(entry.key.as_deref(), Some("0123456789"));
236 let item = &entry.items[0];
237 assert_eq!(item.name.as_deref(), Some("ignition"));
238 assert_eq!(item.title.as_deref(), Some("Ignition"));
239 assert_eq!(item.version.as_deref(), Some("8.3.6"));
240 assert!(
241 item.details.is_some(),
242 "details rides passthrough, never modeled"
243 );
244 }
245
246 /// The unknown-key round-trip: a fake future point-release key
247 /// appended to the live capture still parses AND lands in `extra`
248 /// (the passthrough proof).
249 #[test]
250 fn license_unknown_keys_ride_extra() {
251 let wire: LicenseStatusWire = serde_json::from_value(serde_json::json!({
252 "cloud": [],
253 "certificate": [],
254 "hardware": [],
255 "embedded": [],
256 "leased": [],
257 "effective": { "lastUpdated": 1788748551614i64 },
258 "leasedUnactivateTimeoutMS": 7500,
259 "busy": false,
260 "brandNewPointReleaseKey": {"future": true}
261 }))
262 .expect("unknown keys must never refuse the parse");
263 assert_eq!(
264 wire.extra.get("brandNewPointReleaseKey"),
265 Some(&serde_json::json!({"future": true})),
266 "the unknown key rides flatten passthrough"
267 );
268 }
269}