use std::fs;
use std::path::{Path, PathBuf};
use anyhow::Error;
use semver::Version;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use thiserror::Error;
use wit_parser::{Resolve, WorldId, WorldItem, WorldKey};
use crate::manifest::ComponentManifest;
use crate::wasm;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DescribePayload {
pub name: String,
pub versions: Vec<DescribeVersion>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DescribeVersion {
pub version: Version,
pub schema: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub defaults: Option<Value>,
}
#[derive(Debug, Error)]
pub enum DescribeError {
#[error("failed to read describe payload at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("invalid describe payload at {path}: {source}")]
Json {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("failed to decode component metadata: {0}")]
Metadata(Error),
#[error("describe payload not found: {0}")]
NotFound(String),
}
pub fn from_exported_func(
wasm_path: &Path,
symbol: &str,
) -> Result<DescribePayload, DescribeError> {
let dir = wasm_path
.parent()
.ok_or_else(|| DescribeError::NotFound(symbol.to_string()))?;
let candidate = dir.join(format!("{symbol}.describe.json"));
read_payload(&candidate)
}
pub fn from_wit_world(wasm_path: &Path, world: &str) -> Result<DescribePayload, DescribeError> {
let bytes = fs::read(wasm_path).map_err(|source| DescribeError::Io {
path: wasm_path.to_path_buf(),
source,
})?;
let decoded = wasm::decode_world(&bytes).map_err(DescribeError::Metadata)?;
build_payload_from_world(&decoded.resolve, decoded.world, Some(world))
}
pub fn from_embedded(manifest_dir: &Path) -> Option<DescribePayload> {
let schema_dir = manifest_dir.join("schemas").join("v1");
let entries = fs::read_dir(schema_dir).ok()?;
let mut files = Vec::new();
for entry in entries.flatten() {
files.push(entry.path());
}
files.sort();
for path in files {
if path.extension().and_then(|s| s.to_str()) == Some("json")
&& let Ok(payload) = read_payload(&path)
{
return Some(payload);
}
}
None
}
pub fn load(
wasm_path: &Path,
manifest: &ComponentManifest,
) -> Result<DescribePayload, DescribeError> {
if let Ok(payload) = from_wit_world(wasm_path, manifest.world.as_str()) {
return Ok(payload);
}
if let Ok(payload) = from_exported_func(wasm_path, manifest.describe_export.as_str()) {
return Ok(payload);
}
if let Some(dir) = wasm_path.parent()
&& let Some(payload) = from_embedded(dir)
{
return Ok(payload);
}
Err(DescribeError::NotFound(manifest.id.as_str().to_string()))
}
fn read_payload(path: &Path) -> Result<DescribePayload, DescribeError> {
let data = fs::read_to_string(path).map_err(|source| DescribeError::Io {
path: path.to_path_buf(),
source,
})?;
serde_json::from_str(&data).map_err(|source| DescribeError::Json {
path: path.to_path_buf(),
source,
})
}
fn build_payload_from_world(
resolve: &Resolve,
world_id: WorldId,
preferred_world: Option<&str>,
) -> Result<DescribePayload, DescribeError> {
let world = &resolve.worlds[world_id];
let resolved_world_ref = format_world(resolve, world_id);
let resolved_version = world
.package
.and_then(|pkg_id| resolve.packages[pkg_id].name.version.clone())
.map(|ver| Version::new(ver.major, ver.minor, ver.patch))
.unwrap_or_else(|| Version::new(0, 0, 0));
let (world_ref, name, version) = preferred_world
.and_then(|preferred_world| parse_preferred_world_ref(preferred_world, &resolved_version))
.unwrap_or_else(|| {
(
resolved_world_ref.clone(),
world.name.clone(),
resolved_version.clone(),
)
});
let mut functions = Vec::new();
for (key, item) in &world.exports {
match item {
WorldItem::Function(func) => {
let mut entry = Map::new();
entry.insert("name".into(), Value::String(func.name.clone()));
entry.insert("key".into(), Value::String(label_for_key(resolve, key)));
if let Some(doc) = func.docs.contents.clone() {
entry.insert("docs".into(), Value::String(doc));
}
functions.push(Value::Object(entry));
}
WorldItem::Interface { id, .. } => {
let iface = &resolve.interfaces[*id];
for (name, func) in iface.functions.iter() {
let mut entry = Map::new();
entry.insert("name".into(), Value::String(name.clone()));
if let Some(doc) = func.docs.contents.clone() {
entry.insert("docs".into(), Value::String(doc));
}
if let Some(iface_name) = &iface.name {
entry.insert("interface".into(), Value::String(iface_name.clone()));
}
functions.push(Value::Object(entry));
}
}
WorldItem::Type { .. } => {}
}
}
let schema = json!({
"world": world_ref,
"functions": functions,
});
Ok(DescribePayload {
name,
schema_id: Some(world_ref.clone()),
versions: vec![DescribeVersion {
version,
schema,
defaults: None,
}],
})
}
fn parse_preferred_world_ref(
world_ref: &str,
fallback_version: &Version,
) -> Option<(String, String, Version)> {
if world_ref.trim().is_empty() {
return None;
}
let (name_part, version) = match world_ref.rsplit_once('@') {
Some((name_part, version_part)) => (name_part, Version::parse(version_part).ok()?),
None => (world_ref, fallback_version.clone()),
};
let name = name_part.rsplit('/').next()?.to_string();
Some((world_ref.to_string(), name, version))
}
fn format_world(resolve: &Resolve, world_id: WorldId) -> String {
let world = &resolve.worlds[world_id];
if let Some(pkg_id) = world.package {
let pkg = &resolve.packages[pkg_id];
if let Some(version) = &pkg.name.version {
format!(
"{}:{}/{}@{}",
pkg.name.namespace, pkg.name.name, world.name, version
)
} else {
format!("{}:{}/{}", pkg.name.namespace, pkg.name.name, world.name)
}
} else {
world.name.clone()
}
}
fn label_for_key(resolve: &Resolve, key: &WorldKey) -> String {
match key {
WorldKey::Name(name) => name.to_string(),
WorldKey::Interface(id) => {
let iface = &resolve.interfaces[*id];
iface
.name
.as_ref()
.map(|s| s.to_string())
.unwrap_or_else(|| format!("interface-{}", id.index()))
}
}
}