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    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        choose_latest_version, component_target, extract_node_payload, parse_version_req,
352        select_schema,
353    };
354    use greentic_component::describe::{DescribePayload, DescribeVersion};
355    use semver::Version;
356    use serde_json::json;
357    use tempfile::tempdir;
358
359    #[test]
360    fn empty_version_requirement_defaults_to_any() {
361        let req = parse_version_req("").unwrap();
362        assert!(req.matches(&semver::Version::parse("1.2.3").unwrap()));
363    }
364
365    #[test]
366    fn invalid_version_requirement_is_rejected() {
367        assert!(parse_version_req("not-a-semver").is_err());
368    }
369
370    #[test]
371    fn whitespace_version_requirement_defaults_to_any() {
372        let req = parse_version_req("   ").unwrap();
373        assert!(req.matches(&Version::parse("9.9.9").unwrap()));
374    }
375
376    #[test]
377    fn component_target_falls_back_to_short_name() {
378        let dir = tempdir().unwrap();
379        let short = dir.path().join("hello-world");
380        std::fs::write(&short, "stub").unwrap();
381
382        let target = component_target("ai.greentic.hello-world", Some(dir.path()));
383        match target {
384            super::ComponentTarget::Path(path) => assert_eq!(path, short),
385            _ => panic!("expected path target"),
386        }
387    }
388
389    #[test]
390    fn component_target_uses_direct_name_without_root() {
391        match component_target("ai.greentic.hello-world", None) {
392            super::ComponentTarget::Direct(id) => assert_eq!(id, "ai.greentic.hello-world"),
393            _ => panic!("expected direct target"),
394        }
395    }
396
397    #[test]
398    fn extract_node_payload_reads_component_payload() {
399        let document = json!({
400            "nodes": {
401                "n1": {
402                    "demo.component": { "enabled": true }
403                }
404            }
405        });
406
407        let payload = extract_node_payload(&document, "n1", "demo.component").unwrap();
408        assert_eq!(payload["enabled"], true);
409    }
410
411    #[test]
412    fn extract_node_payload_requires_node_entry() {
413        let document = json!({
414            "nodes": {
415                "n1": {}
416            }
417        });
418
419        let err = extract_node_payload(&document, "missing", "demo.component").unwrap_err();
420        assert!(
421            err.to_string()
422                .contains("flow document missing node `missing`")
423        );
424    }
425
426    #[test]
427    fn extract_node_payload_requires_component_payload() {
428        let document = json!({
429            "nodes": {
430                "n1": {}
431            }
432        });
433
434        let err = extract_node_payload(&document, "n1", "demo.component").unwrap_err();
435        assert!(
436            err.to_string()
437                .contains("node `n1` missing component payload `demo.component`")
438        );
439    }
440
441    #[test]
442    fn choose_latest_version_prefers_highest_semver() {
443        let latest = choose_latest_version(&[
444            DescribeVersion {
445                version: Version::parse("0.9.0").unwrap(),
446                schema: json!({ "type": "object", "title": "old" }),
447                defaults: None,
448            },
449            DescribeVersion {
450                version: Version::parse("1.2.0").unwrap(),
451                schema: json!({ "type": "object", "title": "new" }),
452                defaults: None,
453            },
454        ])
455        .unwrap();
456
457        assert_eq!(latest.version, Version::parse("1.2.0").unwrap());
458        assert_eq!(latest.schema["title"], "new");
459    }
460
461    #[test]
462    fn select_schema_returns_none_when_no_versions_exist() {
463        let describe = DescribePayload {
464            name: "demo.component".to_string(),
465            versions: Vec::new(),
466            schema_id: None,
467        };
468
469        assert_eq!(select_schema(&describe), None);
470    }
471
472    #[test]
473    fn select_schema_serializes_latest_schema() {
474        let describe = DescribePayload {
475            name: "demo.component".to_string(),
476            versions: vec![
477                DescribeVersion {
478                    version: Version::parse("0.9.0").unwrap(),
479                    schema: json!({ "type": "object", "title": "old" }),
480                    defaults: None,
481                },
482                DescribeVersion {
483                    version: Version::parse("1.0.0").unwrap(),
484                    schema: json!({ "type": "object", "title": "new" }),
485                    defaults: None,
486                },
487            ],
488            schema_id: Some("demo.schema".to_string()),
489        };
490
491        let schema = select_schema(&describe).unwrap();
492        assert_eq!(
493            serde_json::from_str::<serde_json::Value>(&schema).unwrap()["title"],
494            "new"
495        );
496    }
497}