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