use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AppType {
Native,
Bun,
Standalone,
#[serde(rename = "platform-runtime")]
PlatformRuntime,
}
impl AppType {
pub fn as_str(self) -> &'static str {
match self {
AppType::Native => "native",
AppType::Bun => "bun",
AppType::Standalone => "standalone",
AppType::PlatformRuntime => "platform-runtime",
}
}
}
impl std::fmt::Display for AppType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AppTier {
FirstParty,
Optional,
Development,
}
impl AppTier {
pub fn as_str(self) -> &'static str {
match self {
AppTier::FirstParty => "first_party",
AppTier::Optional => "optional",
AppTier::Development => "development",
}
}
}
impl std::fmt::Display for AppTier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AbiVersion {
V1,
}
impl AbiVersion {
pub fn as_str(&self) -> &'static str {
match self {
AbiVersion::V1 => "v1",
}
}
pub fn is_supported(&self) -> bool {
matches!(self, AbiVersion::V1)
}
}
impl std::fmt::Display for AbiVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HotReloadKind {
Supported,
Experimental,
Unsupported,
}
impl HotReloadKind {
pub fn default_for(app_type: AppType) -> Self {
match app_type {
AppType::Native => HotReloadKind::Experimental,
AppType::Bun => HotReloadKind::Supported,
AppType::Standalone => HotReloadKind::Unsupported,
AppType::PlatformRuntime => HotReloadKind::Unsupported,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ManifestCapabilities {
#[serde(default)]
pub requires: Vec<String>,
#[serde(default)]
pub provides: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProvidedScope {
pub scope: String,
pub description: String,
pub resource_pattern: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndpointPolicy {
pub method: String,
pub path: String,
pub required_permissions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvidedCapability {
#[serde(default)]
pub description: String,
#[serde(default)]
pub schema: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StandaloneConfig {
pub socket_path: std::path::PathBuf,
}
pub fn validate_manifest_path(path: &str) -> Result<(), String> {
if path.is_empty() {
return Err("path must not be empty".to_string());
}
if path.starts_with('/') {
return Err(format!(
"path '{}' must not be absolute (starts with /)",
path
));
}
let first = path.chars().next().unwrap();
if !first.is_ascii_alphanumeric() && first != '_' {
return Err(format!(
"path '{}' must begin with an alphanumeric character or underscore",
path
));
}
for ch in path.chars().skip(1) {
if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
return Err(format!(
"path '{}' contains disallowed character '{}'",
path, ch
));
}
}
for segment in path.split('/') {
if segment == ".." {
return Err(format!(
"path '{}' contains a '..' segment (path traversal rejected)",
path
));
}
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppManifest {
#[serde(default = "default_manifest_version", rename = "manifest_version")]
pub manifest_version: u8,
pub name: String,
pub version: String,
#[serde(default = "default_app_type_native")]
pub app_type: AppType,
#[serde(default)]
pub description: String,
pub abi: Option<AbiVersion>,
pub entrypoint: Option<String>,
pub hot_reload: Option<HotReloadKind>,
#[serde(default)]
pub critical: bool,
#[serde(
default = "default_auto_start",
deserialize_with = "deserialize_auto_start"
)]
pub auto_start: bool,
#[serde(default)]
pub has_ui: bool,
#[serde(default = "default_ui_path")]
pub ui_path: String,
#[serde(default)]
pub permissions: Vec<String>,
#[serde(default)]
pub optional_permissions: Vec<String>,
#[serde(default)]
pub provides_scopes: Vec<ProvidedScope>,
#[serde(default)]
pub endpoint_policies: Vec<EndpointPolicy>,
#[serde(default)]
pub capability_scopes: HashMap<String, String>,
#[serde(default)]
pub provides: HashMap<String, ProvidedCapability>,
#[serde(default)]
pub capabilities: ManifestCapabilities,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub homepage: Option<String>,
#[serde(default)]
pub depends_on: Option<Vec<String>>,
#[serde(default)]
pub boot_priority: Option<u32>,
#[serde(default)]
pub standalone: Option<StandaloneConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tcp: Option<TcpManifest>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TcpManifest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preferred_port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub direct_bind: Option<bool>,
}
fn default_manifest_version() -> u8 {
1
}
fn default_auto_start() -> bool {
true
}
fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum BoolOrStr {
Bool(bool),
Str(String),
}
Ok(match BoolOrStr::deserialize(deserializer)? {
BoolOrStr::Bool(b) => b,
BoolOrStr::Str(s) => matches!(
s.trim().to_ascii_lowercase().as_str(),
"true" | "eager" | "active" | "auto" | "on" | "1"
),
})
}
fn default_ui_path() -> String {
"dist".to_string()
}
fn default_app_type_native() -> AppType {
AppType::Native
}
impl AppManifest {
pub fn effective_hot_reload(&self) -> HotReloadKind {
self.hot_reload
.unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
}
pub fn effective_entrypoint(&self) -> &str {
if let Some(ref ep) = self.entrypoint {
ep.as_str()
} else {
match self.app_type {
AppType::Native => "app.so",
AppType::Bun => "dist/index.js",
AppType::Standalone => "",
AppType::PlatformRuntime => "bun",
}
}
}
pub fn has_capability_providers(&self) -> bool {
!self.provides.is_empty() || !self.capabilities.provides.is_empty()
}
pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
for raw in &self.capabilities.provides {
let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
if name.is_empty() {
continue;
}
out.entry(name).or_insert(ProvidedCapability {
description: String::new(),
schema: None,
});
}
out
}
pub fn validate(&self) -> Result<(), String> {
self.validate_with_socket_path_policy(false)
}
pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
if self.manifest_version == 2 && self.abi.is_none() {
return Err("manifest_version 2 requires an 'abi' field".to_string());
}
validate_app_name(&self.name)?;
if let Some(ref ep) = self.entrypoint {
validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
}
if !self.ui_path.is_empty() && self.ui_path != "dist" {
validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
}
if let Some(ref hp) = self.homepage {
if !hp.starts_with("https://") && !hp.starts_with("http://") {
return Err(format!(
"homepage '{}' must use https:// or http:// scheme",
hp
));
}
}
match self.app_type {
AppType::Standalone => {
if self.has_capability_providers() {
let cfg = self.standalone.as_ref().ok_or_else(|| {
"standalone apps that declare 'provides' require a \
'standalone.socket_path' field"
.to_string()
})?;
validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
}
}
AppType::Native | AppType::Bun | AppType::PlatformRuntime => {
if self.standalone.is_some() {
return Err(format!(
"'standalone' block is only valid when app_type == 'standalone' \
(found app_type='{}')",
self.app_type
));
}
}
}
if self.app_type == AppType::PlatformRuntime
&& !self.resolved_capability_provides().is_empty()
{
return Err(
"platform-runtime packages cannot provide runtime capabilities".to_string(),
);
}
Ok(())
}
pub fn from_json(json: &str) -> Result<Self, String> {
Self::from_json_with_socket_path_policy(json, false)
}
pub fn from_json_with_socket_path_policy(
json: &str,
allow_non_run: bool,
) -> Result<Self, String> {
let manifest: Self =
serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
manifest.validate_with_socket_path_policy(allow_non_run)?;
Ok(manifest)
}
}
pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
validate_standalone_socket_path_with_policy(path, false)
}
pub fn validate_standalone_socket_path_with_policy(
path: &std::path::Path,
allow_non_run: bool,
) -> Result<(), String> {
if !path.is_absolute() {
return Err(format!(
"standalone.socket_path '{}' must be absolute",
path.display()
));
}
if !allow_non_run && !path.starts_with("/run/") {
return Err(format!(
"standalone.socket_path '{}' must live under /run/",
path.display()
));
}
if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(format!(
"standalone.socket_path '{}' must not contain '..' segments",
path.display()
));
}
Ok(())
}
fn validate_app_name(name: &str) -> Result<(), String> {
let (publisher, app) = if let Some(slash) = name.find('/') {
let (p, rest) = name.split_at(slash);
(Some(p), &rest[1..])
} else {
(None, name)
};
let valid_segment = |s: &str| -> bool {
if s.is_empty() {
return false;
}
let mut chars = s.chars();
let first = chars.next().unwrap();
if !first.is_ascii_lowercase() {
return false;
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
};
if let Some(pub_name) = publisher {
if !valid_segment(pub_name) {
return Err(format!(
"publisher segment '{}' must match [a-z][a-z0-9-]*",
pub_name
));
}
}
if !valid_segment(app) {
return Err(format!(
"app name segment '{}' must match [a-z][a-z0-9-]*",
app
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_ok(json: &str) -> AppManifest {
AppManifest::from_json(json).expect("should parse")
}
fn parse_err(json: &str) -> String {
AppManifest::from_json(json).expect_err("should fail")
}
#[test]
fn v1_minimal_native() {
let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
assert_eq!(m.manifest_version, 1);
assert_eq!(m.app_type, AppType::Native);
assert!(m.abi.is_none());
}
#[test]
fn v1_minimal_bun() {
let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
assert_eq!(m.app_type, AppType::Bun);
assert_eq!(m.effective_entrypoint(), "dist/index.js");
}
#[test]
fn v1_no_manifest_version_field_defaults_to_1() {
let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
assert_eq!(m.manifest_version, 1);
}
#[test]
fn v1_all_optional_fields_missing() {
let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
assert!(!m.critical);
assert!(m.auto_start);
assert!(!m.has_ui);
assert_eq!(m.ui_path, "dist");
assert!(m.permissions.is_empty());
assert!(m.optional_permissions.is_empty());
}
#[test]
fn v1_with_permissions_and_provides() {
let json = r#"{
"name": "example",
"version": "1.0.0",
"app_type": "bun",
"permissions": ["core.storage.kv"],
"optional_permissions": ["core.notifications.create"],
"provides": {
"core.example.run": { "description": "Run example job" }
}
}"#;
let m = parse_ok(json);
assert_eq!(m.permissions, vec!["core.storage.kv"]);
assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
assert!(m.provides.contains_key("core.example.run"));
}
#[test]
fn v2_minimal_native() {
let json = r#"{
"manifest_version": 2,
"name": "cron",
"version": "1.0.0",
"app_type": "native",
"abi": "v1",
"entrypoint": "app.so",
"hot_reload": "experimental"
}"#;
let m = parse_ok(json);
assert_eq!(m.manifest_version, 2);
assert_eq!(m.abi, Some(AbiVersion::V1));
assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
}
#[test]
fn v2_minimal_bun_with_capabilities() {
let json = r#"{
"manifest_version": 2,
"name": "example-fullstack",
"version": "1.0.0",
"app_type": "bun",
"abi": "v1",
"entrypoint": "dist/index.js",
"hot_reload": "supported",
"has_ui": true,
"ui_path": "ui/dist",
"capabilities": {
"requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
"provides": []
}
}"#;
let m = parse_ok(json);
assert_eq!(m.manifest_version, 2);
assert_eq!(m.capabilities.requires.len(), 2);
}
#[test]
fn v2_missing_abi_is_error() {
let json = r#"{
"manifest_version": 2,
"name": "example",
"version": "1.0.0",
"app_type": "bun"
}"#;
let err = parse_err(json);
assert!(err.contains("abi"), "expected abi error, got: {}", err);
}
#[test]
fn publisher_prefixed_name_accepted() {
let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
assert_eq!(m.name, "alice/weather");
}
#[test]
fn double_slash_name_rejected() {
let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
assert!(!err.is_empty());
}
#[test]
fn name_starting_with_digit_rejected() {
let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
assert!(!err.is_empty());
}
#[test]
fn name_with_uppercase_rejected() {
let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
assert!(!err.is_empty());
}
#[test]
fn empty_name_rejected() {
let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
assert!(!err.is_empty());
}
#[test]
fn path_traversal_double_dot_rejected() {
let json = r#"{
"manifest_version": 2, "name": "evil", "version": "1.0.0",
"app_type": "bun", "abi": "v1",
"entrypoint": "../etc/passwd"
}"#;
let err = parse_err(json);
assert!(err.contains(".."), "expected traversal error, got: {}", err);
}
#[test]
fn path_traversal_encoded_dot_not_decoded() {
let json = r#"{
"manifest_version": 2, "name": "evil", "version": "1.0.0",
"app_type": "bun", "abi": "v1",
"entrypoint": "foo/../bar"
}"#;
let err = parse_err(json);
assert!(!err.is_empty(), "should have failed: {}", err);
}
#[test]
fn absolute_path_rejected() {
let json = r#"{
"manifest_version": 2, "name": "evil", "version": "1.0.0",
"app_type": "bun", "abi": "v1",
"entrypoint": "/usr/bin/sh"
}"#;
let err = parse_err(json);
assert!(
err.contains("absolute"),
"expected absolute error, got: {}",
err
);
}
#[test]
fn shell_metachar_in_path_rejected() {
let json = r#"{
"manifest_version": 2, "name": "evil", "version": "1.0.0",
"app_type": "bun", "abi": "v1",
"entrypoint": "dist/index.js;rm -rf /"
}"#;
let err = parse_err(json);
assert!(!err.is_empty());
}
#[test]
fn valid_nested_path_accepted() {
let json = r#"{
"manifest_version": 2, "name": "my-app", "version": "1.0.0",
"app_type": "bun", "abi": "v1",
"entrypoint": "dist/index.js",
"ui_path": "ui/dist"
}"#;
parse_ok(json);
}
#[test]
fn homepage_https_accepted() {
let json = r#"{
"name": "my-app", "version": "1.0.0", "app_type": "bun",
"homepage": "https://example.com"
}"#;
parse_ok(json);
}
#[test]
fn homepage_javascript_scheme_rejected() {
let json = r#"{
"name": "my-app", "version": "1.0.0", "app_type": "bun",
"homepage": "javascript:alert(1)"
}"#;
let err = parse_err(json);
assert!(
err.contains("scheme"),
"expected scheme error, got: {}",
err
);
}
#[test]
fn homepage_file_scheme_rejected() {
let json = r#"{
"name": "my-app", "version": "1.0.0", "app_type": "bun",
"homepage": "file:///etc/passwd"
}"#;
let err = parse_err(json);
assert!(!err.is_empty());
}
#[test]
fn effective_entrypoint_native_default() {
let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
assert_eq!(m.effective_entrypoint(), "app.so");
}
#[test]
fn effective_entrypoint_bun_default() {
let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
assert_eq!(m.effective_entrypoint(), "dist/index.js");
}
#[test]
fn effective_hot_reload_native_default_is_experimental() {
let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
}
#[test]
fn effective_hot_reload_bun_default_is_supported() {
let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
}
#[test]
fn hot_reload_unsupported_explicit() {
let json = r#"{
"manifest_version": 2, "name": "myapp", "version": "1.0.0",
"app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
}"#;
let m = parse_ok(json);
assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
}
#[test]
fn validate_path_simple_valid() {
assert!(validate_manifest_path("dist/index.js").is_ok());
assert!(validate_manifest_path("app.so").is_ok());
assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
assert!(validate_manifest_path("build_output/main").is_ok());
}
#[test]
fn validate_path_empty_rejected() {
assert!(validate_manifest_path("").is_err());
}
#[test]
fn validate_path_absolute_rejected() {
assert!(validate_manifest_path("/usr/bin/sh").is_err());
}
#[test]
fn validate_path_double_dot_segment_rejected() {
assert!(validate_manifest_path("foo/../bar").is_err());
assert!(validate_manifest_path("../etc/passwd").is_err());
}
#[test]
fn validate_path_leading_dot_rejected() {
assert!(validate_manifest_path(".hidden").is_err());
}
#[test]
fn validate_path_null_byte_rejected() {
let path = "foo\0bar";
assert!(validate_manifest_path(path).is_err());
}
#[test]
fn standalone_socket_path_development_override_is_explicit_and_pure() {
let path = std::path::Path::new("/tmp/node-app/example.sock");
assert!(validate_standalone_socket_path(path).is_err());
assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
assert!(validate_standalone_socket_path_with_policy(
std::path::Path::new("/tmp/node-app/../escape.sock"),
true,
)
.is_err());
}
#[test]
fn manifest_capabilities_defaults_to_empty() {
let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
assert!(m.capabilities.requires.is_empty());
assert!(m.capabilities.provides.is_empty());
}
#[test]
fn resolved_capability_provides_v1_only() {
let json = r#"{
"name": "example", "version": "1.0.0", "app_type": "bun",
"provides": { "core.example.run": { "description": "Run example job" } }
}"#;
let m = parse_ok(json);
let out = m.resolved_capability_provides();
assert_eq!(out.len(), 1);
assert_eq!(
out.get("core.example.run").unwrap().description,
"Run example job"
);
}
#[test]
fn resolved_capability_provides_v2_names_get_blank_declaration() {
let json = r#"{
"manifest_version": 2, "name": "example", "version": "1.0.0",
"app_type": "bun", "abi": "v1",
"capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
}"#;
let m = parse_ok(json);
let out = m.resolved_capability_provides();
assert_eq!(out.len(), 2);
assert_eq!(out.get("core.example.run").unwrap().description, "");
assert!(out.get("core.example.run").unwrap().schema.is_none());
assert!(out.contains_key("core.example.other"));
assert!(!out.contains_key("core.example.other:extra"));
}
#[test]
fn resolved_capability_provides_v1_wins_on_conflict() {
let json = r#"{
"manifest_version": 2, "name": "example", "version": "1.0.0",
"app_type": "bun", "abi": "v1",
"provides": { "core.example.run": { "description": "v1 wins" } },
"capabilities": { "requires": [], "provides": ["core.example.run"] }
}"#;
let m = parse_ok(json);
let out = m.resolved_capability_provides();
assert_eq!(out.len(), 1);
assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
}
#[test]
fn resolved_capability_provides_blank_v2_name_skipped() {
let json = r#"{
"manifest_version": 2, "name": "example", "version": "1.0.0",
"app_type": "bun", "abi": "v1",
"capabilities": { "requires": [], "provides": [" ", "core.example.run"] }
}"#;
let m = parse_ok(json);
let out = m.resolved_capability_provides();
assert_eq!(out.len(), 1);
assert!(out.contains_key("core.example.run"));
}
#[test]
fn resolved_capability_provides_empty_manifest_yields_empty_map() {
let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
assert!(m.resolved_capability_provides().is_empty());
}
#[test]
fn abi_v1_is_supported() {
assert!(AbiVersion::V1.is_supported());
}
#[test]
fn app_tier_display() {
assert_eq!(AppTier::FirstParty.to_string(), "first_party");
assert_eq!(AppTier::Optional.to_string(), "optional");
assert_eq!(AppTier::Development.to_string(), "development");
}
#[test]
fn app_tier_serde_roundtrip() {
for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
let json = serde_json::to_string(&tier).unwrap();
let back: AppTier = serde_json::from_str(&json).unwrap();
assert_eq!(
tier, back,
"roundtrip failed for {:?}: serialized as {}",
tier, json
);
}
assert_eq!(
serde_json::to_string(&AppTier::Development).unwrap(),
"\"development\""
);
}
#[test]
fn app_type_display() {
assert_eq!(AppType::Native.to_string(), "native");
assert_eq!(AppType::Bun.to_string(), "bun");
assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
}
#[test]
fn platform_runtime_is_a_supported_packaging_type() {
let manifest: AppManifest = serde_json::from_value(serde_json::json!({
"manifest_version": 2,
"abi": "v1",
"name": "bun-runtime",
"version": "1.0.0",
"app_type": "platform-runtime",
"entrypoint": "bun"
}))
.expect("platform runtime manifest should parse");
assert_eq!(manifest.app_type, AppType::PlatformRuntime);
assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
}
}