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 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 whitespace_version_requirement_defaults_to_any() {
374 let req = parse_version_req(" ").unwrap();
375 assert!(req.matches(&Version::parse("9.9.9").unwrap()));
376 }
377
378 #[test]
379 fn component_target_falls_back_to_short_name() {
380 let dir = tempdir().unwrap();
381 let short = dir.path().join("hello-world");
382 std::fs::write(&short, "stub").unwrap();
383
384 let target = component_target("ai.greentic.hello-world", Some(dir.path()));
385 match target {
386 super::ComponentTarget::Path(path) => assert_eq!(path, short),
387 _ => panic!("expected path target"),
388 }
389 }
390
391 #[test]
392 fn component_target_uses_direct_name_without_root() {
393 match component_target("ai.greentic.hello-world", None) {
394 super::ComponentTarget::Direct(id) => assert_eq!(id, "ai.greentic.hello-world"),
395 _ => panic!("expected direct target"),
396 }
397 }
398
399 #[test]
400 fn extract_node_payload_reads_component_payload() {
401 let document = json!({
402 "nodes": {
403 "n1": {
404 "demo.component": { "enabled": true }
405 }
406 }
407 });
408
409 let payload = extract_node_payload(&document, "n1", "demo.component").unwrap();
410 assert_eq!(payload["enabled"], true);
411 }
412
413 #[test]
414 fn extract_node_payload_reports_missing_shapes() {
415 assert!(
416 extract_node_payload(&json!({}), "n1", "demo.component")
417 .unwrap_err()
418 .to_string()
419 .contains("missing `nodes` object")
420 );
421 assert!(
422 extract_node_payload(&json!({ "nodes": {} }), "n1", "demo.component")
423 .unwrap_err()
424 .to_string()
425 .contains("missing node `n1`")
426 );
427 assert!(
428 extract_node_payload(&json!({ "nodes": { "n1": {} } }), "n1", "demo.component")
429 .unwrap_err()
430 .to_string()
431 .contains("missing component payload")
432 );
433 }
434
435 #[test]
436 fn select_schema_uses_highest_described_version() {
437 let describe = describe_payload(vec![
438 ("0.1.0", json!({ "type": "object" })),
439 (
440 "0.2.0",
441 json!({
442 "type": "object",
443 "required": ["name"],
444 "properties": { "name": { "type": "string" } }
445 }),
446 ),
447 ]);
448
449 let schema = select_schema(&describe).unwrap();
450 assert!(schema.contains("\"name\""));
451 assert_eq!(
452 choose_latest_version(&describe.versions).unwrap().version,
453 Version::parse("0.2.0").unwrap()
454 );
455 }
456
457 #[test]
458 fn validate_node_returns_empty_without_schema() {
459 let component = resolved_component("demo.component", None);
460 let node = ResolvedNode {
461 node_id: "n1".to_string(),
462 component,
463 pointer: "/nodes/n1/demo.component".to_string(),
464 config: json!({ "anything": true }),
465 };
466 let mut resolver = ComponentResolver::new(None);
467
468 assert!(resolver.validate_node(&node).unwrap().is_empty());
469 }
470
471 #[test]
472 fn validate_node_reports_schema_errors_with_node_pointer() {
473 let schema = json!({
474 "type": "object",
475 "required": ["name"],
476 "properties": {
477 "name": { "type": "string" },
478 "count": { "type": "integer" }
479 }
480 })
481 .to_string();
482 let component = resolved_component("demo.component", Some(schema));
483 let node = ResolvedNode {
484 node_id: "n1".to_string(),
485 component,
486 pointer: "/nodes/n1/demo.component".to_string(),
487 config: json!({ "count": "many" }),
488 };
489 let mut resolver = ComponentResolver::new(None);
490
491 let issues = resolver.validate_node(&node).unwrap();
492
493 assert!(issues.iter().any(|issue| issue.node_id == "n1"));
494 assert!(
495 issues
496 .iter()
497 .any(|issue| issue.component == "demo.component")
498 );
499 assert!(issues.iter().any(|issue| issue.pointer.contains("/count")));
500 assert!(
501 issues
502 .iter()
503 .any(|issue| issue.message.contains("required"))
504 );
505 }
506
507 #[test]
508 fn validate_node_rejects_invalid_schema_json() {
509 let component = resolved_component("demo.component", Some("{not json".to_string()));
510 let node = ResolvedNode {
511 node_id: "n1".to_string(),
512 component,
513 pointer: "/nodes/n1/demo.component".to_string(),
514 config: json!({}),
515 };
516 let mut resolver = ComponentResolver::new(None);
517
518 assert!(
519 resolver
520 .validate_node(&node)
521 .unwrap_err()
522 .to_string()
523 .contains("invalid schema JSON")
524 );
525 }
526
527 fn describe_payload(entries: Vec<(&str, serde_json::Value)>) -> DescribePayload {
528 DescribePayload {
529 name: "demo.component".to_string(),
530 versions: entries
531 .into_iter()
532 .map(|(version, schema)| DescribeVersion {
533 version: Version::parse(version).unwrap(),
534 schema,
535 defaults: None,
536 })
537 .collect(),
538 schema_id: None,
539 }
540 }
541
542 fn resolved_component(name: &str, schema_json: Option<String>) -> Arc<ResolvedComponent> {
543 Arc::new(ResolvedComponent {
544 name: name.to_string(),
545 version: Version::parse("1.0.0").unwrap(),
546 wasm_path: PathBuf::from("component.wasm"),
547 manifest_path: PathBuf::from("manifest.json"),
548 schema_json,
549 manifest_json: Some(json!({ "id": name, "version": "1.0.0" }).to_string()),
550 capabilities_json: Some(json!({})),
551 limits_json: None,
552 world: "greentic:component/component@0.4.0".to_string(),
553 wasm_hash: "abc123".to_string(),
554 describe: describe_payload(Vec::new()),
555 })
556 }
557}