greentic_dev/
component_cli.rs

1use std::collections::{BTreeSet, HashMap};
2use std::env;
3use std::fs;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use std::sync::Arc;
8
9use anyhow::{Context, Result, anyhow, bail};
10use clap::{Args, Subcommand};
11use convert_case::{Case, Casing};
12use greentic_component_runtime as component_runtime;
13use greentic_component_runtime::{
14    Bindings, ComponentManifestInfo, ComponentRef, HostPolicy, LoadPolicy,
15};
16use greentic_component_store::ComponentStore;
17use greentic_types::TenantCtx as RuntimeTenantCtx;
18use greentic_types::{EnvId, TenantCtx as FlowTenantCtx, TenantId};
19use once_cell::sync::Lazy;
20use semver::Version;
21use serde::{Deserialize, Serialize};
22use serde_json::{Value as JsonValue, json};
23use sha2::{Digest, Sha256};
24use time::OffsetDateTime;
25use time::format_description::well_known::Rfc3339;
26use wit_component::{DecodedWasm, decode as decode_component};
27use wit_parser::{Resolve, WorldId, WorldItem};
28
29static WORKSPACE_ROOT: Lazy<PathBuf> = Lazy::new(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")));
30
31const TEMPLATE_COMPONENT_CARGO: &str = include_str!(concat!(
32    env!("CARGO_MANIFEST_DIR"),
33    "/templates/component/Cargo.toml.in"
34));
35const TEMPLATE_SRC_LIB: &str = include_str!(concat!(
36    env!("CARGO_MANIFEST_DIR"),
37    "/templates/component/src/lib.rs"
38));
39const TEMPLATE_PROVIDER: &str = include_str!(concat!(
40    env!("CARGO_MANIFEST_DIR"),
41    "/templates/component/provider.toml"
42));
43const TEMPLATE_SCHEMA_CONFIG: &str = include_str!(concat!(
44    env!("CARGO_MANIFEST_DIR"),
45    "/templates/component/schemas/v1/config.schema.json"
46));
47const TEMPLATE_README: &str = include_str!(concat!(
48    env!("CARGO_MANIFEST_DIR"),
49    "/templates/component/README.md"
50));
51const TEMPLATE_WORLD: &str = include_str!(concat!(
52    env!("CARGO_MANIFEST_DIR"),
53    "/templates/component/wit/world.wit"
54));
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57struct ProviderMetadata {
58    name: String,
59    version: String,
60    #[serde(default)]
61    description: Option<String>,
62    #[serde(default)]
63    license: Option<String>,
64    #[serde(default)]
65    homepage: Option<String>,
66    abi: AbiSection,
67    capabilities: CapabilitiesSection,
68    exports: ExportsSection,
69    #[serde(default)]
70    imports: ImportsSection,
71    artifact: ArtifactSection,
72    #[serde(default)]
73    docs: Option<DocsSection>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77struct AbiSection {
78    interfaces_version: String,
79    types_version: String,
80    component_runtime: String,
81    world: String,
82    #[serde(default)]
83    wit_packages: Vec<String>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87struct CapabilitiesSection {
88    #[serde(default)]
89    secrets: bool,
90    #[serde(default)]
91    telemetry: bool,
92    #[serde(default)]
93    network: bool,
94    #[serde(default)]
95    filesystem: bool,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99struct ExportsSection {
100    #[serde(default)]
101    provides: Vec<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, Default)]
105struct ImportsSection {
106    #[serde(default)]
107    requires: Vec<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111struct ArtifactSection {
112    format: String,
113    path: String,
114    #[serde(default)]
115    sha256: String,
116    #[serde(default)]
117    created: String,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, Default)]
121struct DocsSection {
122    #[serde(default)]
123    readme: Option<String>,
124    #[serde(default)]
125    schemas: Vec<String>,
126}
127
128#[derive(Debug)]
129struct ValidationReport {
130    provider: ProviderMetadata,
131    component_dir: PathBuf,
132    artifact_path: PathBuf,
133    sha256: String,
134    world: String,
135    packages: Vec<String>,
136    manifest: Option<ComponentManifestInfo>,
137}
138
139#[derive(Debug, Clone)]
140struct WitInfo {
141    version: String,
142    dir: PathBuf,
143}
144
145#[derive(Debug, Clone)]
146struct Versions {
147    interfaces: String,
148    types: String,
149    component_runtime: String,
150    component_wit: WitInfo,
151    host_import_wit: WitInfo,
152    types_core_wit: WitInfo,
153}
154
155impl Versions {
156    fn load() -> Result<Self> {
157        let interfaces_version = resolved_version("greentic-interfaces")?;
158        let types_version = resolved_version("greentic-types")?;
159        let component_runtime_version = resolved_version("component-runtime")?;
160
161        let interfaces_root = find_crate_source("greentic-interfaces", &interfaces_version)?;
162        let component_wit = detect_wit_package(&interfaces_root, "component")?;
163        let host_import_wit = detect_wit_package(&interfaces_root, "host-import")?;
164        let types_core_wit = detect_wit_package(&interfaces_root, "types-core")?;
165
166        Ok(Self {
167            interfaces: interfaces_version,
168            types: types_version,
169            component_runtime: component_runtime_version,
170            component_wit,
171            host_import_wit,
172            types_core_wit,
173        })
174    }
175}
176
177static VERSIONS: Lazy<Versions> =
178    Lazy::new(|| Versions::load().expect("load greentic crate versions"));
179
180pub fn run_component_command(command: ComponentCommands) -> Result<()> {
181    match command {
182        ComponentCommands::New(args) => new_component(args),
183        ComponentCommands::Validate(args) => validate_command(args),
184        ComponentCommands::Pack(args) => pack_command(args),
185        ComponentCommands::DemoRun(args) => demo_run_command(args),
186    }
187}
188
189#[derive(Subcommand, Debug, Clone)]
190pub enum ComponentCommands {
191    /// Scaffold a new component repository
192    New(NewComponentArgs),
193    /// Build and validate a component against pinned interfaces
194    Validate(ValidateArgs),
195    /// Package a component into `packs/<name>/<version>`
196    Pack(PackArgs),
197    /// Execute a component locally with default mocks
198    DemoRun(DemoRunArgs),
199}
200
201#[derive(Args, Debug, Clone)]
202pub struct NewComponentArgs {
203    /// Name of the component (kebab-case recommended)
204    name: String,
205    /// Optional directory where the component should be created
206    #[arg(long, value_name = "DIR")]
207    dir: Option<PathBuf>,
208}
209
210#[derive(Args, Debug, Clone)]
211pub struct ValidateArgs {
212    /// Path to the component directory
213    #[arg(long, value_name = "PATH", default_value = ".")]
214    path: PathBuf,
215    /// Skip cargo component build (use the existing artifact)
216    #[arg(long)]
217    skip_build: bool,
218}
219
220#[derive(Args, Debug, Clone)]
221pub struct PackArgs {
222    /// Path to the component directory
223    #[arg(long, value_name = "PATH", default_value = ".")]
224    path: PathBuf,
225    /// Output directory for generated packs (defaults to `<component>/packs`)
226    #[arg(long, value_name = "DIR")]
227    out_dir: Option<PathBuf>,
228    /// Skip cargo component build before packing
229    #[arg(long)]
230    skip_build: bool,
231}
232
233#[derive(Args, Debug, Clone)]
234pub struct DemoRunArgs {
235    /// Path to the component directory
236    #[arg(long, value_name = "PATH", default_value = ".")]
237    path: PathBuf,
238    /// Optional path to the component artifact to execute
239    #[arg(long, value_name = "FILE")]
240    artifact: Option<PathBuf>,
241    /// Operation to invoke (defaults to "invoke")
242    #[arg(long, value_name = "NAME")]
243    operation: Option<String>,
244    /// JSON string payload for the invoke call
245    #[arg(long, value_name = "JSON")]
246    input: Option<String>,
247    /// Path to a JSON file with configuration used for binding
248    #[arg(long, value_name = "FILE")]
249    config: Option<PathBuf>,
250    /// Skip rebuilding the component before running
251    #[arg(long)]
252    skip_build: bool,
253}
254
255pub fn new_component(args: NewComponentArgs) -> Result<()> {
256    let context = TemplateContext::new(&args.name)?;
257    let base_dir = match args.dir {
258        Some(ref dir) if dir.is_absolute() => dir.clone(),
259        Some(dir) => env::current_dir()
260            .with_context(|| "failed to resolve current directory")?
261            .join(dir),
262        None => env::current_dir().with_context(|| "failed to resolve current directory")?,
263    };
264    fs::create_dir_all(&base_dir)
265        .with_context(|| format!("failed to prepare base directory {}", base_dir.display()))?;
266    let component_dir = base_dir.join(context.component_dir());
267
268    if component_dir.exists() {
269        bail!(
270            "component directory `{}` already exists",
271            component_dir.display()
272        );
273    }
274
275    println!(
276        "Creating new component scaffold at `{}`",
277        component_dir.display()
278    );
279
280    create_dir(component_dir.join("src"))?;
281    create_dir(component_dir.join("schemas/v1"))?;
282    create_dir(component_dir.join("wit/deps"))?;
283
284    write_template(
285        &component_dir.join("Cargo.toml"),
286        TEMPLATE_COMPONENT_CARGO,
287        &context,
288    )?;
289    write_template(&component_dir.join("README.md"), TEMPLATE_README, &context)?;
290    write_template(
291        &component_dir.join("provider.toml"),
292        TEMPLATE_PROVIDER,
293        &context,
294    )?;
295    write_template(
296        &component_dir.join("src/lib.rs"),
297        TEMPLATE_SRC_LIB,
298        &context,
299    )?;
300    write_template(
301        &component_dir.join("schemas/v1/config.schema.json"),
302        TEMPLATE_SCHEMA_CONFIG,
303        &context,
304    )?;
305    write_template(
306        &component_dir.join("wit/world.wit"),
307        TEMPLATE_WORLD,
308        &context,
309    )?;
310
311    vendor_wit_packages(&component_dir, &context.versions)?;
312
313    println!(
314        "Component `{}` scaffolded successfully.",
315        context.component_name
316    );
317
318    Ok(())
319}
320
321pub fn validate_command(args: ValidateArgs) -> Result<()> {
322    let report = validate_component(&args.path, !args.skip_build)?;
323    print_validation_summary(&report);
324    Ok(())
325}
326
327pub fn pack_command(args: PackArgs) -> Result<()> {
328    let report = validate_component(&args.path, !args.skip_build)?;
329    let base_out = match args.out_dir {
330        Some(ref dir) if dir.is_absolute() => dir.clone(),
331        Some(ref dir) => report.component_dir.join(dir),
332        None => report.component_dir.join("packs"),
333    };
334    fs::create_dir_all(&base_out)
335        .with_context(|| format!("failed to create {}", base_out.display()))?;
336
337    let dest_dir = base_out
338        .join(&report.provider.name)
339        .join(&report.provider.version);
340    if dest_dir.exists() {
341        fs::remove_dir_all(&dest_dir)
342            .with_context(|| format!("failed to clear {}", dest_dir.display()))?;
343    }
344    fs::create_dir_all(&dest_dir)
345        .with_context(|| format!("failed to create {}", dest_dir.display()))?;
346
347    let artifact_file = format!("{}-{}.wasm", report.provider.name, report.provider.version);
348    let dest_wasm = dest_dir.join(&artifact_file);
349    fs::copy(&report.artifact_path, &dest_wasm).with_context(|| {
350        format!(
351            "failed to copy {} to {}",
352            report.artifact_path.display(),
353            dest_wasm.display()
354        )
355    })?;
356
357    let mut meta = report.provider.clone();
358    meta.artifact.path = artifact_file.clone();
359    meta.artifact.sha256 = report.sha256.clone();
360    meta.artifact.created = OffsetDateTime::now_utc()
361        .format(&Rfc3339)
362        .context("unable to format timestamp")?;
363    meta.abi.wit_packages = report.packages.clone();
364
365    let meta_path = dest_dir.join("meta.json");
366    let meta_file = fs::File::create(&meta_path)
367        .with_context(|| format!("failed to create {}", meta_path.display()))?;
368    serde_json::to_writer_pretty(meta_file, &meta)
369        .with_context(|| format!("failed to write {}", meta_path.display()))?;
370
371    let mut sums =
372        fs::File::create(dest_dir.join("SHA256SUMS")).context("failed to create SHA256SUMS")?;
373    writeln!(sums, "{}  {}", report.sha256, artifact_file).context("failed to write SHA256SUMS")?;
374
375    println!("✓ Packed component at {}", dest_dir.display());
376    Ok(())
377}
378
379pub fn demo_run_command(args: DemoRunArgs) -> Result<()> {
380    let report = validate_component(&args.path, !args.skip_build)?;
381    let artifact_path = match args.artifact {
382        Some(ref path) => resolve_path(&report.component_dir, path),
383        None => report.artifact_path.clone(),
384    };
385
386    let cache_root = report.component_dir.join("target/demo-cache");
387    let store = ComponentStore::new(&cache_root)
388        .with_context(|| format!("failed to initialise cache at {}", cache_root.display()))?;
389    let policy = LoadPolicy::new(Arc::new(store)).with_host_policy(HostPolicy {
390        allow_http_fetch: false,
391        allow_telemetry: true,
392    });
393    let cref = ComponentRef {
394        name: report.provider.name.clone(),
395        locator: artifact_path
396            .canonicalize()
397            .unwrap_or(artifact_path.clone())
398            .display()
399            .to_string(),
400    };
401    let handle =
402        component_runtime::load(&cref, &policy).context("failed to load component into runtime")?;
403    let manifest = component_runtime::describe(&handle).context("failed to describe component")?;
404
405    let operation = args
406        .operation
407        .clone()
408        .unwrap_or_else(|| "invoke".to_string());
409    let available_ops: BTreeSet<_> = manifest
410        .exports
411        .iter()
412        .map(|export| export.operation.clone())
413        .collect();
414    if !available_ops.contains(&operation) {
415        bail!(
416            "component does not export required operation `{}`. Available: {}",
417            operation,
418            available_ops.iter().cloned().collect::<Vec<_>>().join(", ")
419        );
420    }
421
422    let input_value: JsonValue = if let Some(ref input) = args.input {
423        serde_json::from_str(input).context("failed to parse --input JSON")?
424    } else {
425        json!({})
426    };
427
428    let config_value: JsonValue = if let Some(ref cfg) = args.config {
429        let cfg_path = resolve_path(&report.component_dir, cfg);
430        let contents = fs::read_to_string(&cfg_path)
431            .with_context(|| format!("failed to read config {}", cfg_path.display()))?;
432        serde_json::from_str(&contents)
433            .with_context(|| format!("invalid JSON in {}", cfg_path.display()))?
434    } else {
435        json!({})
436    };
437
438    let mut missing_secrets = Vec::new();
439    let mut provided_secrets = Vec::new();
440    for secret in &manifest.secrets {
441        if env::var(secret).is_ok() {
442            provided_secrets.push(secret.clone());
443        } else {
444            missing_secrets.push(secret.clone());
445        }
446    }
447    if !missing_secrets.is_empty() {
448        println!(
449            "warning: secrets not provided via environment variables: {}",
450            missing_secrets.join(", ")
451        );
452    }
453
454    let bindings = Bindings::new(config_value.clone(), provided_secrets);
455    let env = EnvId::new("dev").context("invalid default environment id")?;
456    let tenant_id = TenantId::new("demo").context("invalid default tenant id")?;
457    let tenant = FlowTenantCtx::new(env, tenant_id);
458    let runtime_tenant = flow_ctx_to_runtime_ctx(&tenant)?;
459
460    let mut secret_resolver =
461        |key: &str, _ctx: &RuntimeTenantCtx| -> Result<String, component_runtime::CompError> {
462            match env::var(key) {
463                Ok(value) => Ok(value),
464                Err(_) => Err(component_runtime::CompError::Runtime(format!(
465                    "secret `{key}` not provided; set environment variable `{key}`"
466                ))),
467            }
468        };
469
470    component_runtime::bind(&handle, &runtime_tenant, &bindings, &mut secret_resolver)
471        .context("failed to bind component configuration")?;
472    let output = component_runtime::invoke(&handle, &operation, &input_value, &runtime_tenant)
473        .context("component invocation failed")?;
474
475    println!(
476        "{}",
477        serde_json::to_string_pretty(&output)
478            .context("failed to format invocation result as JSON")?
479    );
480
481    Ok(())
482}
483
484fn flow_ctx_to_runtime_ctx(ctx: &FlowTenantCtx) -> Result<RuntimeTenantCtx> {
485    let serialized = serde_json::to_value(ctx)
486        .context("failed to serialize tenant context for runtime mapping")?;
487    serde_json::from_value(serialized)
488        .context("failed to convert tenant context to runtime-compatible version")
489}
490
491fn create_dir(path: PathBuf) -> Result<()> {
492    fs::create_dir_all(&path)
493        .with_context(|| format!("failed to create directory `{}`", path.display()))
494}
495
496fn write_template(path: &Path, template: &str, context: &TemplateContext) -> Result<()> {
497    if path.exists() {
498        bail!("file `{}` already exists", path.display());
499    }
500
501    let rendered = render_template(template, context);
502    fs::write(path, rendered).with_context(|| format!("failed to write `{}`", path.display()))
503}
504
505fn render_template(template: &str, context: &TemplateContext) -> String {
506    let mut output = template.to_owned();
507    for (key, value) in &context.placeholders {
508        let token = format!("{{{{{key}}}}}");
509        output = output.replace(&token, value);
510    }
511    output
512}
513
514fn vendor_wit_packages(component_dir: &Path, versions: &Versions) -> Result<()> {
515    let deps_dir = component_dir.join("wit/deps");
516    create_dir(deps_dir.clone())?;
517
518    for info in [
519        &versions.component_wit,
520        &versions.host_import_wit,
521        &versions.types_core_wit,
522    ] {
523        let package_name = info
524            .dir
525            .file_name()
526            .ok_or_else(|| anyhow!("invalid wit directory {}", info.dir.display()))?
527            .to_string_lossy()
528            .replace('@', "-");
529        let namespace = info
530            .dir
531            .parent()
532            .and_then(|path| path.file_name())
533            .ok_or_else(|| anyhow!("invalid wit namespace for {}", info.dir.display()))?
534            .to_string_lossy()
535            .into_owned();
536        let dest = deps_dir.join(format!("{namespace}-{package_name}"));
537        copy_dir_recursive(&info.dir, &dest)?;
538    }
539
540    Ok(())
541}
542
543fn detect_wit_package(crate_root: &Path, prefix: &str) -> Result<WitInfo> {
544    let wit_dir = crate_root.join("wit");
545    let namespace_dir = wit_dir.join("greentic");
546    let prefix = format!("{prefix}@");
547
548    let mut best: Option<(Version, PathBuf)> = None;
549    for entry in fs::read_dir(&namespace_dir).with_context(|| {
550        format!(
551            "failed to read namespace directory {}",
552            namespace_dir.display()
553        )
554    })? {
555        let entry = entry?;
556        let path = entry.path();
557        if !path.is_dir() {
558            continue;
559        }
560        let name = entry
561            .file_name()
562            .into_string()
563            .map_err(|_| anyhow!("non-unicode filename under {}", namespace_dir.display()))?;
564        if let Some(rest) = name.strip_prefix(&prefix) {
565            let version = Version::parse(rest)
566                .with_context(|| format!("invalid semver `{rest}` for {prefix}"))?;
567            if best.as_ref().is_none_or(|(current, _)| &version > current) {
568                best = Some((version, path));
569            }
570        }
571    }
572
573    match best {
574        Some((version, dir)) => Ok(WitInfo {
575            version: version.to_string(),
576            dir,
577        }),
578        None => Err(anyhow!(
579            "unable to locate WIT package `{}` under {}",
580            prefix,
581            namespace_dir.display()
582        )),
583    }
584}
585
586#[derive(Deserialize)]
587struct LockPackage {
588    name: String,
589    version: String,
590}
591
592#[derive(Deserialize)]
593struct LockFile {
594    package: Vec<LockPackage>,
595}
596
597fn resolved_version(crate_name: &str) -> Result<String> {
598    let lock_path = WORKSPACE_ROOT.join("Cargo.lock");
599    let contents = fs::read_to_string(&lock_path)
600        .with_context(|| format!("failed to read {}", lock_path.display()))?;
601    let lock: LockFile =
602        toml::from_str(&contents).with_context(|| format!("invalid {}", lock_path.display()))?;
603
604    let mut best: Option<(Version, String)> = None;
605    for pkg in lock
606        .package
607        .into_iter()
608        .filter(|pkg| pkg.name == crate_name)
609    {
610        let version = Version::parse(&pkg.version)
611            .with_context(|| format!("invalid semver `{}` for {}", pkg.version, crate_name))?;
612        if best.as_ref().is_none_or(|(current, _)| &version > current) {
613            best = Some((version, pkg.version));
614        }
615    }
616
617    match best {
618        Some((_, version)) => Ok(version),
619        None => Err(anyhow!(
620            "crate `{}` not found in {}",
621            crate_name,
622            lock_path.display()
623        )),
624    }
625}
626
627fn cargo_home() -> Result<PathBuf> {
628    if let Ok(path) = env::var("CARGO_HOME") {
629        return Ok(PathBuf::from(path));
630    }
631    if let Ok(home) = env::var("HOME") {
632        return Ok(PathBuf::from(home).join(".cargo"));
633    }
634    Err(anyhow!(
635        "unable to determine CARGO_HOME; set the environment variable explicitly"
636    ))
637}
638
639fn find_crate_source(crate_name: &str, version: &str) -> Result<PathBuf> {
640    let home = cargo_home()?;
641    let registry_src = home.join("registry/src");
642    if !registry_src.exists() {
643        return Err(anyhow!(
644            "cargo registry src directory not found at {}",
645            registry_src.display()
646        ));
647    }
648
649    for index in fs::read_dir(&registry_src)? {
650        let index_path = index?.path();
651        if !index_path.is_dir() {
652            continue;
653        }
654        let candidate = index_path.join(format!("{crate_name}-{version}"));
655        if candidate.exists() {
656            return Ok(candidate);
657        }
658    }
659
660    Err(anyhow!(
661        "crate `{}` version `{}` not found under {}",
662        crate_name,
663        version,
664        registry_src.display()
665    ))
666}
667
668fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
669    if dest.exists() {
670        fs::remove_dir_all(dest).with_context(|| format!("failed to remove {}", dest.display()))?;
671    }
672    fs::create_dir_all(dest).with_context(|| format!("failed to create {}", dest.display()))?;
673    for entry in
674        fs::read_dir(src).with_context(|| format!("failed to read directory {}", src.display()))?
675    {
676        let entry = entry?;
677        let src_path = entry.path();
678        let dest_path = dest.join(entry.file_name());
679        if src_path.is_dir() {
680            copy_dir_recursive(&src_path, &dest_path)?;
681        } else {
682            fs::copy(&src_path, &dest_path).with_context(|| {
683                format!(
684                    "failed to copy {} to {}",
685                    src_path.display(),
686                    dest_path.display()
687                )
688            })?;
689        }
690    }
691    Ok(())
692}
693
694struct TemplateContext {
695    component_name: String,
696    component_kebab: String,
697    versions: Versions,
698    placeholders: HashMap<String, String>,
699}
700
701impl TemplateContext {
702    fn new(raw: &str) -> Result<Self> {
703        let trimmed = raw.trim();
704        if trimmed.is_empty() {
705            bail!("component name cannot be empty");
706        }
707
708        let component_kebab = trimmed.to_case(Case::Kebab);
709        let component_snake = trimmed.to_case(Case::Snake);
710        let component_pascal = trimmed.to_case(Case::Pascal);
711        let component_name = component_kebab.clone();
712        let versions = VERSIONS.clone();
713
714        let mut placeholders = HashMap::new();
715        placeholders.insert("component_name".into(), component_name.clone());
716        placeholders.insert("component_kebab".into(), component_kebab.clone());
717        placeholders.insert("component_snake".into(), component_snake.clone());
718        placeholders.insert("component_pascal".into(), component_pascal.clone());
719        placeholders.insert("component_crate".into(), component_kebab.clone());
720        placeholders.insert(
721            "component_dir".into(),
722            format!("component-{component_kebab}"),
723        );
724        placeholders.insert("interfaces_version".into(), versions.interfaces.clone());
725        placeholders.insert("types_version".into(), versions.types.clone());
726        placeholders.insert(
727            "component_runtime_version".into(),
728            versions.component_runtime.clone(),
729        );
730        placeholders.insert(
731            "component_world_version".into(),
732            versions.component_wit.version.clone(),
733        );
734        placeholders.insert(
735            "host_import_version".into(),
736            versions.host_import_wit.version.clone(),
737        );
738        placeholders.insert(
739            "types_core_version".into(),
740            versions.types_core_wit.version.clone(),
741        );
742
743        Ok(Self {
744            component_name,
745            component_kebab,
746            versions,
747            placeholders,
748        })
749    }
750
751    fn component_dir(&self) -> String {
752        format!("component-{}", self.component_kebab)
753    }
754}
755
756fn print_validation_summary(report: &ValidationReport) {
757    println!(
758        "✓ Validated {} {}",
759        report.provider.name, report.provider.version
760    );
761    println!("  artifact: {}", report.artifact_path.display());
762    println!("  sha256 : {}", report.sha256);
763    println!("  world  : {}", report.world);
764    println!("  packages:");
765    for pkg in &report.packages {
766        println!("    - {pkg}");
767    }
768    if let Some(manifest) = &report.manifest {
769        println!("  exports:");
770        for export in &manifest.exports {
771            println!("    - {}", export.operation);
772        }
773    } else {
774        println!("  exports: <skipped - missing WASI host support>");
775    }
776}
777
778fn validate_component(path: &Path, build: bool) -> Result<ValidationReport> {
779    let component_dir = resolve_component_dir(path)?;
780
781    if build {
782        ensure_cargo_component_installed()?;
783        run_cargo_component_build(&component_dir)?;
784    }
785
786    let provider_path = component_dir.join("provider.toml");
787    let provider = load_provider(&provider_path)?;
788
789    let versions = Versions::load()?;
790    ensure_version_alignment(&provider, &versions)?;
791
792    let mut attempted = Vec::new();
793    let mut artifact_path = None;
794    for candidate in candidate_artifact_paths(&provider.artifact.path) {
795        let resolved = resolve_path(&component_dir, Path::new(&candidate));
796        attempted.push(resolved.clone());
797        if resolved.exists() {
798            artifact_path = Some(resolved);
799            break;
800        }
801    }
802    let artifact_path = match artifact_path {
803        Some(path) => path,
804        None => {
805            let paths = attempted
806                .into_iter()
807                .map(|p| p.display().to_string())
808                .collect::<Vec<_>>()
809                .join(", ");
810            bail!("artifact path not found; checked {paths}");
811        }
812    };
813
814    let wasm_bytes = fs::read(&artifact_path)
815        .with_context(|| format!("failed to read {}", artifact_path.display()))?;
816    let sha256 = format!("{:x}", Sha256::digest(&wasm_bytes));
817
818    let decoded = decode_component(&wasm_bytes).context("failed to decode component")?;
819    let (resolve, world_id) = match decoded {
820        DecodedWasm::Component(resolve, world) => (resolve, world),
821        DecodedWasm::WitPackage(_, _) => {
822            bail!("expected a component artifact but found a WIT package bundle")
823        }
824    };
825    let (packages, world, export_package) = extract_wit_metadata(&resolve, world_id)?;
826
827    if packages.is_empty() {
828        bail!("no WIT packages embedded in component artifact");
829    }
830
831    if provider.abi.world != world {
832        if let Some(expected_pkg) = world_to_package_id(&provider.abi.world) {
833            if let Some(actual_pkg) = export_package {
834                if actual_pkg != expected_pkg {
835                    bail!(
836                        "provider world `{}` expects package '{}', but embedded exports use '{}'",
837                        provider.abi.world,
838                        expected_pkg,
839                        actual_pkg
840                    );
841                }
842            } else if !packages.iter().any(|pkg| pkg == &expected_pkg) {
843                bail!(
844                    "provider world `{}` expects package '{}', which was not embedded (found {:?})",
845                    provider.abi.world,
846                    expected_pkg,
847                    packages
848                );
849            }
850        } else {
851            bail!(
852                "provider world `{}` is not formatted as <namespace>:<package>/<world>@<version>",
853                provider.abi.world
854            );
855        }
856    }
857
858    let expected_packages: BTreeSet<_> = provider.abi.wit_packages.iter().cloned().collect();
859    if !expected_packages.is_empty() {
860        let actual_greentic: BTreeSet<_> = packages
861            .iter()
862            .filter(|pkg| pkg.starts_with("greentic:"))
863            .cloned()
864            .collect();
865        if !expected_packages.is_subset(&actual_greentic) {
866            bail!(
867                "provider wit_packages {expected_packages:?} not satisfied by embedded packages \
868                 {actual_greentic:?}"
869            );
870        }
871    }
872
873    let cache_root = component_dir.join("target/component-cache");
874    let store = ComponentStore::new(&cache_root)
875        .with_context(|| format!("failed to initialise cache at {}", cache_root.display()))?;
876    let policy = LoadPolicy::new(Arc::new(store)).with_host_policy(HostPolicy {
877        allow_http_fetch: false,
878        allow_telemetry: true,
879    });
880    let cref = ComponentRef {
881        name: provider.name.clone(),
882        locator: artifact_path
883            .canonicalize()
884            .unwrap_or(artifact_path.clone())
885            .display()
886            .to_string(),
887    };
888    let manifest = match component_runtime::load(&cref, &policy) {
889        Ok(handle) => {
890            let manifest = component_runtime::describe(&handle)
891                .context("failed to inspect component manifest")?;
892            validate_exports(&provider, &manifest)?;
893            validate_capabilities(&provider, &manifest)?;
894            Some(manifest)
895        }
896        Err(component_runtime::CompError::Wasmtime(wasmtime_err)) => {
897            let msg = wasmtime_err.to_string();
898            if msg.contains("wasi:") {
899                println!(
900                    "warning: skipping runtime manifest validation due to missing WASI host support: {msg}"
901                );
902                None
903            } else {
904                return Err(component_runtime::CompError::Wasmtime(wasmtime_err).into());
905            }
906        }
907        Err(other) => return Err(other.into()),
908    };
909
910    Ok(ValidationReport {
911        provider,
912        component_dir,
913        artifact_path,
914        sha256,
915        world,
916        packages,
917        manifest,
918    })
919}
920
921fn resolve_component_dir(path: &Path) -> Result<PathBuf> {
922    let dir = if path.is_absolute() {
923        path.to_path_buf()
924    } else {
925        env::current_dir()
926            .context("unable to determine current directory")?
927            .join(path)
928    };
929    dir.canonicalize()
930        .with_context(|| format!("failed to canonicalize {}", dir.display()))
931}
932
933fn resolve_path(base: &Path, raw: impl AsRef<Path>) -> PathBuf {
934    let raw_path = raw.as_ref();
935    if raw_path.is_absolute() {
936        raw_path.to_path_buf()
937    } else {
938        base.join(raw_path)
939    }
940}
941
942fn candidate_artifact_paths(original: &str) -> Vec<String> {
943    let mut paths = Vec::new();
944    paths.push(original.to_string());
945
946    for (from, to) in [
947        ("wasm32-wasip2", "wasm32-wasip1"),
948        ("wasm32-wasip2", "wasm32-wasi"),
949        ("wasm32-wasip1", "wasm32-wasip2"),
950        ("wasm32-wasip1", "wasm32-wasi"),
951        ("wasm32-wasi", "wasm32-wasip2"),
952        ("wasm32-wasi", "wasm32-wasip1"),
953    ] {
954        if original.contains(from) {
955            let candidate = original.replace(from, to);
956            if candidate != original && !paths.contains(&candidate) {
957                paths.push(candidate);
958            }
959        }
960    }
961
962    paths
963}
964
965fn ensure_cargo_component_installed() -> Result<()> {
966    let status = Command::new("cargo")
967        .arg("component")
968        .arg("--version")
969        .status();
970    match status {
971        Ok(status) if status.success() => Ok(()),
972        Ok(_) => bail!(
973            "cargo-component is required. Install with `cargo install cargo-component --locked`."
974        ),
975        Err(err) => Err(anyhow!(
976            "failed to execute `cargo component --version`: {err}. Install cargo-component with `cargo install cargo-component --locked`."
977        )),
978    }
979}
980
981fn run_cargo_component_build(component_dir: &Path) -> Result<()> {
982    let cache_dir = component_dir.join("target").join(".component-cache");
983    let status = Command::new("cargo")
984        .current_dir(component_dir)
985        .arg("component")
986        .arg("build")
987        .arg("--release")
988        .arg("--target")
989        .arg("wasm32-wasip2")
990        .env("CARGO_COMPONENT_CACHE_DIR", cache_dir.as_os_str())
991        .env("CARGO_NET_OFFLINE", "true")
992        .status()
993        .with_context(|| {
994            format!(
995                "failed to run `cargo component build` in {}",
996                component_dir.display()
997            )
998        })?;
999    if status.success() {
1000        Ok(())
1001    } else {
1002        bail!("cargo component build failed")
1003    }
1004}
1005
1006fn load_provider(path: &Path) -> Result<ProviderMetadata> {
1007    let contents = fs::read_to_string(path)
1008        .with_context(|| format!("failed to read provider metadata {}", path.display()))?;
1009    let provider: ProviderMetadata =
1010        toml::from_str(&contents).context("provider.toml is not valid TOML")?;
1011    if provider.artifact.format != "wasm-component" {
1012        bail!(
1013            "artifact.format must be `wasm-component`, found `{}`",
1014            provider.artifact.format
1015        );
1016    }
1017    Ok(provider)
1018}
1019
1020fn ensure_version_alignment(provider: &ProviderMetadata, versions: &Versions) -> Result<()> {
1021    if provider.abi.interfaces_version != versions.interfaces {
1022        bail!(
1023            "provider abi.interfaces_version `{}` does not match pinned `{}`",
1024            provider.abi.interfaces_version,
1025            versions.interfaces
1026        );
1027    }
1028    if provider.abi.types_version != versions.types {
1029        bail!(
1030            "provider abi.types_version `{}` does not match pinned `{}`",
1031            provider.abi.types_version,
1032            versions.types
1033        );
1034    }
1035    if provider.abi.component_runtime != versions.component_runtime {
1036        bail!(
1037            "provider abi.component_runtime `{}` does not match pinned `{}`",
1038            provider.abi.component_runtime,
1039            versions.component_runtime
1040        );
1041    }
1042    Ok(())
1043}
1044
1045fn extract_wit_metadata(
1046    resolve: &Resolve,
1047    world_id: WorldId,
1048) -> Result<(Vec<String>, String, Option<String>)> {
1049    let mut packages = Vec::new();
1050    for (_, package) in resolve.packages.iter() {
1051        let name = &package.name;
1052        if name.namespace == "root" {
1053            continue;
1054        }
1055        if let Some(version) = &name.version {
1056            packages.push(format!("{}:{}@{}", name.namespace, name.name, version));
1057        } else {
1058            packages.push(format!("{}:{}", name.namespace, name.name));
1059        }
1060    }
1061    packages.sort();
1062    packages.dedup();
1063
1064    let world = &resolve.worlds[world_id];
1065    let mut export_package = None;
1066    for item in world.exports.values() {
1067        if let WorldItem::Interface { id, .. } = item {
1068            let iface = &resolve.interfaces[*id];
1069            if let Some(pkg_id) = iface.package {
1070                let pkg = &resolve.packages[pkg_id].name;
1071                if pkg.namespace != "root" {
1072                    let mut ident = format!("{}:{}", pkg.namespace, pkg.name);
1073                    if let Some(version) = &pkg.version {
1074                        ident.push('@');
1075                        ident.push_str(&version.to_string());
1076                    }
1077                    export_package.get_or_insert(ident);
1078                }
1079            }
1080        }
1081    }
1082
1083    let world_string = if let Some(pkg_id) = world.package {
1084        let pkg = &resolve.packages[pkg_id];
1085        if let Some(version) = &pkg.name.version {
1086            format!(
1087                "{}:{}/{}@{}",
1088                pkg.name.namespace, pkg.name.name, world.name, version
1089            )
1090        } else {
1091            format!("{}:{}/{}", pkg.name.namespace, pkg.name.name, world.name)
1092        }
1093    } else {
1094        world.name.clone()
1095    };
1096
1097    Ok((packages, world_string, export_package))
1098}
1099
1100fn world_to_package_id(world: &str) -> Option<String> {
1101    let (pkg_part, rest) = world.split_once('/')?;
1102    let (_, version) = rest.rsplit_once('@')?;
1103    Some(format!("{pkg_part}@{version}"))
1104}
1105
1106fn validate_exports(provider: &ProviderMetadata, manifest: &ComponentManifestInfo) -> Result<()> {
1107    let actual: BTreeSet<_> = manifest
1108        .exports
1109        .iter()
1110        .map(|export| export.operation.clone())
1111        .collect();
1112    for required in &provider.exports.provides {
1113        if !actual.contains(required) {
1114            bail!("component manifest is missing required export `{required}`");
1115        }
1116    }
1117    Ok(())
1118}
1119
1120fn validate_capabilities(
1121    provider: &ProviderMetadata,
1122    manifest: &ComponentManifestInfo,
1123) -> Result<()> {
1124    let actual: BTreeSet<_> = manifest
1125        .capabilities
1126        .iter()
1127        .map(|cap| cap.as_str().to_string())
1128        .collect();
1129    for (name, required) in [
1130        ("secrets", provider.capabilities.secrets),
1131        ("telemetry", provider.capabilities.telemetry),
1132        ("network", provider.capabilities.network),
1133        ("filesystem", provider.capabilities.filesystem),
1134    ] {
1135        if required && !actual.contains(name) {
1136            bail!(
1137                "provider declares capability `{name}` but component manifest does not expose it"
1138            );
1139        }
1140    }
1141    Ok(())
1142}