Skip to main content

node_app_manifest/
host_contract.rs

1//! Host contract features: which manifest constructs a host understands.
2//!
3//! Features are derived from manifest content, never declared by authors.
4//! `node-app contract stamp` writes the derived set into `manifest.json` as
5//! `host_contract` so hosts released before a feature existed can still
6//! recognise that they do not support it (see `check_host_contract`).
7
8use std::collections::BTreeSet;
9use std::fmt;
10
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12
13use crate::{AppManifest, AppType, AppUiKind, ASSISTANT_SLOT};
14
15/// A manifest construct that only some host releases understand.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub enum HostFeature {
18    UiSurfaces,
19    UiWidget,
20    UiComposition,
21    UiWidgetData,
22    UiCommands,
23    UiAssistantSlot,
24    UiWidgetUiOnly,
25}
26
27impl HostFeature {
28    pub const ALL: [HostFeature; 7] = [
29        HostFeature::UiSurfaces,
30        HostFeature::UiWidget,
31        HostFeature::UiComposition,
32        HostFeature::UiWidgetData,
33        HostFeature::UiCommands,
34        HostFeature::UiAssistantSlot,
35        HostFeature::UiWidgetUiOnly,
36    ];
37
38    /// Stable wire id. Never rename a published id.
39    pub fn id(self) -> &'static str {
40        match self {
41            HostFeature::UiSurfaces => "ui.surfaces",
42            HostFeature::UiWidget => "ui.widget",
43            HostFeature::UiComposition => "ui.composition",
44            HostFeature::UiWidgetData => "ui.widget-data",
45            HostFeature::UiCommands => "ui.commands",
46            HostFeature::UiAssistantSlot => "ui.assistant-slot",
47            HostFeature::UiWidgetUiOnly => "ui.widget-ui-only",
48        }
49    }
50
51    pub fn from_id(id: &str) -> Option<HostFeature> {
52        HostFeature::ALL
53            .into_iter()
54            .find(|feature| feature.id() == id)
55    }
56
57    pub fn unlocks(self) -> &'static str {
58        match self {
59            HostFeature::UiSurfaces => "`ui.surfaces` shell-chrome contributions",
60            HostFeature::UiWidget => "`ui.kind: \"widget\"` UIs composed by a parent stage",
61            HostFeature::UiComposition => "`ui.composes` and the composition input/event port",
62            HostFeature::UiWidgetData => "`ui.data` app-data declarations on a widget",
63            HostFeature::UiCommands => {
64                "`requires.commands` kernel commands sent through `ctx.command`"
65            }
66            HostFeature::UiAssistantSlot => "the single-occupant `assistant` shell surface slot",
67            HostFeature::UiWidgetUiOnly => "a composed widget with no backend process",
68        }
69    }
70
71    pub fn derived_when(self) -> &'static str {
72        match self {
73            HostFeature::UiSurfaces => "`ui.surfaces` is non-empty",
74            HostFeature::UiWidget => "`ui.kind` is `widget`",
75            HostFeature::UiComposition => "`ui.composes` is non-empty",
76            HostFeature::UiWidgetData => "`ui.kind` is `widget` and `ui.data` is present",
77            HostFeature::UiCommands => "any `requires.commands` (stage or surface) is non-empty",
78            HostFeature::UiAssistantSlot => "a `ui.surfaces` entry uses slot `assistant`",
79            HostFeature::UiWidgetUiOnly => {
80                "`ui.kind` is `widget` and `app_type` is `ui-only` (or neither `app_type` nor `entrypoint` is set)"
81            }
82        }
83    }
84
85    /// First host release that supports this feature.
86    pub fn since(self) -> HostVersion {
87        match self {
88            HostFeature::UiSurfaces => HostVersion::new(7, 0, 11),
89            HostFeature::UiWidget => HostVersion::new(7, 1, 11),
90            HostFeature::UiComposition => HostVersion::new(7, 1, 11),
91            // Confirm with the release owner before tagging (spec §13 A9).
92            HostFeature::UiWidgetData => HostVersion::new(7, 2, 0),
93            // Floor confirmed by the release owner (7.3.0).
94            HostFeature::UiCommands => HostVersion::new(7, 3, 0),
95            // Floor confirmed by the release owner (7.3.0).
96            HostFeature::UiAssistantSlot => HostVersion::new(7, 3, 0),
97            // In no tag yet (v7.2.5, the latest, lacks it). Recorded as the next
98            // minor, as ui.widget-data was; releases since 7.2.0 were patches, so
99            // settle it with the release owner before merge (ADR-035).
100            HostFeature::UiWidgetUiOnly => HostVersion::new(7, 3, 0),
101        }
102    }
103}
104
105/// A host release version, `major.minor.patch`.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
107pub struct HostVersion {
108    pub major: u32,
109    pub minor: u32,
110    pub patch: u32,
111}
112
113impl HostVersion {
114    pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
115        HostVersion {
116            major,
117            minor,
118            patch,
119        }
120    }
121
122    /// Accepts `7.2.0`, `v7.2.0`, and package versions with a `+…`/`-…`/`~…` suffix.
123    pub fn parse(value: &str) -> Option<HostVersion> {
124        let value = value.strip_prefix('v').unwrap_or(value);
125        let core = value.split(['+', '-', '~']).next()?;
126        let mut parts = core.split('.');
127        let major = parts.next()?.parse().ok()?;
128        let minor = parts.next()?.parse().ok()?;
129        let patch = parts.next()?.parse().ok()?;
130        if parts.next().is_some() {
131            return None;
132        }
133        Some(HostVersion::new(major, minor, patch))
134    }
135}
136
137impl fmt::Display for HostVersion {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
140    }
141}
142
143impl Serialize for HostVersion {
144    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
145        serializer.collect_str(self)
146    }
147}
148
149impl<'de> Deserialize<'de> for HostVersion {
150    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
151        let raw = String::deserialize(deserializer)?;
152        HostVersion::parse(&raw)
153            .ok_or_else(|| serde::de::Error::custom(format!("invalid host version '{raw}'")))
154    }
155}
156
157/// The features one host release understands.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct HostFeatureSet(BTreeSet<HostFeature>);
160
161impl HostFeatureSet {
162    /// Everything this build understands.
163    pub fn current() -> Self {
164        HostFeatureSet(HostFeature::ALL.into_iter().collect())
165    }
166
167    /// What the host release `version` understood.
168    pub fn at(version: HostVersion) -> Self {
169        HostFeatureSet(
170            HostFeature::ALL
171                .into_iter()
172                .filter(|feature| feature.since() <= version)
173                .collect(),
174        )
175    }
176
177    pub fn contains(&self, feature: HostFeature) -> bool {
178        self.0.contains(&feature)
179    }
180
181    pub fn ids(&self) -> Vec<&'static str> {
182        self.0.iter().map(|feature| feature.id()).collect()
183    }
184}
185
186/// Features this manifest needs, computed from its content.
187pub fn required_features(manifest: &AppManifest) -> BTreeSet<HostFeature> {
188    let mut features = BTreeSet::new();
189    let Some(ui) = &manifest.ui else {
190        return features;
191    };
192    if ui.kind == AppUiKind::Widget {
193        features.insert(HostFeature::UiWidget);
194        if ui.data.is_some() {
195            features.insert(HostFeature::UiWidgetData);
196        }
197        // Older hosts refuse a UI-only widget as invalid; the stamp lets them
198        // say "needs a newer Node" instead.
199        if manifest.app_type == AppType::UiOnly {
200            features.insert(HostFeature::UiWidgetUiOnly);
201        }
202    }
203    if !ui.composes.is_empty() {
204        features.insert(HostFeature::UiComposition);
205    }
206    if !ui.surfaces.is_empty() {
207        features.insert(HostFeature::UiSurfaces);
208    }
209    let any_commands = !ui.requires.commands.is_empty()
210        || ui
211            .surfaces
212            .iter()
213            .any(|surface| !surface.requires.commands.is_empty());
214    if any_commands {
215        features.insert(HostFeature::UiCommands);
216    }
217    if ui
218        .surfaces
219        .iter()
220        .any(|surface| surface.slot == ASSISTANT_SLOT)
221    {
222        features.insert(HostFeature::UiAssistantSlot);
223    }
224    features
225}
226
227/// The first host release that supports every feature in `features`.
228pub fn min_host_for(features: &BTreeSet<HostFeature>) -> Option<HostVersion> {
229    features.iter().map(|feature| feature.since()).max()
230}
231
232/// Markdown table for `docs/development/stages/09-compatibility.md`.
233pub fn render_host_feature_table() -> String {
234    let mut table =
235        String::from("| Feature | Unlocks | Derived when | First host |\n|---|---|---|---|\n");
236    for feature in HostFeature::ALL {
237        table.push_str(&format!(
238            "| `{}` | {} | {} | {} |\n",
239            feature.id(),
240            feature.unlocks(),
241            feature.derived_when(),
242            feature.since()
243        ));
244    }
245    table
246}
247
248/// The `host_contract` block written into `manifest.json` at build time.
249///
250/// Deliberately tolerant: a newer toolchain may add stamp members or write a
251/// `min_host` this host cannot parse, and neither may turn a "this Node is too
252/// old" answer into "the package is broken". Unknown members are ignored and an
253/// unparseable `min_host` reads as absent.
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct HostContractStamp {
256    /// Feature ids, sorted by id. Kept as strings so ids from newer toolchains survive parsing.
257    pub features: Vec<String>,
258    #[serde(
259        default,
260        deserialize_with = "lenient_min_host",
261        skip_serializing_if = "Option::is_none"
262    )]
263    pub min_host: Option<HostVersion>,
264}
265
266impl HostContractStamp {
267    /// Same feature set and `min_host`, regardless of feature order.
268    pub fn same_contract(&self, other: &HostContractStamp) -> bool {
269        let mine: BTreeSet<&str> = self.features.iter().map(String::as_str).collect();
270        let theirs: BTreeSet<&str> = other.features.iter().map(String::as_str).collect();
271        mine == theirs && self.min_host == other.min_host
272    }
273}
274
275fn lenient_min_host<'de, D: Deserializer<'de>>(
276    deserializer: D,
277) -> Result<Option<HostVersion>, D::Error> {
278    let raw = serde_json::Value::deserialize(deserializer)?;
279    Ok(raw.as_str().and_then(HostVersion::parse))
280}
281
282/// The stamp `node-app contract stamp` writes for this manifest.
283pub fn stamp_for(manifest: &AppManifest) -> HostContractStamp {
284    let features = required_features(manifest);
285    let mut ids: Vec<String> = features
286        .iter()
287        .map(|feature| feature.id().to_string())
288        .collect();
289    ids.sort();
290    HostContractStamp {
291        features: ids,
292        min_host: min_host_for(&features),
293    }
294}
295
296/// Why a host (or a target host) cannot load a manifest.
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub enum ManifestRejection {
299    /// The manifest is fine; this host is too old for it.
300    HostFeatureMissing {
301        features: Vec<String>,
302        min_host: Option<HostVersion>,
303    },
304    /// The package itself is broken.
305    Invalid { message: String },
306}
307
308impl fmt::Display for ManifestRejection {
309    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310        match self {
311            ManifestRejection::HostFeatureMissing { features, min_host } => {
312                write!(
313                    f,
314                    "requires host features this Node does not support: {}",
315                    features.join(", ")
316                )?;
317                if let Some(version) = min_host {
318                    write!(f, " (first available in Node {version})")?;
319                }
320                Ok(())
321            }
322            ManifestRejection::Invalid { message } => f.write_str(message),
323        }
324    }
325}
326
327/// Runs on raw JSON before full parsing, so a manifest using constructs this
328/// host cannot even deserialize is still reported as "too new", not "broken".
329/// A missing or malformed stamp is left for full parsing to report.
330///
331/// Reads only `host_contract.features` and `host_contract.min_host`, leniently,
332/// so a stamp shape from a newer toolchain (extra members, a `min_host` this
333/// host cannot parse) still yields "too new" rather than "broken".
334pub fn precheck_stamp(json: &str, supported: &HostFeatureSet) -> Result<(), ManifestRejection> {
335    let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
336        return Ok(());
337    };
338    let Some(features) = value
339        .get("host_contract")
340        .and_then(|stamp| stamp.get("features"))
341        .and_then(serde_json::Value::as_array)
342    else {
343        return Ok(());
344    };
345    let missing: Vec<String> = features
346        .iter()
347        .filter_map(serde_json::Value::as_str)
348        .filter(|id| !HostFeature::from_id(id).is_some_and(|feature| supported.contains(feature)))
349        .map(str::to_string)
350        .collect();
351    if missing.is_empty() {
352        return Ok(());
353    }
354    let min_host = value["host_contract"]
355        .get("min_host")
356        .and_then(serde_json::Value::as_str)
357        .and_then(HostVersion::parse);
358    Err(ManifestRejection::HostFeatureMissing {
359        features: missing,
360        min_host,
361    })
362}
363
364/// Checks a parsed manifest against `supported`, and that any stamp is not stale.
365pub fn check_host_contract(
366    manifest: &AppManifest,
367    supported: &HostFeatureSet,
368) -> Result<(), ManifestRejection> {
369    let derived = required_features(manifest);
370    let missing: BTreeSet<HostFeature> = derived
371        .iter()
372        .copied()
373        .filter(|feature| !supported.contains(*feature))
374        .collect();
375    if !missing.is_empty() {
376        let mut features: Vec<String> = missing
377            .iter()
378            .map(|feature| feature.id().to_string())
379            .collect();
380        features.sort();
381        return Err(ManifestRejection::HostFeatureMissing {
382            features,
383            min_host: min_host_for(&missing),
384        });
385    }
386    if let Some(stamp) = &manifest.host_contract {
387        let unstamped: Vec<&str> = derived
388            .iter()
389            .map(|feature| feature.id())
390            .filter(|id| !stamp.features.iter().any(|stamped| stamped == id))
391            .collect();
392        if !unstamped.is_empty() {
393            return Err(ManifestRejection::Invalid {
394                message: format!(
395                    "host_contract is stale: missing {}; run `node-app contract stamp`",
396                    unstamped.join(", ")
397                ),
398            });
399        }
400        if let (Some(required), stamped) = (min_host_for(&derived), stamp.min_host) {
401            if stamped.is_none_or(|version| version < required) {
402                return Err(ManifestRejection::Invalid {
403                    message: format!(
404                        "host_contract is stale: min_host must be at least {required}; run `node-app contract stamp`"
405                    ),
406                });
407            }
408        }
409    }
410    Ok(())
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::AppManifest;
417
418    const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
419
420    fn manifest(ui: &str) -> AppManifest {
421        AppManifest::from_json(&format!(
422            r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{ui}}}"#
423        ))
424        .expect("valid manifest")
425    }
426
427    fn ui(kind: &str, extra: &str) -> String {
428        format!(
429            r#"{{"kind":"{kind}","entry":"ui/main.js","title":"Demo","ui_api":1,"integrity":{{"ui/main.js":"{HASH}"}}{extra}}}"#
430        )
431    }
432
433    #[test]
434    fn plain_stage_requires_nothing() {
435        assert!(required_features(&manifest(&ui("stage", ""))).is_empty());
436    }
437
438    #[test]
439    fn widget_requires_ui_widget() {
440        let features = required_features(&manifest(&ui("widget", "")));
441        assert_eq!(
442            features.into_iter().collect::<Vec<_>>(),
443            vec![HostFeature::UiWidget]
444        );
445    }
446
447    #[test]
448    fn widget_with_data_requires_widget_data() {
449        let mut m = manifest(&ui("widget", ""));
450        m.ui.as_mut().unwrap().data = Some(
451            serde_json::from_str(
452                r#"{"namespace":"demo","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#,
453            )
454            .unwrap(),
455        );
456        let features: Vec<_> = required_features(&m).into_iter().collect();
457        assert_eq!(
458            features,
459            vec![HostFeature::UiWidget, HostFeature::UiWidgetData]
460        );
461    }
462
463    /// A UI-only manifest: a `ui` object and neither `app_type` nor `entrypoint`.
464    fn ui_only(ui: &str) -> AppManifest {
465        AppManifest::from_json(&format!(r#"{{"name":"demo","version":"1.0.0","ui":{ui}}}"#))
466            .expect("valid UI-only manifest")
467    }
468
469    #[test]
470    fn ui_only_widget_requires_widget_ui_only() {
471        let m = ui_only(&ui("widget", ""));
472        assert_eq!(m.app_type, AppType::UiOnly);
473        let features: Vec<_> = required_features(&m).into_iter().collect();
474        assert_eq!(
475            features,
476            vec![HostFeature::UiWidget, HostFeature::UiWidgetUiOnly]
477        );
478        let stamp = stamp_for(&m);
479        assert_eq!(stamp.features, vec!["ui.widget", "ui.widget-ui-only"]);
480        assert_eq!(stamp.min_host, Some(HostVersion::new(7, 3, 0)));
481    }
482
483    /// Only the widget is new: a UI-only stage has shipped since Plan 07a,
484    /// and a widget with a backend needs nothing more than `ui.widget`.
485    #[test]
486    fn ui_only_stage_and_bun_widget_do_not_require_widget_ui_only() {
487        assert!(required_features(&ui_only(&ui("stage", ""))).is_empty());
488        assert!(
489            !required_features(&manifest(&ui("widget", ""))).contains(&HostFeature::UiWidgetUiOnly)
490        );
491    }
492
493    /// v7.2.5, the latest release, lacks the feature.
494    #[test]
495    fn a_host_before_widget_ui_only_reports_it_missing() {
496        let m = ui_only(&ui("widget", ""));
497        assert_eq!(
498            check_host_contract(&m, &HostFeatureSet::at(HostVersion::new(7, 2, 5))),
499            Err(ManifestRejection::HostFeatureMissing {
500                features: vec!["ui.widget-ui-only".into()],
501                min_host: Some(HostVersion::new(7, 3, 0)),
502            })
503        );
504        assert_eq!(check_host_contract(&m, &HostFeatureSet::current()), Ok(()));
505        // A stamp from before this feature is stale, not silently accepted.
506        let mut stale = m.clone();
507        stale.host_contract = Some(HostContractStamp {
508            features: vec!["ui.widget".into()],
509            min_host: Some(HostVersion::new(7, 1, 11)),
510        });
511        assert!(matches!(
512            check_host_contract(&stale, &HostFeatureSet::current()),
513            Err(ManifestRejection::Invalid { .. })
514        ));
515    }
516
517    /// What an older host sees: the stamped id is unknown, so the raw
518    /// precheck answers "too new" before the manifest is even parsed.
519    #[test]
520    fn precheck_on_an_older_host_reports_widget_ui_only_as_too_new() {
521        let json = format!(
522            r#"{{"name":"demo","version":"1.0.0","host_contract":{{"features":["ui.widget","ui.widget-ui-only"],"min_host":"7.3.0"}},"ui":{}}}"#,
523            ui("widget", "")
524        );
525        let before = HostFeatureSet(
526            HostFeature::ALL
527                .into_iter()
528                .filter(|feature| *feature != HostFeature::UiWidgetUiOnly)
529                .collect(),
530        );
531        assert_eq!(
532            precheck_stamp(&json, &before),
533            Err(ManifestRejection::HostFeatureMissing {
534                features: vec!["ui.widget-ui-only".into()],
535                min_host: Some(HostVersion::new(7, 3, 0)),
536            })
537        );
538        assert_eq!(precheck_stamp(&json, &HostFeatureSet::current()), Ok(()));
539    }
540
541    #[test]
542    fn composing_stage_requires_composition() {
543        let features: Vec<_> =
544            required_features(&manifest(&ui("stage", r#","composes":["child"]"#)))
545                .into_iter()
546                .collect();
547        assert_eq!(features, vec![HostFeature::UiComposition]);
548    }
549
550    #[test]
551    fn ids_round_trip_and_are_stable() {
552        let ids: Vec<_> = HostFeature::ALL.iter().map(|f| f.id()).collect();
553        assert_eq!(
554            ids,
555            vec![
556                "ui.surfaces",
557                "ui.widget",
558                "ui.composition",
559                "ui.widget-data",
560                "ui.commands",
561                "ui.assistant-slot",
562                "ui.widget-ui-only"
563            ]
564        );
565        for feature in HostFeature::ALL {
566            assert_eq!(HostFeature::from_id(feature.id()), Some(feature));
567        }
568        assert_eq!(HostFeature::from_id("ui.future"), None);
569    }
570
571    #[test]
572    fn feature_sets_follow_release_history() {
573        let old = HostFeatureSet::at(HostVersion::new(7, 1, 17));
574        assert!(old.contains(HostFeature::UiWidget));
575        assert!(old.contains(HostFeature::UiComposition));
576        assert!(!old.contains(HostFeature::UiWidgetData));
577        assert!(HostFeatureSet::at(HostVersion::new(7, 2, 0)).contains(HostFeature::UiWidgetData));
578        assert!(
579            !HostFeatureSet::at(HostVersion::new(7, 2, 4)).contains(HostFeature::UiWidgetUiOnly)
580        );
581        assert!(HostFeatureSet::at(HostVersion::new(7, 3, 0)).contains(HostFeature::UiWidgetUiOnly));
582        assert!(!HostFeatureSet::at(HostVersion::new(7, 0, 10)).contains(HostFeature::UiSurfaces));
583        assert_eq!(
584            HostFeatureSet::current().ids().len(),
585            HostFeature::ALL.len()
586        );
587    }
588
589    #[test]
590    fn host_version_parses_tags_and_package_suffixes() {
591        assert_eq!(HostVersion::parse("7.2.0"), Some(HostVersion::new(7, 2, 0)));
592        assert_eq!(
593            HostVersion::parse("v7.1.17"),
594            Some(HostVersion::new(7, 1, 17))
595        );
596        assert_eq!(
597            HostVersion::parse("7.1.17+composed.abc"),
598            Some(HostVersion::new(7, 1, 17))
599        );
600        assert_eq!(HostVersion::parse("7.1"), None);
601        assert_eq!(HostVersion::parse("seven"), None);
602        assert_eq!(HostVersion::new(7, 2, 0).to_string(), "7.2.0");
603    }
604
605    #[test]
606    fn min_host_is_the_latest_first_release() {
607        let set: BTreeSet<_> = [HostFeature::UiWidget, HostFeature::UiWidgetData]
608            .into_iter()
609            .collect();
610        assert_eq!(min_host_for(&set), Some(HostVersion::new(7, 2, 0)));
611        assert_eq!(min_host_for(&BTreeSet::new()), None);
612    }
613
614    #[test]
615    fn feature_table_lists_every_feature() {
616        let table = render_host_feature_table();
617        for feature in HostFeature::ALL {
618            assert!(table.contains(&format!("`{}`", feature.id())), "{table}");
619        }
620    }
621
622    const DATA: &str = r#","data":{"namespace":"demo","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#;
623
624    #[test]
625    fn widget_may_declare_app_data() {
626        let m = manifest(&ui("widget", DATA));
627        assert!(m.ui.unwrap().data.is_some());
628    }
629
630    #[test]
631    fn widget_app_data_gets_stage_rules() {
632        let error = AppManifest::from_json(&format!(
633            r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{}}}"#,
634            ui("widget", r#","data":{"namespace":"other","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#)
635        ))
636        .expect_err("namespace must equal the app name");
637        assert!(error.contains("must equal the app name"), "{error}");
638    }
639
640    #[test]
641    fn widget_nav_is_still_rejected() {
642        let error = AppManifest::from_json(&format!(
643            r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{}}}"#,
644            ui("widget", r#","nav":{"section":"default","order":1}"#)
645        ))
646        .expect_err("widgets have no nav");
647        assert!(error.contains("nav"), "{error}");
648    }
649
650    #[test]
651    fn stamp_is_sorted_derived_features_with_min_host() {
652        let stamp = stamp_for(&manifest(&ui("widget", DATA)));
653        assert_eq!(stamp.features, vec!["ui.widget", "ui.widget-data"]);
654        assert_eq!(stamp.min_host, Some(HostVersion::new(7, 2, 0)));
655        assert_eq!(
656            serde_json::to_string(&stamp).unwrap(),
657            r#"{"features":["ui.widget","ui.widget-data"],"min_host":"7.2.0"}"#
658        );
659        let empty = stamp_for(&manifest(&ui("stage", "")));
660        assert_eq!(serde_json::to_string(&empty).unwrap(), r#"{"features":[]}"#);
661    }
662
663    #[test]
664    fn check_accepts_absent_and_fresh_stamps() {
665        let m = manifest(&ui("widget", DATA));
666        assert_eq!(check_host_contract(&m, &HostFeatureSet::current()), Ok(()));
667        let mut stamped = m.clone();
668        stamped.host_contract = Some(stamp_for(&m));
669        assert_eq!(
670            check_host_contract(&stamped, &HostFeatureSet::current()),
671            Ok(())
672        );
673    }
674
675    #[test]
676    fn check_rejects_a_stale_stamp() {
677        let mut m = manifest(&ui("widget", DATA));
678        m.host_contract = Some(HostContractStamp {
679            features: vec!["ui.widget".into()],
680            min_host: Some(HostVersion::new(7, 1, 11)),
681        });
682        match check_host_contract(&m, &HostFeatureSet::current()) {
683            Err(ManifestRejection::Invalid { message }) => {
684                assert!(message.contains("stale"), "{message}");
685                assert!(message.contains("ui.widget-data"), "{message}");
686            }
687            other => panic!("expected stale stamp rejection, got {other:?}"),
688        }
689    }
690
691    #[test]
692    fn check_reports_missing_features_for_an_older_target() {
693        let m = manifest(&ui("widget", DATA));
694        assert_eq!(
695            check_host_contract(&m, &HostFeatureSet::at(HostVersion::new(7, 1, 17))),
696            Err(ManifestRejection::HostFeatureMissing {
697                features: vec!["ui.widget-data".into()],
698                min_host: Some(HostVersion::new(7, 2, 0)),
699            })
700        );
701    }
702
703    #[test]
704    fn precheck_reports_unknown_stamped_feature_with_stamped_min_host() {
705        let json = format!(
706            r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future","ui.widget"],"min_host":"9.0.0"}},"ui":{}}}"#,
707            ui("widget", "")
708        );
709        assert_eq!(
710            precheck_stamp(&json, &HostFeatureSet::current()),
711            Err(ManifestRejection::HostFeatureMissing {
712                features: vec!["ui.future".into()],
713                min_host: Some(HostVersion::new(9, 0, 0)),
714            })
715        );
716    }
717
718    #[test]
719    fn precheck_tolerates_a_newer_stamp_shape_and_the_manifest_still_parses() {
720        let json = format!(
721            r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future"],"min_host":"9.0.0","extra":1}},"ui":{}}}"#,
722            ui("widget", "")
723        );
724        assert_eq!(
725            precheck_stamp(&json, &HostFeatureSet::current()),
726            Err(ManifestRejection::HostFeatureMissing {
727                features: vec!["ui.future".into()],
728                min_host: Some(HostVersion::new(9, 0, 0)),
729            })
730        );
731        let manifest = AppManifest::from_json(&json).expect("unknown stamp members are tolerated");
732        assert_eq!(
733            manifest.host_contract.unwrap().min_host,
734            Some(HostVersion::new(9, 0, 0))
735        );
736    }
737
738    #[test]
739    fn unparseable_stamped_min_host_reads_as_absent() {
740        let json = format!(
741            r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future","ui.widget"],"min_host":"nine"}},"ui":{}}}"#,
742            ui("widget", "")
743        );
744        assert_eq!(
745            precheck_stamp(&json, &HostFeatureSet::current()),
746            Err(ManifestRejection::HostFeatureMissing {
747                features: vec!["ui.future".into()],
748                min_host: None
749            })
750        );
751        let manifest = AppManifest::from_json(&json).expect("an unparseable min_host is tolerated");
752        let stamp = manifest.host_contract.clone().unwrap();
753        assert_eq!(stamp.min_host, None);
754        assert_eq!(
755            serde_json::to_string(&stamp).unwrap(),
756            r#"{"features":["ui.future","ui.widget"]}"#
757        );
758        // Known features with an unusable min_host are still a stale stamp.
759        let mut widget = manifest.clone();
760        widget.host_contract = Some(HostContractStamp {
761            features: vec!["ui.widget".into()],
762            min_host: None,
763        });
764        assert!(matches!(
765            check_host_contract(&widget, &HostFeatureSet::current()),
766            Err(ManifestRejection::Invalid { .. })
767        ));
768    }
769
770    #[test]
771    fn stamp_features_are_sorted_by_id_and_compared_as_sets() {
772        let m = manifest(&ui("widget", &format!(r#"{DATA},"composes":["child"]"#)));
773        let stamp = stamp_for(&m);
774        assert_eq!(
775            stamp.features,
776            vec!["ui.composition", "ui.widget", "ui.widget-data"]
777        );
778        let reordered = HostContractStamp {
779            features: vec![
780                "ui.widget-data".into(),
781                "ui.composition".into(),
782                "ui.widget".into(),
783            ],
784            min_host: stamp.min_host,
785        };
786        assert!(stamp.same_contract(&reordered));
787        let mut stamped = m.clone();
788        stamped.host_contract = Some(reordered);
789        assert_eq!(
790            check_host_contract(&stamped, &HostFeatureSet::current()),
791            Ok(())
792        );
793        let fewer = HostContractStamp {
794            features: vec!["ui.widget".into()],
795            min_host: stamp.min_host,
796        };
797        assert!(!stamp.same_contract(&fewer));
798    }
799
800    #[test]
801    fn precheck_ignores_absent_or_malformed_stamps() {
802        assert_eq!(
803            precheck_stamp(r#"{"name":"demo"}"#, &HostFeatureSet::current()),
804            Ok(())
805        );
806        assert_eq!(
807            precheck_stamp(r#"{"host_contract":7}"#, &HostFeatureSet::current()),
808            Ok(())
809        );
810        assert_eq!(
811            precheck_stamp("not json", &HostFeatureSet::current()),
812            Ok(())
813        );
814    }
815
816    #[test]
817    fn rejection_messages_are_actionable() {
818        let missing = ManifestRejection::HostFeatureMissing {
819            features: vec!["ui.widget-data".into()],
820            min_host: Some(HostVersion::new(7, 2, 0)),
821        };
822        assert_eq!(
823            missing.to_string(),
824            "requires host features this Node does not support: ui.widget-data (first available in Node 7.2.0)"
825        );
826    }
827}