Skip to main content

greentic_component/
describe.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::Error;
5use semver::Version;
6use serde::{Deserialize, Serialize};
7use serde_json::{Map, Value, json};
8use thiserror::Error;
9use wit_parser::{Resolve, WorldId, WorldItem, WorldKey};
10
11use crate::manifest::ComponentManifest;
12use crate::wasm;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct DescribePayload {
16    pub name: String,
17    pub versions: Vec<DescribeVersion>,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub schema_id: Option<String>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct DescribeVersion {
24    pub version: Version,
25    pub schema: Value,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub defaults: Option<Value>,
28}
29
30#[derive(Debug, Error)]
31pub enum DescribeError {
32    #[error("failed to read describe payload at {path}: {source}")]
33    Io {
34        path: PathBuf,
35        #[source]
36        source: std::io::Error,
37    },
38    #[error("invalid describe payload at {path}: {source}")]
39    Json {
40        path: PathBuf,
41        #[source]
42        source: serde_json::Error,
43    },
44    #[error("failed to decode component metadata: {0}")]
45    Metadata(Error),
46    #[error("describe payload not found: {0}")]
47    NotFound(String),
48}
49
50pub fn from_exported_func(
51    wasm_path: &Path,
52    symbol: &str,
53) -> Result<DescribePayload, DescribeError> {
54    let dir = wasm_path
55        .parent()
56        .ok_or_else(|| DescribeError::NotFound(symbol.to_string()))?;
57    let candidate = dir.join(format!("{symbol}.describe.json"));
58    read_payload(&candidate)
59}
60
61pub fn from_wit_world(wasm_path: &Path, world: &str) -> Result<DescribePayload, DescribeError> {
62    let bytes = fs::read(wasm_path).map_err(|source| DescribeError::Io {
63        path: wasm_path.to_path_buf(),
64        source,
65    })?;
66    let decoded = wasm::decode_world(&bytes).map_err(DescribeError::Metadata)?;
67    build_payload_from_world(&decoded.resolve, decoded.world, Some(world))
68}
69
70pub fn from_embedded(manifest_dir: &Path) -> Option<DescribePayload> {
71    let schema_dir = manifest_dir.join("schemas").join("v1");
72    let entries = fs::read_dir(schema_dir).ok()?;
73    let mut files = Vec::new();
74    for entry in entries.flatten() {
75        files.push(entry.path());
76    }
77    files.sort();
78    for path in files {
79        if path.extension().and_then(|s| s.to_str()) == Some("json")
80            && let Ok(payload) = read_payload(&path)
81        {
82            return Some(payload);
83        }
84    }
85    None
86}
87
88pub fn load(
89    wasm_path: &Path,
90    manifest: &ComponentManifest,
91) -> Result<DescribePayload, DescribeError> {
92    if let Ok(payload) = from_wit_world(wasm_path, manifest.world.as_str()) {
93        return Ok(payload);
94    }
95    if let Ok(payload) = from_exported_func(wasm_path, manifest.describe_export.as_str()) {
96        return Ok(payload);
97    }
98    if let Some(dir) = wasm_path.parent()
99        && let Some(payload) = from_embedded(dir)
100    {
101        return Ok(payload);
102    }
103    Err(DescribeError::NotFound(manifest.id.as_str().to_string()))
104}
105
106fn read_payload(path: &Path) -> Result<DescribePayload, DescribeError> {
107    let data = fs::read_to_string(path).map_err(|source| DescribeError::Io {
108        path: path.to_path_buf(),
109        source,
110    })?;
111    serde_json::from_str(&data).map_err(|source| DescribeError::Json {
112        path: path.to_path_buf(),
113        source,
114    })
115}
116
117fn build_payload_from_world(
118    resolve: &Resolve,
119    world_id: WorldId,
120    preferred_world: Option<&str>,
121) -> Result<DescribePayload, DescribeError> {
122    let world = &resolve.worlds[world_id];
123    let resolved_world_ref = format_world(resolve, world_id);
124    let resolved_version = world
125        .package
126        .and_then(|pkg_id| resolve.packages[pkg_id].name.version.clone())
127        .map(|ver| Version::new(ver.major, ver.minor, ver.patch))
128        .unwrap_or_else(|| Version::new(0, 0, 0));
129    let (world_ref, name, version) = preferred_world
130        .and_then(|preferred_world| parse_preferred_world_ref(preferred_world, &resolved_version))
131        .unwrap_or_else(|| {
132            (
133                resolved_world_ref.clone(),
134                world.name.clone(),
135                resolved_version.clone(),
136            )
137        });
138
139    let mut functions = Vec::new();
140    for (key, item) in &world.exports {
141        match item {
142            WorldItem::Function(func) => {
143                let mut entry = Map::new();
144                entry.insert("name".into(), Value::String(func.name.clone()));
145                entry.insert("key".into(), Value::String(label_for_key(resolve, key)));
146                if let Some(doc) = func.docs.contents.clone() {
147                    entry.insert("docs".into(), Value::String(doc));
148                }
149                functions.push(Value::Object(entry));
150            }
151            WorldItem::Interface { id, .. } => {
152                let iface = &resolve.interfaces[*id];
153                for (name, func) in iface.functions.iter() {
154                    let mut entry = Map::new();
155                    entry.insert("name".into(), Value::String(name.clone()));
156                    if let Some(doc) = func.docs.contents.clone() {
157                        entry.insert("docs".into(), Value::String(doc));
158                    }
159                    if let Some(iface_name) = &iface.name {
160                        entry.insert("interface".into(), Value::String(iface_name.clone()));
161                    }
162                    functions.push(Value::Object(entry));
163                }
164            }
165            WorldItem::Type { .. } => {}
166        }
167    }
168
169    let schema = json!({
170        "world": world_ref,
171        "functions": functions,
172    });
173
174    Ok(DescribePayload {
175        name,
176        schema_id: Some(world_ref.clone()),
177        versions: vec![DescribeVersion {
178            version,
179            schema,
180            defaults: None,
181        }],
182    })
183}
184
185fn parse_preferred_world_ref(
186    world_ref: &str,
187    fallback_version: &Version,
188) -> Option<(String, String, Version)> {
189    if world_ref.trim().is_empty() {
190        return None;
191    }
192    let (name_part, version) = match world_ref.rsplit_once('@') {
193        Some((name_part, version_part)) => (name_part, Version::parse(version_part).ok()?),
194        None => (world_ref, fallback_version.clone()),
195    };
196    let name = name_part.rsplit('/').next()?.to_string();
197    Some((world_ref.to_string(), name, version))
198}
199
200fn format_world(resolve: &Resolve, world_id: WorldId) -> String {
201    let world = &resolve.worlds[world_id];
202    if let Some(pkg_id) = world.package {
203        let pkg = &resolve.packages[pkg_id];
204        if let Some(version) = &pkg.name.version {
205            format!(
206                "{}:{}/{}@{}",
207                pkg.name.namespace, pkg.name.name, world.name, version
208            )
209        } else {
210            format!("{}:{}/{}", pkg.name.namespace, pkg.name.name, world.name)
211        }
212    } else {
213        world.name.clone()
214    }
215}
216
217fn label_for_key(resolve: &Resolve, key: &WorldKey) -> String {
218    match key {
219        WorldKey::Name(name) => name.to_string(),
220        WorldKey::Interface(id) => {
221            let iface = &resolve.interfaces[*id];
222            iface
223                .name
224                .as_ref()
225                .map(|s| s.to_string())
226                .unwrap_or_else(|| format!("interface-{}", id.index()))
227        }
228    }
229}