use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::describe;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResolvedOperation {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_schema: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResolvedComponent {
pub component_ref: String,
pub digest: String,
pub operations: Vec<ResolvedOperation>,
}
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error("failed to fetch component '{reference}': {source}")]
Fetch {
reference: String,
#[source]
source: greentic_distributor_client::dist::DistError,
},
#[error("fetched artifact for '{reference}' has no local wasm file")]
NoWasm { reference: String },
#[error("failed to introspect component operations: {0}")]
Introspect(#[from] describe::DescribeError),
}
pub async fn resolve_component(
reference: &str,
cache_dir: PathBuf,
) -> Result<ResolvedComponent, ResolveError> {
use greentic_distributor_client::dist::{CachePolicy, ResolvePolicy};
use greentic_distributor_client::{DistClient, DistOptions};
let opts = DistOptions {
cache_dir,
..DistOptions::default()
};
let client = DistClient::new(opts);
let fetch_err = |source| ResolveError::Fetch {
reference: reference.to_string(),
source,
};
let source = client.parse_source(reference).map_err(fetch_err)?;
let descriptor = client
.resolve(source, ResolvePolicy)
.await
.map_err(fetch_err)?;
let artifact = client
.fetch(&descriptor, CachePolicy)
.await
.map_err(fetch_err)?;
let wasm_path = artifact
.wasm_path
.clone()
.filter(|p| p.exists())
.unwrap_or_else(|| artifact.local_path.clone());
if !wasm_path.exists() {
return Err(ResolveError::NoWasm {
reference: reference.to_string(),
});
}
let operations = introspect_operations(&wasm_path)?;
Ok(ResolvedComponent {
component_ref: artifact.component_id.clone(),
digest: artifact.resolved_digest.clone(),
operations,
})
}
fn introspect_operations(
wasm_path: &Path,
) -> Result<Vec<ResolvedOperation>, describe::DescribeError> {
let payload = describe::from_wit_world(wasm_path, "")?;
Ok(operations_from_payload(&payload))
}
fn operations_from_payload(payload: &describe::DescribePayload) -> Vec<ResolvedOperation> {
let mut ops = Vec::new();
let mut seen = BTreeSet::new();
for version in &payload.versions {
let Some(functions) = version.schema.get("functions").and_then(|f| f.as_array()) else {
continue;
};
for function in functions {
let Some(name) = function.get("name").and_then(|n| n.as_str()) else {
continue;
};
if !seen.insert(name.to_string()) {
continue;
}
let description = function
.get("docs")
.and_then(|d| d.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
ops.push(ResolvedOperation {
name: name.to_string(),
input_schema: None,
description,
});
}
}
ops
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn payload(functions: serde_json::Value) -> describe::DescribePayload {
describe::DescribePayload {
name: "test".into(),
schema_id: None,
versions: vec![describe::DescribeVersion {
version: semver::Version::new(0, 1, 0),
schema: json!({ "world": "greentic:component/node@0.1.0", "functions": functions }),
defaults: None,
}],
}
}
#[test]
fn maps_function_names_dedups_and_captures_docs() {
let p = payload(json!([
{ "name": "validate", "key": "validate" },
{ "name": "translate", "key": "translate", "docs": " Translate input. " },
{ "name": "validate", "key": "validate" },
]));
let ops = operations_from_payload(&p);
assert_eq!(
ops.iter().map(|o| o.name.as_str()).collect::<Vec<_>>(),
vec!["validate", "translate"],
"names map in order and de-duplicate"
);
assert_eq!(ops[1].description.as_deref(), Some("Translate input."));
assert!(
ops.iter().all(|o| o.input_schema.is_none()),
"WIT introspection carries no input schema yet"
);
}
#[test]
fn empty_or_missing_functions_yields_no_operations() {
assert!(operations_from_payload(&payload(json!([]))).is_empty());
let no_functions = describe::DescribePayload {
name: "test".into(),
schema_id: None,
versions: vec![describe::DescribeVersion {
version: semver::Version::new(0, 1, 0),
schema: json!({ "world": "w" }),
defaults: None,
}],
};
assert!(operations_from_payload(&no_functions).is_empty());
}
}