Skip to main content

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}