Skip to main content

greentic_dev/
component_resolver.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use anyhow::{Context, Result, anyhow, bail};
7use greentic_component::describe::{DescribePayload, DescribeVersion};
8use greentic_component::lifecycle::Lifecycle;
9use greentic_component::manifest::ComponentManifest;
10use greentic_component::prepare::PreparedComponent;
11use greentic_component::prepare_component;
12use greentic_flow::flow_bundle::NodeRef;
13use jsonschema::{Draft, Validator};
14use semver::{Version, VersionReq};
15use serde::Serialize;
16use serde_json::Value as JsonValue;
17
18#[derive(Debug, Clone)]
19pub struct ResolvedComponent {
20    pub name: String,
21    pub version: Version,
22    pub wasm_path: PathBuf,
23    #[allow(dead_code)]
24    pub manifest_path: PathBuf,
25    pub schema_json: Option<String>,
26    pub manifest_json: Option<String>,
27    pub capabilities_json: Option<JsonValue>,
28    #[allow(dead_code)]
29    pub limits_json: Option<JsonValue>,
30    pub world: String,
31    pub wasm_hash: String,
32    #[allow(dead_code)]
33    pub(crate) describe: DescribePayload,
34}
35
36#[derive(Debug, Clone)]
37pub struct ResolvedNode {
38    pub node_id: String,
39    pub component: Arc<ResolvedComponent>,
40    pub pointer: String,
41    pub config: JsonValue,
42}
43
44#[derive(Debug, Clone)]
45pub struct NodeSchemaError {
46    pub node_id: String,
47    pub component: String,
48    pub pointer: String,
49    pub message: String,
50}
51
52#[derive(Debug, Clone, Hash, PartialEq, Eq)]
53struct ComponentCacheKey {
54    name: String,
55    version: Version,
56}
57
58impl ComponentCacheKey {
59    fn new(name: impl Into<String>, version: &Version) -> Self {
60        Self {
61            name: name.into(),
62            version: version.clone(),
63        }
64    }
65}
66
67pub struct ComponentResolver {
68    component_dir: Option<PathBuf>,
69    cache: HashMap<ComponentCacheKey, Arc<ResolvedComponent>>,
70    schema_cache: HashMap<String, Arc<CachedSchema>>,
71}
72
73struct CachedSchema(Validator);
74
75impl ComponentResolver {
76    pub fn new(component_dir: Option<PathBuf>) -> Self {
77        Self {
78            component_dir,
79            cache: HashMap::new(),
80            schema_cache: HashMap::new(),
81        }
82    }
83
84    pub fn resolve_component(
85        &mut self,
86        name: &str,
87        version_req: &VersionReq,
88    ) -> Result<Arc<ResolvedComponent>> {
89        self.load_component(name, version_req)
90    }
91
92    pub fn resolve_node(&mut self, node: &NodeRef, flow_doc: &JsonValue) -> Result<ResolvedNode> {
93        let component_key = &node.component;
94        let pointer = format!("/nodes/{}/{}", node.node_id, component_key.name);
95        let config = extract_node_payload(flow_doc, &node.node_id, &component_key.name)
96            .with_context(|| {
97                format!(
98                    "failed to extract payload for node `{}` ({})",
99                    node.node_id, component_key.name
100                )
101            })?;
102
103        let version_req = parse_version_req(&component_key.version_req).with_context(|| {
104            format!(
105                "node `{}` has invalid semver requirement `{}`",
106                node.node_id, component_key.version_req
107            )
108        })?;
109
110        let component = self
111            .load_component(&component_key.name, &version_req)
112            .with_context(|| {
113                format!(
114                    "node `{}`: failed to prepare component `{}`",
115                    node.node_id, component_key.name
116                )
117            })?;
118
119        Ok(ResolvedNode {
120            node_id: node.node_id.clone(),
121            component,
122            pointer,
123            config,
124        })
125    }
126
127    pub fn validate_node(&mut self, node: &ResolvedNode) -> Result<Vec<NodeSchemaError>> {
128        let Some(schema_json) = &node.component.schema_json else {
129            return Ok(Vec::new());
130        };
131
132        let validator = self.compile_schema(schema_json)?;
133        let mut issues = Vec::new();
134        if let Err(error) = validator.0.validate(&node.config) {
135            for error in std::iter::once(error).chain(validator.0.iter_errors(&node.config)) {
136                let suffix = error.instance_path().to_string();
137                let pointer = if suffix.is_empty() || suffix == "/" {
138                    node.pointer.clone()
139                } else {
140                    format!("{}{}", node.pointer, suffix)
141                };
142                issues.push(NodeSchemaError {
143                    node_id: node.node_id.clone(),
144                    component: node.component.name.clone(),
145                    pointer,
146                    message: error.to_string(),
147                });
148            }
149        }
150        Ok(issues)
151    }
152
153    fn compile_schema(&mut self, schema_json: &str) -> Result<Arc<CachedSchema>> {
154        if let Some(existing) = self.schema_cache.get(schema_json) {
155            return Ok(existing.clone());
156        }
157
158        let schema_value: JsonValue =
159            serde_json::from_str(schema_json).context("invalid schema JSON")?;
160        let compiled = jsonschema::options()
161            .with_draft(Draft::Draft7)
162            .build(&schema_value)
163            .map_err(|error| anyhow!("failed to compile schema JSON: {error}"))?;
164        let entry = Arc::new(CachedSchema(compiled));
165        self.schema_cache
166            .insert(schema_json.to_string(), entry.clone());
167        Ok(entry)
168    }
169
170    fn load_component(
171        &mut self,
172        name: &str,
173        version_req: &VersionReq,
174    ) -> Result<Arc<ResolvedComponent>> {
175        let target = component_target(name, self.component_dir.as_deref());
176        let target_display = match &target {
177            ComponentTarget::Direct(id) => id.clone(),
178            ComponentTarget::Path(path) => path.display().to_string(),
179        };
180
181        let prepared = prepare_component(target.as_ref()).with_context(|| {
182            format!(
183                "resolver looked for `{name}` via `{target_display}` but prepare_component failed"
184            )
185        })?;
186
187        if !version_req.matches(&prepared.manifest.version) {
188            bail!(
189                "component `{name}` version `{}` does not satisfy requirement `{version_req}`",
190                prepared.manifest.version
191            );
192        }
193
194        let key = ComponentCacheKey::new(name, &prepared.manifest.version);
195        if let Some(existing) = self.cache.get(&key) {
196            return Ok(existing.clone());
197        }
198
199        let resolved = Arc::new(to_resolved_component(prepared)?);
200        self.cache.insert(key, resolved.clone());
201        Ok(resolved)
202    }
203}
204
205enum ComponentTarget {
206    Direct(String),
207    Path(PathBuf),
208}
209
210impl ComponentTarget {
211    fn as_ref(&self) -> &str {
212        match self {
213            ComponentTarget::Direct(id) => id,
214            ComponentTarget::Path(path) => path.to_str().expect("component path utf-8"),
215        }
216    }
217}
218
219fn component_target(name: &str, root: Option<&Path>) -> ComponentTarget {
220    if let Some(dir) = root {
221        let candidate = dir.join(name);
222        if candidate.exists() {
223            return ComponentTarget::Path(candidate);
224        }
225
226        // Fallback: many manifests use fully-qualified ids (e.g., ai.greentic.hello-world) but are
227        // checked into components/ under the short name (hello-world).
228        if let Some(short) = name
229            .rsplit(['.', ':', '/'])
230            .next()
231            .filter(|s| !s.is_empty())
232        {
233            let alt = dir.join(short);
234            if alt.exists() {
235                return ComponentTarget::Path(alt);
236            }
237        }
238
239        return ComponentTarget::Path(candidate);
240    }
241    ComponentTarget::Direct(name.to_string())
242}
243
244fn parse_version_req(input: &str) -> Result<VersionReq> {
245    if input.trim().is_empty() {
246        VersionReq::parse("*").map_err(Into::into)
247    } else {
248        VersionReq::parse(input).map_err(Into::into)
249    }
250}
251
252fn to_resolved_component(prepared: PreparedComponent) -> Result<ResolvedComponent> {
253    let manifest_json = fs::read_to_string(&prepared.manifest_path)
254        .with_context(|| format!("failed to read {}", prepared.manifest_path.display()))?;
255    let capabilities_json = serde_json::to_value(&prepared.manifest.capabilities)
256        .context("failed to serialize capabilities")?;
257    let limits_json = prepared
258        .manifest
259        .limits
260        .as_ref()
261        .map(|limits| serde_json::to_value(limits).expect("limits serialize"));
262    let schema_json = select_schema(&prepared.describe);
263
264    Ok(ResolvedComponent {
265        name: prepared.manifest.id.as_str().to_string(),
266        version: prepared.manifest.version.clone(),
267        wasm_path: prepared.wasm_path.clone(),
268        manifest_path: prepared.manifest_path.clone(),
269        schema_json,
270        manifest_json: Some(manifest_json),
271        capabilities_json: Some(capabilities_json),
272        limits_json,
273        world: prepared.manifest.world.as_str().to_string(),
274        wasm_hash: prepared.wasm_hash.clone(),
275        describe: prepared.describe,
276    })
277}
278
279fn extract_node_payload(
280    document: &JsonValue,
281    node_id: &str,
282    component_name: &str,
283) -> Result<JsonValue> {
284    let nodes = document
285        .get("nodes")
286        .and_then(|value| value.as_object())
287        .context("flow document missing `nodes` object")?;
288
289    let node_entry = nodes
290        .get(node_id)
291        .and_then(|value| value.as_object())
292        .context(format!("flow document missing node `{node_id}`"))?;
293
294    let payload = node_entry.get(component_name).cloned().context(format!(
295        "node `{node_id}` missing component payload `{component_name}`"
296    ))?;
297
298    Ok(payload)
299}
300
301fn select_schema(describe: &DescribePayload) -> Option<String> {
302    choose_latest_version(&describe.versions)
303        .map(|entry| serde_json::to_string(&entry.schema).expect("describe schema serializes"))
304}
305
306fn choose_latest_version(versions: &[DescribeVersion]) -> Option<DescribeVersion> {
307    let mut sorted = versions.to_vec();
308    sorted.sort_by(|a, b| b.version.cmp(&a.version));
309    sorted.into_iter().next()
310}
311
312#[allow(dead_code)]
313#[derive(Serialize)]
314struct PreparedComponentView<'a> {
315    manifest: &'a ComponentManifest,
316    manifest_path: String,
317    wasm_path: String,
318    wasm_hash: &'a str,
319    world_ok: bool,
320    hash_verified: bool,
321    describe: &'a DescribePayload,
322    lifecycle: &'a Lifecycle,
323}
324
325#[allow(dead_code)]
326pub fn inspect(target: &str, compact_json: bool) -> Result<()> {
327    let prepared = prepare_component(target)
328        .with_context(|| format!("failed to prepare component `{target}`"))?;
329    let view = PreparedComponentView {
330        manifest: &prepared.manifest,
331        manifest_path: prepared.manifest_path.display().to_string(),
332        wasm_path: prepared.wasm_path.display().to_string(),
333        wasm_hash: &prepared.wasm_hash,
334        world_ok: prepared.world_ok,
335        hash_verified: prepared.hash_verified,
336        describe: &prepared.describe,
337        lifecycle: &prepared.lifecycle,
338    };
339
340    if compact_json {
341        println!("{}", serde_json::to_string(&view)?);
342    } else {
343        println!("{}", serde_json::to_string_pretty(&view)?);
344    }
345    Ok(())
346}
347
348#[cfg(test)]
349mod tests {
350    use super::{
351        ComponentResolver, ResolvedComponent, ResolvedNode, choose_latest_version,
352        component_target, extract_node_payload, parse_version_req, select_schema,
353    };
354    use greentic_component::describe::{DescribePayload, DescribeVersion};
355    use semver::Version;
356    use serde_json::json;
357    use std::path::PathBuf;
358    use std::sync::Arc;
359    use tempfile::tempdir;
360
361    #[test]
362    fn empty_version_requirement_defaults_to_any() {
363        let req = parse_version_req("").unwrap();
364        assert!(req.matches(&semver::Version::parse("1.2.3").unwrap()));
365    }
366
367    #[test]
368    fn invalid_version_requirement_is_rejected() {
369        assert!(parse_version_req("not-a-semver").is_err());
370    }
371
372    #[test]
373    fn component_target_falls_back_to_short_name() {
374        let dir = tempdir().unwrap();
375        let short = dir.path().join("hello-world");
376        std::fs::write(&short, "stub").unwrap();
377
378        let target = component_target("ai.greentic.hello-world", Some(dir.path()));
379        match target {
380            super::ComponentTarget::Path(path) => assert_eq!(path, short),
381            _ => panic!("expected path target"),
382        }
383    }
384
385    #[test]
386    fn extract_node_payload_reads_component_payload() {
387        let document = json!({
388            "nodes": {
389                "n1": {
390                    "demo.component": { "enabled": true }
391                }
392            }
393        });
394
395        let payload = extract_node_payload(&document, "n1", "demo.component").unwrap();
396        assert_eq!(payload["enabled"], true);
397    }
398
399    #[test]
400    fn extract_node_payload_reports_missing_shapes() {
401        assert!(
402            extract_node_payload(&json!({}), "n1", "demo.component")
403                .unwrap_err()
404                .to_string()
405                .contains("missing `nodes` object")
406        );
407        assert!(
408            extract_node_payload(&json!({ "nodes": {} }), "n1", "demo.component")
409                .unwrap_err()
410                .to_string()
411                .contains("missing node `n1`")
412        );
413        assert!(
414            extract_node_payload(&json!({ "nodes": { "n1": {} } }), "n1", "demo.component")
415                .unwrap_err()
416                .to_string()
417                .contains("missing component payload")
418        );
419    }
420
421    #[test]
422    fn select_schema_uses_highest_described_version() {
423        let describe = describe_payload(vec![
424            ("0.1.0", json!({ "type": "object" })),
425            (
426                "0.2.0",
427                json!({
428                    "type": "object",
429                    "required": ["name"],
430                    "properties": { "name": { "type": "string" } }
431                }),
432            ),
433        ]);
434
435        let schema = select_schema(&describe).unwrap();
436        assert!(schema.contains("\"name\""));
437        assert_eq!(
438            choose_latest_version(&describe.versions).unwrap().version,
439            Version::parse("0.2.0").unwrap()
440        );
441    }
442
443    #[test]
444    fn validate_node_returns_empty_without_schema() {
445        let component = resolved_component("demo.component", None);
446        let node = ResolvedNode {
447            node_id: "n1".to_string(),
448            component,
449            pointer: "/nodes/n1/demo.component".to_string(),
450            config: json!({ "anything": true }),
451        };
452        let mut resolver = ComponentResolver::new(None);
453
454        assert!(resolver.validate_node(&node).unwrap().is_empty());
455    }
456
457    #[test]
458    fn validate_node_reports_schema_errors_with_node_pointer() {
459        let schema = json!({
460            "type": "object",
461            "required": ["name"],
462            "properties": {
463                "name": { "type": "string" },
464                "count": { "type": "integer" }
465            }
466        })
467        .to_string();
468        let component = resolved_component("demo.component", Some(schema));
469        let node = ResolvedNode {
470            node_id: "n1".to_string(),
471            component,
472            pointer: "/nodes/n1/demo.component".to_string(),
473            config: json!({ "count": "many" }),
474        };
475        let mut resolver = ComponentResolver::new(None);
476
477        let issues = resolver.validate_node(&node).unwrap();
478
479        assert!(issues.iter().any(|issue| issue.node_id == "n1"));
480        assert!(
481            issues
482                .iter()
483                .any(|issue| issue.component == "demo.component")
484        );
485        assert!(issues.iter().any(|issue| issue.pointer.contains("/count")));
486        assert!(
487            issues
488                .iter()
489                .any(|issue| issue.message.contains("required"))
490        );
491    }
492
493    #[test]
494    fn validate_node_rejects_invalid_schema_json() {
495        let component = resolved_component("demo.component", Some("{not json".to_string()));
496        let node = ResolvedNode {
497            node_id: "n1".to_string(),
498            component,
499            pointer: "/nodes/n1/demo.component".to_string(),
500            config: json!({}),
501        };
502        let mut resolver = ComponentResolver::new(None);
503
504        assert!(
505            resolver
506                .validate_node(&node)
507                .unwrap_err()
508                .to_string()
509                .contains("invalid schema JSON")
510        );
511    }
512
513    fn describe_payload(entries: Vec<(&str, serde_json::Value)>) -> DescribePayload {
514        DescribePayload {
515            name: "demo.component".to_string(),
516            versions: entries
517                .into_iter()
518                .map(|(version, schema)| DescribeVersion {
519                    version: Version::parse(version).unwrap(),
520                    schema,
521                    defaults: None,
522                })
523                .collect(),
524            schema_id: None,
525        }
526    }
527
528    fn resolved_component(name: &str, schema_json: Option<String>) -> Arc<ResolvedComponent> {
529        Arc::new(ResolvedComponent {
530            name: name.to_string(),
531            version: Version::parse("1.0.0").unwrap(),
532            wasm_path: PathBuf::from("component.wasm"),
533            manifest_path: PathBuf::from("manifest.json"),
534            schema_json,
535            manifest_json: Some(json!({ "id": name, "version": "1.0.0" }).to_string()),
536            capabilities_json: Some(json!({})),
537            limits_json: None,
538            world: "greentic:component/component@0.4.0".to_string(),
539            wasm_hash: "abc123".to_string(),
540            describe: describe_payload(Vec::new()),
541        })
542    }
543}