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    "/src/templates/component/Cargo.toml.in"
33));
34const TEMPLATE_SRC_LIB: &str = include_str!(concat!(
35    env!("CARGO_MANIFEST_DIR"),
36    "/src/templates/component/src/lib.rs"
37));
38const TEMPLATE_PROVIDER: &str = include_str!(concat!(
39    env!("CARGO_MANIFEST_DIR"),
40    "/src/templates/component/provider.toml"
41));
42const TEMPLATE_SCHEMA_CONFIG: &str = include_str!(concat!(
43    env!("CARGO_MANIFEST_DIR"),
44    "/src/templates/component/schemas/v1/config.schema.json"
45));
46const TEMPLATE_README: &str = include_str!(concat!(
47    env!("CARGO_MANIFEST_DIR"),
48    "/src/templates/component/README.md"
49));
50const TEMPLATE_WORLD: &str = include_str!(concat!(
51    env!("CARGO_MANIFEST_DIR"),
52    "/src/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 `{}` not provided; set environment variable `{}`",
465                    key, 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 `{}` for {}", rest, 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 {:?} not satisfied by embedded packages {:?}",
868                expected_packages,
869                actual_greentic
870            );
871        }
872    }
873
874    let cache_root = component_dir.join("target/component-cache");
875    let store = ComponentStore::new(&cache_root)
876        .with_context(|| format!("failed to initialise cache at {}", cache_root.display()))?;
877    let policy = LoadPolicy::new(Arc::new(store)).with_host_policy(HostPolicy {
878        allow_http_fetch: false,
879        allow_telemetry: true,
880    });
881    let cref = ComponentRef {
882        name: provider.name.clone(),
883        locator: artifact_path
884            .canonicalize()
885            .unwrap_or(artifact_path.clone())
886            .display()
887            .to_string(),
888    };
889    let manifest = match component_runtime::load(&cref, &policy) {
890        Ok(handle) => {
891            let manifest = component_runtime::describe(&handle)
892                .context("failed to inspect component manifest")?;
893            validate_exports(&provider, &manifest)?;
894            validate_capabilities(&provider, &manifest)?;
895            Some(manifest)
896        }
897        Err(component_runtime::CompError::Wasmtime(wasmtime_err)) => {
898            let msg = wasmtime_err.to_string();
899            if msg.contains("wasi:") {
900                println!(
901                    "warning: skipping runtime manifest validation due to missing WASI host support: {}",
902                    msg
903                );
904                None
905            } else {
906                return Err(component_runtime::CompError::Wasmtime(wasmtime_err).into());
907            }
908        }
909        Err(other) => return Err(other.into()),
910    };
911
912    Ok(ValidationReport {
913        provider,
914        component_dir,
915        artifact_path,
916        sha256,
917        world,
918        packages,
919        manifest,
920    })
921}
922
923fn resolve_component_dir(path: &Path) -> Result<PathBuf> {
924    let dir = if path.is_absolute() {
925        path.to_path_buf()
926    } else {
927        env::current_dir()
928            .context("unable to determine current directory")?
929            .join(path)
930    };
931    dir.canonicalize()
932        .with_context(|| format!("failed to canonicalize {}", dir.display()))
933}
934
935fn resolve_path(base: &Path, raw: impl AsRef<Path>) -> PathBuf {
936    let raw_path = raw.as_ref();
937    if raw_path.is_absolute() {
938        raw_path.to_path_buf()
939    } else {
940        base.join(raw_path)
941    }
942}
943
944fn candidate_artifact_paths(original: &str) -> Vec<String> {
945    let mut paths = Vec::new();
946    paths.push(original.to_string());
947
948    for (from, to) in [
949        ("wasm32-wasip2", "wasm32-wasip1"),
950        ("wasm32-wasip2", "wasm32-wasi"),
951        ("wasm32-wasip1", "wasm32-wasip2"),
952        ("wasm32-wasip1", "wasm32-wasi"),
953        ("wasm32-wasi", "wasm32-wasip2"),
954        ("wasm32-wasi", "wasm32-wasip1"),
955    ] {
956        if original.contains(from) {
957            let candidate = original.replace(from, to);
958            if candidate != original && !paths.contains(&candidate) {
959                paths.push(candidate);
960            }
961        }
962    }
963
964    paths
965}
966
967fn ensure_cargo_component_installed() -> Result<()> {
968    let status = Command::new("cargo")
969        .arg("component")
970        .arg("--version")
971        .status();
972    match status {
973        Ok(status) if status.success() => Ok(()),
974        Ok(_) => bail!(
975            "cargo-component is required. Install with `cargo install cargo-component --locked`."
976        ),
977        Err(err) => Err(anyhow!(
978            "failed to execute `cargo component --version`: {err}. Install cargo-component with `cargo install cargo-component --locked`."
979        )),
980    }
981}
982
983fn run_cargo_component_build(component_dir: &Path) -> Result<()> {
984    let cache_dir = component_dir.join("target").join(".component-cache");
985    let status = Command::new("cargo")
986        .current_dir(component_dir)
987        .arg("component")
988        .arg("build")
989        .arg("--release")
990        .arg("--target")
991        .arg("wasm32-wasip2")
992        .env("CARGO_COMPONENT_CACHE_DIR", cache_dir.as_os_str())
993        .env("CARGO_NET_OFFLINE", "true")
994        .status()
995        .with_context(|| {
996            format!(
997                "failed to run `cargo component build` in {}",
998                component_dir.display()
999            )
1000        })?;
1001    if status.success() {
1002        Ok(())
1003    } else {
1004        bail!("cargo component build failed")
1005    }
1006}
1007
1008fn load_provider(path: &Path) -> Result<ProviderMetadata> {
1009    let contents = fs::read_to_string(path)
1010        .with_context(|| format!("failed to read provider metadata {}", path.display()))?;
1011    let provider: ProviderMetadata =
1012        toml::from_str(&contents).context("provider.toml is not valid TOML")?;
1013    if provider.artifact.format != "wasm-component" {
1014        bail!(
1015            "artifact.format must be `wasm-component`, found `{}`",
1016            provider.artifact.format
1017        );
1018    }
1019    Ok(provider)
1020}
1021
1022fn ensure_version_alignment(provider: &ProviderMetadata, versions: &Versions) -> Result<()> {
1023    if provider.abi.interfaces_version != versions.interfaces {
1024        bail!(
1025            "provider abi.interfaces_version `{}` does not match pinned `{}`",
1026            provider.abi.interfaces_version,
1027            versions.interfaces
1028        );
1029    }
1030    if provider.abi.types_version != versions.types {
1031        bail!(
1032            "provider abi.types_version `{}` does not match pinned `{}`",
1033            provider.abi.types_version,
1034            versions.types
1035        );
1036    }
1037    if provider.abi.component_runtime != versions.component_runtime {
1038        bail!(
1039            "provider abi.component_runtime `{}` does not match pinned `{}`",
1040            provider.abi.component_runtime,
1041            versions.component_runtime
1042        );
1043    }
1044    Ok(())
1045}
1046
1047fn extract_wit_metadata(
1048    resolve: &Resolve,
1049    world_id: WorldId,
1050) -> Result<(Vec<String>, String, Option<String>)> {
1051    let mut packages = Vec::new();
1052    for (_, package) in resolve.packages.iter() {
1053        let name = &package.name;
1054        if name.namespace == "root" {
1055            continue;
1056        }
1057        if let Some(version) = &name.version {
1058            packages.push(format!("{}:{}@{}", name.namespace, name.name, version));
1059        } else {
1060            packages.push(format!("{}:{}", name.namespace, name.name));
1061        }
1062    }
1063    packages.sort();
1064    packages.dedup();
1065
1066    let world = &resolve.worlds[world_id];
1067    let mut export_package = None;
1068    for item in world.exports.values() {
1069        if let WorldItem::Interface { id, .. } = item {
1070            let iface = &resolve.interfaces[*id];
1071            if let Some(pkg_id) = iface.package {
1072                let pkg = &resolve.packages[pkg_id].name;
1073                if pkg.namespace != "root" {
1074                    let mut ident = format!("{}:{}", pkg.namespace, pkg.name);
1075                    if let Some(version) = &pkg.version {
1076                        ident.push('@');
1077                        ident.push_str(&version.to_string());
1078                    }
1079                    export_package.get_or_insert(ident);
1080                }
1081            }
1082        }
1083    }
1084
1085    let world_string = if let Some(pkg_id) = world.package {
1086        let pkg = &resolve.packages[pkg_id];
1087        if let Some(version) = &pkg.name.version {
1088            format!(
1089                "{}:{}/{}@{}",
1090                pkg.name.namespace, pkg.name.name, world.name, version
1091            )
1092        } else {
1093            format!("{}:{}/{}", pkg.name.namespace, pkg.name.name, world.name)
1094        }
1095    } else {
1096        world.name.clone()
1097    };
1098
1099    Ok((packages, world_string, export_package))
1100}
1101
1102fn world_to_package_id(world: &str) -> Option<String> {
1103    let (pkg_part, rest) = world.split_once('/')?;
1104    let (_, version) = rest.rsplit_once('@')?;
1105    Some(format!("{}@{}", pkg_part, version))
1106}
1107
1108fn validate_exports(provider: &ProviderMetadata, manifest: &ComponentManifestInfo) -> Result<()> {
1109    let actual: BTreeSet<_> = manifest
1110        .exports
1111        .iter()
1112        .map(|export| export.operation.clone())
1113        .collect();
1114    for required in &provider.exports.provides {
1115        if !actual.contains(required) {
1116            bail!(
1117                "component manifest is missing required export `{}`",
1118                required
1119            );
1120        }
1121    }
1122    Ok(())
1123}
1124
1125fn validate_capabilities(
1126    provider: &ProviderMetadata,
1127    manifest: &ComponentManifestInfo,
1128) -> Result<()> {
1129    let actual: BTreeSet<_> = manifest
1130        .capabilities
1131        .iter()
1132        .map(|cap| cap.as_str().to_string())
1133        .collect();
1134    for (name, required) in [
1135        ("secrets", provider.capabilities.secrets),
1136        ("telemetry", provider.capabilities.telemetry),
1137        ("network", provider.capabilities.network),
1138        ("filesystem", provider.capabilities.filesystem),
1139    ] {
1140        if required && !actual.contains(name) {
1141            bail!(
1142                "provider declares capability `{}` but component manifest does not expose it",
1143                name
1144            );
1145        }
1146    }
1147    Ok(())
1148}