use std::collections::BTreeSet;
use std::fmt;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::{AppManifest, AppUiKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum HostFeature {
UiSurfaces,
UiWidget,
UiComposition,
UiWidgetData,
}
impl HostFeature {
pub const ALL: [HostFeature; 4] = [
HostFeature::UiSurfaces,
HostFeature::UiWidget,
HostFeature::UiComposition,
HostFeature::UiWidgetData,
];
pub fn id(self) -> &'static str {
match self {
HostFeature::UiSurfaces => "ui.surfaces",
HostFeature::UiWidget => "ui.widget",
HostFeature::UiComposition => "ui.composition",
HostFeature::UiWidgetData => "ui.widget-data",
}
}
pub fn from_id(id: &str) -> Option<HostFeature> {
HostFeature::ALL.into_iter().find(|feature| feature.id() == id)
}
pub fn unlocks(self) -> &'static str {
match self {
HostFeature::UiSurfaces => "`ui.surfaces` shell-chrome contributions",
HostFeature::UiWidget => "`ui.kind: \"widget\"` UIs composed by a parent stage",
HostFeature::UiComposition => "`ui.composes` and the composition input/event port",
HostFeature::UiWidgetData => "`ui.data` app-data declarations on a widget",
}
}
pub fn derived_when(self) -> &'static str {
match self {
HostFeature::UiSurfaces => "`ui.surfaces` is non-empty",
HostFeature::UiWidget => "`ui.kind` is `widget`",
HostFeature::UiComposition => "`ui.composes` is non-empty",
HostFeature::UiWidgetData => "`ui.kind` is `widget` and `ui.data` is present",
}
}
pub fn since(self) -> HostVersion {
match self {
HostFeature::UiSurfaces => HostVersion::new(7, 0, 11),
HostFeature::UiWidget => HostVersion::new(7, 1, 11),
HostFeature::UiComposition => HostVersion::new(7, 1, 11),
HostFeature::UiWidgetData => HostVersion::new(7, 2, 0),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct HostVersion {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl HostVersion {
pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
HostVersion { major, minor, patch }
}
pub fn parse(value: &str) -> Option<HostVersion> {
let value = value.strip_prefix('v').unwrap_or(value);
let core = value.split(['+', '-', '~']).next()?;
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some(HostVersion::new(major, minor, patch))
}
}
impl fmt::Display for HostVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
impl Serialize for HostVersion {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for HostVersion {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
HostVersion::parse(&raw)
.ok_or_else(|| serde::de::Error::custom(format!("invalid host version '{raw}'")))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostFeatureSet(BTreeSet<HostFeature>);
impl HostFeatureSet {
pub fn current() -> Self {
HostFeatureSet(HostFeature::ALL.into_iter().collect())
}
pub fn at(version: HostVersion) -> Self {
HostFeatureSet(
HostFeature::ALL
.into_iter()
.filter(|feature| feature.since() <= version)
.collect(),
)
}
pub fn contains(&self, feature: HostFeature) -> bool {
self.0.contains(&feature)
}
pub fn ids(&self) -> Vec<&'static str> {
self.0.iter().map(|feature| feature.id()).collect()
}
}
pub fn required_features(manifest: &AppManifest) -> BTreeSet<HostFeature> {
let mut features = BTreeSet::new();
let Some(ui) = &manifest.ui else {
return features;
};
if ui.kind == AppUiKind::Widget {
features.insert(HostFeature::UiWidget);
if ui.data.is_some() {
features.insert(HostFeature::UiWidgetData);
}
}
if !ui.composes.is_empty() {
features.insert(HostFeature::UiComposition);
}
if !ui.surfaces.is_empty() {
features.insert(HostFeature::UiSurfaces);
}
features
}
pub fn min_host_for(features: &BTreeSet<HostFeature>) -> Option<HostVersion> {
features.iter().map(|feature| feature.since()).max()
}
pub fn render_host_feature_table() -> String {
let mut table = String::from(
"| Feature | Unlocks | Derived when | First host |\n|---|---|---|---|\n",
);
for feature in HostFeature::ALL {
table.push_str(&format!(
"| `{}` | {} | {} | {} |\n",
feature.id(),
feature.unlocks(),
feature.derived_when(),
feature.since()
));
}
table
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostContractStamp {
pub features: Vec<String>,
#[serde(default, deserialize_with = "lenient_min_host", skip_serializing_if = "Option::is_none")]
pub min_host: Option<HostVersion>,
}
impl HostContractStamp {
pub fn same_contract(&self, other: &HostContractStamp) -> bool {
let mine: BTreeSet<&str> = self.features.iter().map(String::as_str).collect();
let theirs: BTreeSet<&str> = other.features.iter().map(String::as_str).collect();
mine == theirs && self.min_host == other.min_host
}
}
fn lenient_min_host<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<HostVersion>, D::Error> {
let raw = serde_json::Value::deserialize(deserializer)?;
Ok(raw.as_str().and_then(HostVersion::parse))
}
pub fn stamp_for(manifest: &AppManifest) -> HostContractStamp {
let features = required_features(manifest);
let mut ids: Vec<String> = features.iter().map(|feature| feature.id().to_string()).collect();
ids.sort();
HostContractStamp {
features: ids,
min_host: min_host_for(&features),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManifestRejection {
HostFeatureMissing {
features: Vec<String>,
min_host: Option<HostVersion>,
},
Invalid { message: String },
}
impl fmt::Display for ManifestRejection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ManifestRejection::HostFeatureMissing { features, min_host } => {
write!(
f,
"requires host features this Node does not support: {}",
features.join(", ")
)?;
if let Some(version) = min_host {
write!(f, " (first available in Node {version})")?;
}
Ok(())
}
ManifestRejection::Invalid { message } => f.write_str(message),
}
}
}
pub fn precheck_stamp(json: &str, supported: &HostFeatureSet) -> Result<(), ManifestRejection> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
return Ok(());
};
let Some(features) = value
.get("host_contract")
.and_then(|stamp| stamp.get("features"))
.and_then(serde_json::Value::as_array)
else {
return Ok(());
};
let missing: Vec<String> = features
.iter()
.filter_map(serde_json::Value::as_str)
.filter(|id| !HostFeature::from_id(id).is_some_and(|feature| supported.contains(feature)))
.map(str::to_string)
.collect();
if missing.is_empty() {
return Ok(());
}
let min_host = value["host_contract"]
.get("min_host")
.and_then(serde_json::Value::as_str)
.and_then(HostVersion::parse);
Err(ManifestRejection::HostFeatureMissing { features: missing, min_host })
}
pub fn check_host_contract(
manifest: &AppManifest,
supported: &HostFeatureSet,
) -> Result<(), ManifestRejection> {
let derived = required_features(manifest);
let missing: BTreeSet<HostFeature> = derived
.iter()
.copied()
.filter(|feature| !supported.contains(*feature))
.collect();
if !missing.is_empty() {
let mut features: Vec<String> = missing.iter().map(|feature| feature.id().to_string()).collect();
features.sort();
return Err(ManifestRejection::HostFeatureMissing {
features,
min_host: min_host_for(&missing),
});
}
if let Some(stamp) = &manifest.host_contract {
let unstamped: Vec<&str> = derived
.iter()
.map(|feature| feature.id())
.filter(|id| !stamp.features.iter().any(|stamped| stamped == id))
.collect();
if !unstamped.is_empty() {
return Err(ManifestRejection::Invalid {
message: format!(
"host_contract is stale: missing {}; run `node-app contract stamp`",
unstamped.join(", ")
),
});
}
if let (Some(required), stamped) = (min_host_for(&derived), stamp.min_host) {
if stamped.is_none_or(|version| version < required) {
return Err(ManifestRejection::Invalid {
message: format!(
"host_contract is stale: min_host must be at least {required}; run `node-app contract stamp`"
),
});
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AppManifest;
const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
fn manifest(ui: &str) -> AppManifest {
AppManifest::from_json(&format!(
r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{ui}}}"#
))
.expect("valid manifest")
}
fn ui(kind: &str, extra: &str) -> String {
format!(
r#"{{"kind":"{kind}","entry":"ui/main.js","title":"Demo","ui_api":1,"integrity":{{"ui/main.js":"{HASH}"}}{extra}}}"#
)
}
#[test]
fn plain_stage_requires_nothing() {
assert!(required_features(&manifest(&ui("stage", ""))).is_empty());
}
#[test]
fn widget_requires_ui_widget() {
let features = required_features(&manifest(&ui("widget", "")));
assert_eq!(features.into_iter().collect::<Vec<_>>(), vec![HostFeature::UiWidget]);
}
#[test]
fn widget_with_data_requires_widget_data() {
let mut m = manifest(&ui("widget", ""));
m.ui.as_mut().unwrap().data = Some(
serde_json::from_str(
r#"{"namespace":"demo","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#,
)
.unwrap(),
);
let features: Vec<_> = required_features(&m).into_iter().collect();
assert_eq!(features, vec![HostFeature::UiWidget, HostFeature::UiWidgetData]);
}
#[test]
fn composing_stage_requires_composition() {
let features: Vec<_> = required_features(&manifest(&ui("stage", r#","composes":["child"]"#)))
.into_iter()
.collect();
assert_eq!(features, vec![HostFeature::UiComposition]);
}
#[test]
fn ids_round_trip_and_are_stable() {
let ids: Vec<_> = HostFeature::ALL.iter().map(|f| f.id()).collect();
assert_eq!(ids, vec!["ui.surfaces", "ui.widget", "ui.composition", "ui.widget-data"]);
for feature in HostFeature::ALL {
assert_eq!(HostFeature::from_id(feature.id()), Some(feature));
}
assert_eq!(HostFeature::from_id("ui.future"), None);
}
#[test]
fn feature_sets_follow_release_history() {
let old = HostFeatureSet::at(HostVersion::new(7, 1, 17));
assert!(old.contains(HostFeature::UiWidget));
assert!(old.contains(HostFeature::UiComposition));
assert!(!old.contains(HostFeature::UiWidgetData));
assert!(HostFeatureSet::at(HostVersion::new(7, 2, 0)).contains(HostFeature::UiWidgetData));
assert!(!HostFeatureSet::at(HostVersion::new(7, 0, 10)).contains(HostFeature::UiSurfaces));
assert_eq!(HostFeatureSet::current().ids().len(), HostFeature::ALL.len());
}
#[test]
fn host_version_parses_tags_and_package_suffixes() {
assert_eq!(HostVersion::parse("7.2.0"), Some(HostVersion::new(7, 2, 0)));
assert_eq!(HostVersion::parse("v7.1.17"), Some(HostVersion::new(7, 1, 17)));
assert_eq!(HostVersion::parse("7.1.17+composed.abc"), Some(HostVersion::new(7, 1, 17)));
assert_eq!(HostVersion::parse("7.1"), None);
assert_eq!(HostVersion::parse("seven"), None);
assert_eq!(HostVersion::new(7, 2, 0).to_string(), "7.2.0");
}
#[test]
fn min_host_is_the_latest_first_release() {
let set: BTreeSet<_> = [HostFeature::UiWidget, HostFeature::UiWidgetData].into_iter().collect();
assert_eq!(min_host_for(&set), Some(HostVersion::new(7, 2, 0)));
assert_eq!(min_host_for(&BTreeSet::new()), None);
}
#[test]
fn feature_table_lists_every_feature() {
let table = render_host_feature_table();
for feature in HostFeature::ALL {
assert!(table.contains(&format!("`{}`", feature.id())), "{table}");
}
}
const DATA: &str = r#","data":{"namespace":"demo","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#;
#[test]
fn widget_may_declare_app_data() {
let m = manifest(&ui("widget", DATA));
assert!(m.ui.unwrap().data.is_some());
}
#[test]
fn widget_app_data_gets_stage_rules() {
let error = AppManifest::from_json(&format!(
r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{}}}"#,
ui("widget", r#","data":{"namespace":"other","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#)
))
.expect_err("namespace must equal the app name");
assert!(error.contains("must equal the app name"), "{error}");
}
#[test]
fn widget_nav_is_still_rejected() {
let error = AppManifest::from_json(&format!(
r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{}}}"#,
ui("widget", r#","nav":{"section":"default","order":1}"#)
))
.expect_err("widgets have no nav");
assert!(error.contains("nav"), "{error}");
}
#[test]
fn stamp_is_sorted_derived_features_with_min_host() {
let stamp = stamp_for(&manifest(&ui("widget", DATA)));
assert_eq!(stamp.features, vec!["ui.widget", "ui.widget-data"]);
assert_eq!(stamp.min_host, Some(HostVersion::new(7, 2, 0)));
assert_eq!(
serde_json::to_string(&stamp).unwrap(),
r#"{"features":["ui.widget","ui.widget-data"],"min_host":"7.2.0"}"#
);
let empty = stamp_for(&manifest(&ui("stage", "")));
assert_eq!(serde_json::to_string(&empty).unwrap(), r#"{"features":[]}"#);
}
#[test]
fn check_accepts_absent_and_fresh_stamps() {
let m = manifest(&ui("widget", DATA));
assert_eq!(check_host_contract(&m, &HostFeatureSet::current()), Ok(()));
let mut stamped = m.clone();
stamped.host_contract = Some(stamp_for(&m));
assert_eq!(check_host_contract(&stamped, &HostFeatureSet::current()), Ok(()));
}
#[test]
fn check_rejects_a_stale_stamp() {
let mut m = manifest(&ui("widget", DATA));
m.host_contract = Some(HostContractStamp {
features: vec!["ui.widget".into()],
min_host: Some(HostVersion::new(7, 1, 11)),
});
match check_host_contract(&m, &HostFeatureSet::current()) {
Err(ManifestRejection::Invalid { message }) => {
assert!(message.contains("stale"), "{message}");
assert!(message.contains("ui.widget-data"), "{message}");
}
other => panic!("expected stale stamp rejection, got {other:?}"),
}
}
#[test]
fn check_reports_missing_features_for_an_older_target() {
let m = manifest(&ui("widget", DATA));
assert_eq!(
check_host_contract(&m, &HostFeatureSet::at(HostVersion::new(7, 1, 17))),
Err(ManifestRejection::HostFeatureMissing {
features: vec!["ui.widget-data".into()],
min_host: Some(HostVersion::new(7, 2, 0)),
})
);
}
#[test]
fn precheck_reports_unknown_stamped_feature_with_stamped_min_host() {
let json = format!(
r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future","ui.widget"],"min_host":"9.0.0"}},"ui":{}}}"#,
ui("widget", "")
);
assert_eq!(
precheck_stamp(&json, &HostFeatureSet::current()),
Err(ManifestRejection::HostFeatureMissing {
features: vec!["ui.future".into()],
min_host: Some(HostVersion::new(9, 0, 0)),
})
);
}
#[test]
fn precheck_tolerates_a_newer_stamp_shape_and_the_manifest_still_parses() {
let json = format!(
r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future"],"min_host":"9.0.0","extra":1}},"ui":{}}}"#,
ui("widget", "")
);
assert_eq!(
precheck_stamp(&json, &HostFeatureSet::current()),
Err(ManifestRejection::HostFeatureMissing {
features: vec!["ui.future".into()],
min_host: Some(HostVersion::new(9, 0, 0)),
})
);
let manifest = AppManifest::from_json(&json).expect("unknown stamp members are tolerated");
assert_eq!(manifest.host_contract.unwrap().min_host, Some(HostVersion::new(9, 0, 0)));
}
#[test]
fn unparseable_stamped_min_host_reads_as_absent() {
let json = format!(
r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future","ui.widget"],"min_host":"nine"}},"ui":{}}}"#,
ui("widget", "")
);
assert_eq!(
precheck_stamp(&json, &HostFeatureSet::current()),
Err(ManifestRejection::HostFeatureMissing { features: vec!["ui.future".into()], min_host: None })
);
let manifest = AppManifest::from_json(&json).expect("an unparseable min_host is tolerated");
let stamp = manifest.host_contract.clone().unwrap();
assert_eq!(stamp.min_host, None);
assert_eq!(serde_json::to_string(&stamp).unwrap(), r#"{"features":["ui.future","ui.widget"]}"#);
let mut widget = manifest.clone();
widget.host_contract = Some(HostContractStamp { features: vec!["ui.widget".into()], min_host: None });
assert!(matches!(
check_host_contract(&widget, &HostFeatureSet::current()),
Err(ManifestRejection::Invalid { .. })
));
}
#[test]
fn stamp_features_are_sorted_by_id_and_compared_as_sets() {
let m = manifest(&ui("widget", &format!(r#"{DATA},"composes":["child"]"#)));
let stamp = stamp_for(&m);
assert_eq!(stamp.features, vec!["ui.composition", "ui.widget", "ui.widget-data"]);
let reordered = HostContractStamp {
features: vec!["ui.widget-data".into(), "ui.composition".into(), "ui.widget".into()],
min_host: stamp.min_host,
};
assert!(stamp.same_contract(&reordered));
let mut stamped = m.clone();
stamped.host_contract = Some(reordered);
assert_eq!(check_host_contract(&stamped, &HostFeatureSet::current()), Ok(()));
let fewer = HostContractStamp { features: vec!["ui.widget".into()], min_host: stamp.min_host };
assert!(!stamp.same_contract(&fewer));
}
#[test]
fn precheck_ignores_absent_or_malformed_stamps() {
assert_eq!(precheck_stamp(r#"{"name":"demo"}"#, &HostFeatureSet::current()), Ok(()));
assert_eq!(precheck_stamp(r#"{"host_contract":7}"#, &HostFeatureSet::current()), Ok(()));
assert_eq!(precheck_stamp("not json", &HostFeatureSet::current()), Ok(()));
}
#[test]
fn rejection_messages_are_actionable() {
let missing = ManifestRejection::HostFeatureMissing {
features: vec!["ui.widget-data".into()],
min_host: Some(HostVersion::new(7, 2, 0)),
};
assert_eq!(
missing.to_string(),
"requires host features this Node does not support: ui.widget-data (first available in Node 7.2.0)"
);
}
}