use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use clap::Subcommand;
use node_app_manifest::{
check_host_contract, precheck_stamp, render_host_feature_table, stamp_for, AppManifest,
HostContractStamp, HostFeatureSet, HostVersion, ManifestRejection,
};
use serde::Serialize;
#[derive(Subcommand, Debug)]
pub enum ContractAction {
Stamp {
#[arg(short, long, default_value = ".")]
path: PathBuf,
},
Check {
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(long)]
target_host: String,
#[arg(long = "composed", value_name = "DIR")]
composed: Vec<PathBuf>,
#[arg(long)]
allow_future_host: bool,
#[arg(long)]
json: bool,
},
Features {
#[arg(long)]
markdown: bool,
},
}
#[derive(Debug, Serialize)]
pub struct AppReport {
pub name: String,
pub features: Vec<String>,
pub min_host: Option<String>,
pub status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CheckReport {
pub apps: Vec<AppReport>,
pub min_host: Option<String>,
#[serde(skip)]
pub ok: bool,
}
pub fn run(action: ContractAction) -> Result<()> {
match action {
ContractAction::Stamp { path } => stamp(&path),
ContractAction::Check { path, target_host, composed, allow_future_host, json } => {
let report = check(&path, &target_host, &composed, allow_future_host)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
for app in &report.apps {
println!(
"{:<16} {:<22} min_host={:<8} {}{}",
app.name,
app.status,
app.min_host.as_deref().unwrap_or("-"),
app.features.join(","),
app.message.as_deref().map(|m| format!(" {m}")).unwrap_or_default()
);
}
}
if report.ok { Ok(()) } else { Err(anyhow!("host contract check failed")) }
}
ContractAction::Features { markdown: _ } => {
print!("{}", render_host_feature_table());
Ok(())
}
}
}
fn read_manifest(dir: &Path) -> Result<(String, AppManifest)> {
let path = dir.join("manifest.json");
let text = std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
let manifest = AppManifest::from_json(&text)
.map_err(|error| anyhow!("{}: {error}", path.display()))?;
Ok((text, manifest))
}
pub fn stamp(dir: &Path) -> Result<()> {
let (text, manifest) = read_manifest(dir)?;
let intended = stamp_for(&manifest);
let stamp_json = serde_json::to_string_pretty(&intended)?;
let next = upsert_top_level_member(&text, "host_contract", &stamp_json).map_err(|e| anyhow!(e))?;
if next != text {
verify_stamped(&next, &intended)
.context("refusing to write manifest.json: upsert produced an unexpected result")?;
std::fs::write(dir.join("manifest.json"), next)?;
}
Ok(())
}
fn verify_stamped(next: &str, intended: &HostContractStamp) -> Result<()> {
let value: serde_json::Value =
serde_json::from_str(next).context("stamped manifest is not valid JSON")?;
let raw = value
.get("host_contract")
.ok_or_else(|| anyhow!("stamped manifest is missing host_contract"))?;
let actual: HostContractStamp = serde_json::from_value(raw.clone())
.context("stamped manifest's host_contract does not parse")?;
if &actual != intended {
return Err(anyhow!(
"stamped manifest's host_contract does not match the derived contract"
));
}
Ok(())
}
pub fn check(dir: &Path, target_host: &str, composed: &[PathBuf], allow_future_host: bool) -> Result<CheckReport> {
let target = HostVersion::parse(target_host).ok_or_else(|| anyhow!("invalid --target-host '{target_host}'"))?;
let supported = HostFeatureSet::at(target);
let mut apps = Vec::new();
let mut ok = true;
let mut composes = Vec::new();
for (index, app_dir) in std::iter::once(dir.to_path_buf()).chain(composed.iter().cloned()).enumerate() {
let app_name = app_dir
.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
.unwrap_or_else(|| "unknown".to_string());
let text = match std::fs::read_to_string(app_dir.join("manifest.json")) {
Ok(text) => text,
Err(_) => {
ok = false;
apps.push(AppReport {
name: app_name,
features: Vec::new(),
min_host: None,
status: "manifest-unreadable",
message: Some("manifest.json could not be read".to_string()),
});
continue;
}
};
let manifest = match AppManifest::from_json(&text) {
Ok(manifest) => manifest,
Err(_) => {
ok = false;
apps.push(AppReport {
name: app_name,
features: Vec::new(),
min_host: None,
status: "invalid",
message: Some("manifest.json is not a valid manifest".to_string()),
});
continue;
}
};
if index == 0 {
composes = manifest.ui.as_ref().map(|ui| ui.composes.clone()).unwrap_or_default();
}
let stamp = stamp_for(&manifest);
let outcome = precheck_stamp(&text, &supported).and_then(|()| check_host_contract(&manifest, &supported));
let (status, message) = match (&outcome, &manifest.host_contract) {
(Err(ManifestRejection::HostFeatureMissing { .. }), _) => ("host-feature-missing", outcome.as_ref().err().map(ToString::to_string)),
(Err(ManifestRejection::Invalid { message }), _) => ("invalid", Some(message.clone())),
(Ok(()), None) => ("invalid", Some("host_contract missing; run `node-app contract stamp`".to_string())),
(Ok(()), Some(stamped)) if !stamped.same_contract(&stamp) => ("invalid", Some("host_contract differs from the derived contract; run `node-app contract stamp`".to_string())),
(Ok(()), Some(_)) => ("ok", None),
};
if status == "invalid" || (status == "host-feature-missing" && !allow_future_host) {
ok = false;
}
apps.push(AppReport {
name: manifest.name.clone(),
features: stamp.features.clone(),
min_host: stamp.min_host.map(|v| v.to_string()),
status,
message,
});
}
for child in &composes {
if !apps.iter().skip(1).any(|app| &app.name == child) {
ok = false;
apps.push(AppReport {
name: child.clone(),
features: Vec::new(),
min_host: None,
status: "invalid",
message: Some(format!("composed child '{child}' was not provided with --composed")),
});
}
}
let min_host = apps
.iter()
.filter_map(|app| app.min_host.as_deref().and_then(HostVersion::parse))
.max()
.map(|v| v.to_string());
Ok(CheckReport { apps, min_host, ok })
}
pub fn upsert_top_level_member(json: &str, key: &str, value_json: &str) -> Result<String, String> {
let bytes = json.as_bytes();
let open = json.find(|c: char| !c.is_whitespace()).ok_or("empty document")?;
if bytes[open] != b'{' {
return Err("manifest is not a JSON object".into());
}
let mut members: Vec<(String, usize, usize)> = Vec::new();
let (mut depth, mut i, mut in_string) = (0usize, open, false);
let mut key_start: Option<usize> = None;
let mut current_key: Option<String> = None;
let mut close = None;
while i < bytes.len() {
let c = bytes[i];
if in_string {
if c == b'\\' { i += 2; continue; }
if c == b'"' {
in_string = false;
if depth == 1 && current_key.is_none() {
if let Some(start) = key_start {
current_key = Some(json[start + 1..i].to_string());
}
}
}
i += 1;
continue;
}
match c {
b'"' => {
in_string = true;
if depth == 1 && current_key.is_none() { key_start = Some(i); }
}
b'{' | b'[' => depth += 1,
b'}' | b']' => {
depth -= 1;
if depth == 0 {
if let (Some(k), Some(s)) = (current_key.take(), key_start.take()) {
members.push((k, s, trim_end(json, i)));
}
close = Some(i);
break;
}
}
b',' if depth == 1 => {
if let (Some(k), Some(s)) = (current_key.take(), key_start.take()) {
members.push((k, s, trim_end(json, i)));
}
}
_ => {}
}
i += 1;
}
let close = close.ok_or("unterminated JSON object")?;
let member = |indent: &str| format!("\"{key}\": {}", value_json.replace('\n', &format!("\n{indent}")));
if let Some((_, start, end)) = members.iter().find(|(k, _, _)| k == key) {
return Ok(format!("{}{}{}", &json[..*start], member(&line_indent(json, *start)), &json[*end..]));
}
let last_end = members.last().map(|(_, _, end)| *end).ok_or("empty manifest object")?;
let indent = members.last().map(|(_, start, _)| line_indent(json, *start)).unwrap_or_default();
let _ = close;
Ok(format!("{},\n{}{}{}", &json[..last_end], indent, member(&indent), &json[last_end..]))
}
fn line_indent(json: &str, at: usize) -> String {
let line_start = json[..at].rfind('\n').map_or(0, |newline| newline + 1);
let prefix = &json[line_start..at];
if prefix.chars().all(char::is_whitespace) { prefix.to_string() } else { String::new() }
}
fn trim_end(json: &str, end: usize) -> usize {
json[..end].trim_end().len()
}
#[cfg(test)]
mod tests {
use super::*;
const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
fn widget(name: &str, data: bool) -> String {
let data = if data {
format!(r#","data":{{"namespace":"{name}","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}}"#)
} else {
String::new()
};
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"app_type\": \"bun\",\n \"ui\": {{\"kind\":\"widget\",\"entry\":\"ui/main.js\",\"title\":\"W\",\"ui_api\":1,\"integrity\":{{\"ui/main.js\":\"{HASH}\"}}{data}}}\n}}\n"
)
}
fn write_app(dir: &std::path::Path, manifest: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("manifest.json"), manifest).unwrap();
}
#[test]
fn upsert_appends_then_replaces_preserving_order() {
let json = "{\n \"b\": 1,\n \"a\": {\"x\": \"}\"}\n}\n";
let added = upsert_top_level_member(json, "host_contract", r#"{"features":[]}"#).unwrap();
assert_eq!(added, "{\n \"b\": 1,\n \"a\": {\"x\": \"}\"},\n \"host_contract\": {\"features\":[]}\n}\n");
let replaced = upsert_top_level_member(&added, "host_contract", r#"{"features":["ui.widget"]}"#).unwrap();
assert_eq!(replaced, "{\n \"b\": 1,\n \"a\": {\"x\": \"}\"},\n \"host_contract\": {\"features\":[\"ui.widget\"]}\n}\n");
let pretty = upsert_top_level_member(&added, "host_contract", "{\n \"features\": []\n}").unwrap();
assert_eq!(pretty, "{\n \"b\": 1,\n \"a\": {\"x\": \"}\"},\n \"host_contract\": {\n \"features\": []\n }\n}\n");
assert!(upsert_top_level_member("[1]", "k", "1").is_err());
}
#[test]
fn stamp_writes_the_derived_contract() {
let dir = tempfile::tempdir().unwrap();
write_app(dir.path(), &widget("friends", true));
stamp(dir.path()).unwrap();
let text = std::fs::read_to_string(dir.path().join("manifest.json")).unwrap();
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(value["host_contract"], serde_json::json!({"features":["ui.widget","ui.widget-data"],"min_host":"7.2.0"}));
assert!(text.find("\"name\"").unwrap() < text.find("\"host_contract\"").unwrap());
assert!(
text.contains(" \"host_contract\": {\n \"features\": [\n \"ui.widget\",\n \"ui.widget-data\"\n ],\n \"min_host\": \"7.2.0\"\n }\n}\n"),
"{text}"
);
stamp(dir.path()).unwrap();
assert_eq!(std::fs::read_to_string(dir.path().join("manifest.json")).unwrap(), text, "stamp is idempotent");
}
#[test]
fn check_fails_for_an_older_target_and_passes_for_new() {
let dir = tempfile::tempdir().unwrap();
write_app(dir.path(), &widget("friends", true));
stamp(dir.path()).unwrap();
let old = check(dir.path(), "7.1.17", &[], false).unwrap();
assert!(!old.ok);
assert_eq!(old.apps[0].status, "host-feature-missing");
assert_eq!(old.min_host.as_deref(), Some("7.2.0"));
assert!(check(dir.path(), "7.1.17", &[], true).unwrap().ok, "--allow-future-host downgrades to a warning");
assert!(check(dir.path(), "7.2.0", &[], false).unwrap().ok);
}
#[test]
fn check_requires_a_fresh_stamp() {
let dir = tempfile::tempdir().unwrap();
write_app(dir.path(), &widget("friends", true));
let report = check(dir.path(), "7.2.0", &[], false).unwrap();
assert!(!report.ok);
assert!(report.apps[0].message.as_deref().unwrap().contains("node-app contract stamp"));
}
#[test]
fn check_compares_stamped_features_as_a_set() {
let dir = tempfile::tempdir().unwrap();
let reordered = widget("friends", true).replacen(
"\"app_type\"",
"\"host_contract\": {\"features\":[\"ui.widget-data\",\"ui.widget\"],\"min_host\":\"7.2.0\"},\n \"app_type\"",
1,
);
write_app(dir.path(), &reordered);
let report = check(dir.path(), "7.2.0", &[], false).unwrap();
assert!(report.ok, "{:?}", report.apps);
assert_eq!(report.apps[0].status, "ok");
}
#[test]
fn stamp_replaces_an_existing_multiline_host_contract_and_validates_before_writing() {
let dir = tempfile::tempdir().unwrap();
let original = format!(
"{{\n \"name\": \"friends\",\n \"version\": \"1.0.0\",\n \"app_type\": \"bun\",\n \"host_contract\": {{\n \"features\": [\n \"ui.widget\"\n ],\n \"min_host\": \"7.1.11\"\n }},\n \"ui\": {{\"kind\":\"widget\",\"entry\":\"ui/main.js\",\"title\":\"W\",\"ui_api\":1,\"integrity\":{{\"ui/main.js\":\"{HASH}\"}}}}\n}}\n"
);
write_app(dir.path(), &original);
stamp(dir.path()).unwrap();
let text = std::fs::read_to_string(dir.path().join("manifest.json")).unwrap();
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(value["host_contract"], serde_json::json!({"features":["ui.widget"],"min_host":"7.1.11"}));
let expected = format!(
"{{\n \"name\": \"friends\",\n \"version\": \"1.0.0\",\n \"app_type\": \"bun\",\n \"host_contract\": {{\n \"features\": [\n \"ui.widget\"\n ],\n \"min_host\": \"7.1.11\"\n }},\n \"ui\": {{\"kind\":\"widget\",\"entry\":\"ui/main.js\",\"title\":\"W\",\"ui_api\":1,\"integrity\":{{\"ui/main.js\":\"{HASH}\"}}}}\n}}\n"
);
assert_eq!(text, expected);
stamp(dir.path()).unwrap();
assert_eq!(std::fs::read_to_string(dir.path().join("manifest.json")).unwrap(), text, "stamp is idempotent");
}
#[test]
fn check_reports_an_unreadable_composed_manifest_instead_of_aborting() {
let root = tempfile::tempdir().unwrap();
let parent = root.path().join("parent");
let child = root.path().join("child");
write_app(&parent, &format!(
"{{\"name\":\"parent\",\"version\":\"1.0.0\",\"app_type\":\"bun\",\"ui\":{{\"kind\":\"stage\",\"entry\":\"ui/main.js\",\"title\":\"P\",\"ui_api\":1,\"composes\":[\"child\"],\"integrity\":{{\"ui/main.js\":\"{HASH}\"}}}}}}"
));
stamp(&parent).unwrap();
std::fs::create_dir_all(&child).unwrap();
let report = check(&parent, "7.2.0", std::slice::from_ref(&child), false).unwrap();
assert!(!report.ok);
assert_eq!(report.apps.len(), 2);
let child_row = &report.apps[1];
assert_eq!(child_row.name, "child");
assert_eq!(child_row.status, "manifest-unreadable");
assert_eq!(child_row.features, Vec::<String>::new());
assert_eq!(child_row.min_host, None);
let message = child_row.message.as_deref().unwrap();
assert_eq!(message, "manifest.json could not be read");
assert!(!message.contains(child.to_string_lossy().as_ref()));
assert!(!message.to_lowercase().contains("os error"));
assert!(!message.to_lowercase().contains("no such file"));
let json = serde_json::to_value(&report).unwrap();
assert_eq!(
json["apps"][1],
serde_json::json!({
"name": "child",
"features": [],
"min_host": serde_json::Value::Null,
"status": "manifest-unreadable",
"message": "manifest.json could not be read"
})
);
}
#[test]
fn composed_check_validates_parent_and_children_together() {
let root = tempfile::tempdir().unwrap();
let parent = root.path().join("parent");
let child = root.path().join("child");
write_app(&parent, &format!(
"{{\"name\":\"parent\",\"version\":\"1.0.0\",\"app_type\":\"bun\",\"ui\":{{\"kind\":\"stage\",\"entry\":\"ui/main.js\",\"title\":\"P\",\"ui_api\":1,\"composes\":[\"child\"],\"integrity\":{{\"ui/main.js\":\"{HASH}\"}}}}}}"
));
write_app(&child, &widget("child", true));
stamp(&parent).unwrap();
stamp(&child).unwrap();
let report = check(&parent, "7.2.0", std::slice::from_ref(&child), false).unwrap();
assert!(report.ok, "{report:?}");
assert_eq!(report.apps.len(), 2);
let missing = check(&parent, "7.2.0", &[], false).unwrap();
assert!(!missing.ok, "composes names a child that was not provided");
}
}