greentic_dev/
component_cli.rs

1use anyhow::{Context, Result, anyhow, bail};
2use clap::{Args, Subcommand};
3use convert_case::{Case, Casing};
4use once_cell::sync::Lazy;
5use semver::Version;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::collections::{BTreeSet, HashMap};
9use std::env;
10use std::fs;
11use std::io::Write;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14use time::OffsetDateTime;
15use time::format_description::well_known::Rfc3339;
16use wit_component::{DecodedWasm, decode as decode_component};
17use wit_parser::{Resolve, WorldId, WorldItem};
18
19static WORKSPACE_ROOT: Lazy<PathBuf> = Lazy::new(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")));
20
21const TEMPLATE_COMPONENT_CARGO: &str = include_str!(concat!(
22    env!("CARGO_MANIFEST_DIR"),
23    "/templates/component/Cargo.toml.in"
24));
25const TEMPLATE_SRC_LIB: &str = include_str!(concat!(
26    env!("CARGO_MANIFEST_DIR"),
27    "/templates/component/src/lib.rs"
28));
29const TEMPLATE_PROVIDER: &str = include_str!(concat!(
30    env!("CARGO_MANIFEST_DIR"),
31    "/templates/component/provider.toml"
32));
33const TEMPLATE_SCHEMA_CONFIG: &str = include_str!(concat!(
34    env!("CARGO_MANIFEST_DIR"),
35    "/templates/component/schemas/v1/config.schema.json"
36));
37const TEMPLATE_README: &str = include_str!(concat!(
38    env!("CARGO_MANIFEST_DIR"),
39    "/templates/component/README.md"
40));
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43struct ProviderMetadata {
44    name: String,
45    version: String,
46    #[serde(default)]
47    description: Option<String>,
48    #[serde(default)]
49    license: Option<String>,
50    #[serde(default)]
51    homepage: Option<String>,
52    abi: AbiSection,
53    capabilities: CapabilitiesSection,
54    exports: ExportsSection,
55    #[serde(default)]
56    imports: ImportsSection,
57    artifact: ArtifactSection,
58    #[serde(default)]
59    docs: Option<DocsSection>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63struct AbiSection {
64    interfaces_version: String,
65    types_version: String,
66    component_runtime: String,
67    world: String,
68    #[serde(default)]
69    wit_packages: Vec<String>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73struct CapabilitiesSection {
74    #[serde(default)]
75    secrets: bool,
76    #[serde(default)]
77    telemetry: bool,
78    #[serde(default)]
79    network: bool,
80    #[serde(default)]
81    filesystem: bool,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85struct ExportsSection {
86    #[serde(default)]
87    provides: Vec<String>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, Default)]
91struct ImportsSection {
92    #[serde(default)]
93    requires: Vec<String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97struct ArtifactSection {
98    format: String,
99    path: String,
100    #[serde(default)]
101    sha256: String,
102    #[serde(default)]
103    created: String,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, Default)]
107struct DocsSection {
108    #[serde(default)]
109    readme: Option<String>,
110    #[serde(default)]
111    schemas: Vec<String>,
112}
113
114#[derive(Debug)]
115struct ValidationReport {
116    provider: ProviderMetadata,
117    component_dir: PathBuf,
118    artifact_path: PathBuf,
119    sha256: String,
120    world: String,
121    packages: Vec<String>,
122}
123
124#[derive(Debug, Clone)]
125struct Versions {
126    interfaces: String,
127    types: String,
128    component_runtime: String,
129    component_wit_version: String,
130    secrets_wit_version: String,
131    state_wit_version: String,
132    http_wit_version: String,
133    telemetry_wit_version: String,
134}
135
136impl Versions {
137    fn load() -> Result<Self> {
138        let interfaces_version = resolved_version("greentic-interfaces")?;
139        let types_version = resolved_version("greentic-types")?;
140        let component_runtime_version = resolved_version("greentic-component")?;
141
142        let interfaces_root = find_crate_source("greentic-interfaces", &interfaces_version)?;
143        let component_wit_version = detect_component_node_world_version(&interfaces_root)?;
144        let secrets_wit_version = detect_wit_package_version(&interfaces_root, "secrets")?;
145        let state_wit_version = detect_wit_package_version(&interfaces_root, "state")?;
146        let http_wit_version = detect_wit_package_version(&interfaces_root, "http")?;
147        let telemetry_wit_version = detect_wit_package_version(&interfaces_root, "telemetry")?;
148
149        Ok(Self {
150            interfaces: interfaces_version,
151            types: types_version,
152            component_runtime: component_runtime_version,
153            component_wit_version,
154            secrets_wit_version,
155            state_wit_version,
156            http_wit_version,
157            telemetry_wit_version,
158        })
159    }
160}
161
162static VERSIONS: Lazy<Versions> =
163    Lazy::new(|| Versions::load().expect("load greentic crate versions"));
164
165pub fn run_component_command(command: ComponentCommands) -> Result<()> {
166    match command {
167        ComponentCommands::New(args) => new_component(args),
168        ComponentCommands::Validate(args) => validate_command(args),
169        ComponentCommands::Pack(args) => pack_command(args),
170    }
171}
172
173#[derive(Subcommand, Debug, Clone)]
174pub enum ComponentCommands {
175    /// Scaffold a new component repository
176    New(NewComponentArgs),
177    /// Build and validate a component against pinned interfaces
178    Validate(ValidateArgs),
179    /// Package a component into `packs/<name>/<version>`
180    Pack(PackArgs),
181}
182
183#[derive(Args, Debug, Clone)]
184pub struct NewComponentArgs {
185    /// Name of the component (kebab-case recommended)
186    name: String,
187    /// Optional directory where the component should be created
188    #[arg(long, value_name = "DIR")]
189    dir: Option<PathBuf>,
190}
191
192#[derive(Args, Debug, Clone)]
193pub struct ValidateArgs {
194    /// Path to the component directory
195    #[arg(long, value_name = "PATH", default_value = ".")]
196    path: PathBuf,
197    /// Skip cargo component build (use the existing artifact)
198    #[arg(long)]
199    skip_build: bool,
200}
201
202#[derive(Args, Debug, Clone)]
203pub struct PackArgs {
204    /// Path to the component directory
205    #[arg(long, value_name = "PATH", default_value = ".")]
206    path: PathBuf,
207    /// Output directory for generated packs (defaults to `<component>/packs`)
208    #[arg(long, value_name = "DIR")]
209    out_dir: Option<PathBuf>,
210    /// Skip cargo component build before packing
211    #[arg(long)]
212    skip_build: bool,
213}
214
215pub fn new_component(args: NewComponentArgs) -> Result<()> {
216    let context = TemplateContext::new(&args.name)?;
217    let base_dir = match args.dir {
218        Some(ref dir) if dir.is_absolute() => dir.clone(),
219        Some(dir) => env::current_dir()
220            .with_context(|| "failed to resolve current directory")?
221            .join(dir),
222        None => env::current_dir().with_context(|| "failed to resolve current directory")?,
223    };
224    fs::create_dir_all(&base_dir)
225        .with_context(|| format!("failed to prepare base directory {}", base_dir.display()))?;
226    let component_dir = base_dir.join(context.component_dir());
227
228    if component_dir.exists() {
229        bail!(
230            "component directory `{}` already exists",
231            component_dir.display()
232        );
233    }
234
235    println!(
236        "Creating new component scaffold at `{}`",
237        component_dir.display()
238    );
239
240    create_dir(component_dir.join("src"))?;
241    create_dir(component_dir.join("schemas/v1"))?;
242
243    write_template(
244        &component_dir.join("Cargo.toml"),
245        TEMPLATE_COMPONENT_CARGO,
246        &context,
247    )?;
248    write_template(&component_dir.join("README.md"), TEMPLATE_README, &context)?;
249    write_template(
250        &component_dir.join("provider.toml"),
251        TEMPLATE_PROVIDER,
252        &context,
253    )?;
254    write_template(
255        &component_dir.join("src/lib.rs"),
256        TEMPLATE_SRC_LIB,
257        &context,
258    )?;
259    write_template(
260        &component_dir.join("schemas/v1/config.schema.json"),
261        TEMPLATE_SCHEMA_CONFIG,
262        &context,
263    )?;
264
265    println!(
266        "Component `{}` scaffolded successfully.",
267        context.component_name
268    );
269
270    Ok(())
271}
272
273pub fn validate_command(args: ValidateArgs) -> Result<()> {
274    let report = validate_component(&args.path, !args.skip_build)?;
275    print_validation_summary(&report);
276    Ok(())
277}
278
279pub fn pack_command(args: PackArgs) -> Result<()> {
280    let report = validate_component(&args.path, !args.skip_build)?;
281    let base_out = match args.out_dir {
282        Some(ref dir) if dir.is_absolute() => dir.clone(),
283        Some(ref dir) => report.component_dir.join(dir),
284        None => report.component_dir.join("packs"),
285    };
286    fs::create_dir_all(&base_out)
287        .with_context(|| format!("failed to create {}", base_out.display()))?;
288
289    let dest_dir = base_out
290        .join(&report.provider.name)
291        .join(&report.provider.version);
292    if dest_dir.exists() {
293        fs::remove_dir_all(&dest_dir)
294            .with_context(|| format!("failed to clear {}", dest_dir.display()))?;
295    }
296    fs::create_dir_all(&dest_dir)
297        .with_context(|| format!("failed to create {}", dest_dir.display()))?;
298
299    let artifact_file = format!("{}-{}.wasm", report.provider.name, report.provider.version);
300    let dest_wasm = dest_dir.join(&artifact_file);
301    fs::copy(&report.artifact_path, &dest_wasm).with_context(|| {
302        format!(
303            "failed to copy {} to {}",
304            report.artifact_path.display(),
305            dest_wasm.display()
306        )
307    })?;
308
309    let mut meta = report.provider.clone();
310    meta.artifact.path = artifact_file.clone();
311    meta.artifact.sha256 = report.sha256.clone();
312    meta.artifact.created = OffsetDateTime::now_utc()
313        .format(&Rfc3339)
314        .context("unable to format timestamp")?;
315    meta.abi.wit_packages = report.packages.clone();
316
317    let meta_path = dest_dir.join("meta.json");
318    let meta_file = fs::File::create(&meta_path)
319        .with_context(|| format!("failed to create {}", meta_path.display()))?;
320    serde_json::to_writer_pretty(meta_file, &meta)
321        .with_context(|| format!("failed to write {}", meta_path.display()))?;
322
323    let mut sums =
324        fs::File::create(dest_dir.join("SHA256SUMS")).context("failed to create SHA256SUMS")?;
325    writeln!(sums, "{}  {}", report.sha256, artifact_file).context("failed to write SHA256SUMS")?;
326
327    println!("✓ Packed component at {}", dest_dir.display());
328    Ok(())
329}
330
331fn create_dir(path: PathBuf) -> Result<()> {
332    fs::create_dir_all(&path)
333        .with_context(|| format!("failed to create directory `{}`", path.display()))
334}
335
336fn write_template(path: &Path, template: &str, context: &TemplateContext) -> Result<()> {
337    if path.exists() {
338        bail!("file `{}` already exists", path.display());
339    }
340
341    let rendered = render_template(template, context);
342    fs::write(path, rendered).with_context(|| format!("failed to write `{}`", path.display()))
343}
344
345fn render_template(template: &str, context: &TemplateContext) -> String {
346    let mut output = template.to_owned();
347    for (key, value) in &context.placeholders {
348        let token = format!("{{{{{key}}}}}");
349        output = output.replace(&token, value);
350    }
351    output
352}
353
354fn detect_component_node_world_version(crate_root: &Path) -> Result<String> {
355    let wit_dir = crate_root.join("wit");
356    let namespace_dir = wit_dir.join("greentic");
357    let prefix = "component@";
358    let mut best: Option<(Version, PathBuf)> = None;
359
360    for entry in fs::read_dir(&namespace_dir).with_context(|| {
361        format!(
362            "failed to read namespace directory {}",
363            namespace_dir.display()
364        )
365    })? {
366        let entry = entry?;
367        let path = entry.path();
368        if !path.is_dir() {
369            continue;
370        }
371        let name = entry
372            .file_name()
373            .into_string()
374            .map_err(|_| anyhow!("non-unicode filename under {}", namespace_dir.display()))?;
375        if let Some(rest) = name.strip_prefix(prefix) {
376            let version = Version::parse(rest)
377                .with_context(|| format!("invalid semver `{rest}` for {prefix}"))?;
378            let package_path = path.join("package.wit");
379            let contents = fs::read_to_string(&package_path).with_context(|| {
380                format!("failed to read package file {}", package_path.display())
381            })?;
382            if contents.contains("export node") {
383                match &best {
384                    Some((best_ver, _)) if version <= *best_ver => {}
385                    _ => best = Some((version, path)),
386                }
387            }
388        }
389    }
390
391    if let Some((version, _)) = best {
392        return Ok(version.to_string());
393    }
394
395    detect_wit_package_version(crate_root, "component")
396}
397
398fn detect_wit_package_version(crate_root: &Path, prefix: &str) -> Result<String> {
399    let wit_dir = crate_root.join("wit");
400    let namespace_dir = wit_dir.join("greentic");
401    let prefix = format!("{prefix}@");
402
403    let mut best: Option<(Version, PathBuf)> = None;
404    for entry in fs::read_dir(&namespace_dir).with_context(|| {
405        format!(
406            "failed to read namespace directory {}",
407            namespace_dir.display()
408        )
409    })? {
410        let entry = entry?;
411        let path = entry.path();
412        if !path.is_dir() {
413            continue;
414        }
415        let name = entry
416            .file_name()
417            .into_string()
418            .map_err(|_| anyhow!("non-unicode filename under {}", namespace_dir.display()))?;
419        if let Some(rest) = name.strip_prefix(&prefix) {
420            let version = Version::parse(rest)
421                .with_context(|| format!("invalid semver `{rest}` for {prefix}"))?;
422            if best.as_ref().is_none_or(|(current, _)| &version > current) {
423                best = Some((version, path));
424            }
425        }
426    }
427
428    match best {
429        Some((version, _)) => Ok(version.to_string()),
430        None => Err(anyhow!(
431            "unable to locate WIT package `{}` under {}",
432            prefix,
433            namespace_dir.display()
434        )),
435    }
436}
437
438#[derive(Deserialize)]
439struct LockPackage {
440    name: String,
441    version: String,
442}
443
444#[derive(Deserialize)]
445struct LockFile {
446    package: Vec<LockPackage>,
447}
448
449fn resolved_version(crate_name: &str) -> Result<String> {
450    let lock_path = WORKSPACE_ROOT.join("Cargo.lock");
451    let contents = fs::read_to_string(&lock_path)
452        .with_context(|| format!("failed to read {}", lock_path.display()))?;
453    let lock: LockFile =
454        toml::from_str(&contents).with_context(|| format!("invalid {}", lock_path.display()))?;
455
456    let mut best: Option<(Version, String)> = None;
457    for pkg in lock
458        .package
459        .into_iter()
460        .filter(|pkg| pkg.name == crate_name)
461    {
462        let version = Version::parse(&pkg.version)
463            .with_context(|| format!("invalid semver `{}` for {}", pkg.version, crate_name))?;
464        if best.as_ref().is_none_or(|(current, _)| &version > current) {
465            best = Some((version, pkg.version));
466        }
467    }
468
469    match best {
470        Some((_, version)) => Ok(version),
471        None => Err(anyhow!(
472            "crate `{}` not found in {}",
473            crate_name,
474            lock_path.display()
475        )),
476    }
477}
478
479fn cargo_home() -> Result<PathBuf> {
480    if let Ok(path) = env::var("CARGO_HOME") {
481        return Ok(PathBuf::from(path));
482    }
483    if let Ok(home) = env::var("HOME") {
484        return Ok(PathBuf::from(home).join(".cargo"));
485    }
486    Err(anyhow!(
487        "unable to determine CARGO_HOME; set the environment variable explicitly"
488    ))
489}
490
491fn find_crate_source(crate_name: &str, version: &str) -> Result<PathBuf> {
492    let home = cargo_home()?;
493    let registry_src = home.join("registry/src");
494    if !registry_src.exists() {
495        return Err(anyhow!(
496            "cargo registry src directory not found at {}",
497            registry_src.display()
498        ));
499    }
500
501    for index in fs::read_dir(&registry_src)? {
502        let index_path = index?.path();
503        if !index_path.is_dir() {
504            continue;
505        }
506        let candidate = index_path.join(format!("{crate_name}-{version}"));
507        if candidate.exists() {
508            return Ok(candidate);
509        }
510    }
511
512    Err(anyhow!(
513        "crate `{}` version `{}` not found under {}",
514        crate_name,
515        version,
516        registry_src.display()
517    ))
518}
519
520struct TemplateContext {
521    component_name: String,
522    component_kebab: String,
523    placeholders: HashMap<String, String>,
524}
525
526impl TemplateContext {
527    fn new(raw: &str) -> Result<Self> {
528        let trimmed = raw.trim();
529        if trimmed.is_empty() {
530            bail!("component name cannot be empty");
531        }
532
533        let component_kebab = trimmed.to_case(Case::Kebab);
534        let component_snake = trimmed.to_case(Case::Snake);
535        let component_pascal = trimmed.to_case(Case::Pascal);
536        let component_name = component_kebab.clone();
537        let versions = VERSIONS.clone();
538
539        let mut placeholders = HashMap::new();
540        placeholders.insert("component_name".into(), component_name.clone());
541        placeholders.insert("component_kebab".into(), component_kebab.clone());
542        placeholders.insert("component_snake".into(), component_snake.clone());
543        placeholders.insert("component_pascal".into(), component_pascal.clone());
544        placeholders.insert("component_crate".into(), component_kebab.clone());
545        placeholders.insert(
546            "component_dir".into(),
547            format!("component-{component_kebab}"),
548        );
549        placeholders.insert("interfaces_version".into(), versions.interfaces.clone());
550        placeholders.insert("types_version".into(), versions.types.clone());
551        placeholders.insert(
552            "component_runtime_version".into(),
553            versions.component_runtime.clone(),
554        );
555        placeholders.insert(
556            "component_world_version".into(),
557            versions.component_wit_version.clone(),
558        );
559        placeholders.insert(
560            "interfaces_guest_version".into(),
561            versions.interfaces.clone(),
562        );
563        placeholders.insert(
564            "secrets_wit_version".into(),
565            versions.secrets_wit_version.clone(),
566        );
567        placeholders.insert(
568            "state_wit_version".into(),
569            versions.state_wit_version.clone(),
570        );
571        placeholders.insert("http_wit_version".into(), versions.http_wit_version.clone());
572        placeholders.insert(
573            "telemetry_wit_version".into(),
574            versions.telemetry_wit_version.clone(),
575        );
576
577        Ok(Self {
578            component_name,
579            component_kebab,
580            placeholders,
581        })
582    }
583
584    fn component_dir(&self) -> String {
585        format!("component-{}", self.component_kebab)
586    }
587}
588
589fn print_validation_summary(report: &ValidationReport) {
590    println!(
591        "✓ Validated {} {}",
592        report.provider.name, report.provider.version
593    );
594    println!("  artifact: {}", report.artifact_path.display());
595    println!("  sha256 : {}", report.sha256);
596    println!("  world  : {}", report.world);
597    println!("  packages:");
598    for pkg in &report.packages {
599        println!("    - {pkg}");
600    }
601}
602
603fn validate_component(path: &Path, build: bool) -> Result<ValidationReport> {
604    let component_dir = resolve_component_dir(path)?;
605
606    if build {
607        ensure_cargo_component_installed()?;
608        run_cargo_component_build(&component_dir)?;
609    }
610
611    let provider_path = component_dir.join("provider.toml");
612    let provider = load_provider(&provider_path)?;
613
614    let versions = Versions::load()?;
615    ensure_version_alignment(&provider, &versions)?;
616
617    let mut attempted = Vec::new();
618    let mut artifact_path = None;
619    for candidate in candidate_artifact_paths(&provider.artifact.path) {
620        let resolved = resolve_path(&component_dir, Path::new(&candidate));
621        attempted.push(resolved.clone());
622        if resolved.exists() {
623            artifact_path = Some(resolved);
624            break;
625        }
626    }
627    let artifact_path = match artifact_path {
628        Some(path) => path,
629        None => {
630            let paths = attempted
631                .into_iter()
632                .map(|p| p.display().to_string())
633                .collect::<Vec<_>>()
634                .join(", ");
635            bail!("artifact path not found; checked {paths}");
636        }
637    };
638
639    let wasm_bytes = fs::read(&artifact_path)
640        .with_context(|| format!("failed to read {}", artifact_path.display()))?;
641    let sha256 = format!("{:x}", Sha256::digest(&wasm_bytes));
642
643    let decoded = decode_component(&wasm_bytes).context("failed to decode component")?;
644    let (resolve, world_id) = match decoded {
645        DecodedWasm::Component(resolve, world) => (resolve, world),
646        DecodedWasm::WitPackage(_, _) => {
647            bail!("expected a component artifact but found a WIT package bundle")
648        }
649    };
650    let (packages, world, export_package) = extract_wit_metadata(&resolve, world_id)?;
651
652    if packages.is_empty() {
653        bail!("no WIT packages embedded in component artifact");
654    }
655
656    if provider.abi.world != world {
657        if let Some(expected_pkg) = world_to_package_id(&provider.abi.world) {
658            if let Some(actual_pkg) = export_package {
659                if actual_pkg != expected_pkg {
660                    bail!(
661                        "provider world `{}` expects package '{}', but embedded exports use '{}'",
662                        provider.abi.world,
663                        expected_pkg,
664                        actual_pkg
665                    );
666                }
667            } else if !packages.iter().any(|pkg| pkg == &expected_pkg) {
668                bail!(
669                    "provider world `{}` expects package '{}', which was not embedded (found {:?})",
670                    provider.abi.world,
671                    expected_pkg,
672                    packages
673                );
674            }
675        } else {
676            bail!(
677                "provider world `{}` is not formatted as <namespace>:<package>/<world>@<version>",
678                provider.abi.world
679            );
680        }
681    }
682
683    let expected_packages: BTreeSet<_> = provider.abi.wit_packages.iter().cloned().collect();
684    if !expected_packages.is_empty() {
685        let actual_greentic: BTreeSet<_> = packages
686            .iter()
687            .filter(|pkg| pkg.starts_with("greentic:"))
688            .cloned()
689            .collect();
690        if !expected_packages.is_subset(&actual_greentic) {
691            bail!(
692                "provider wit_packages {expected_packages:?} not satisfied by embedded packages \
693                 {actual_greentic:?}"
694            );
695        }
696    }
697
698    Ok(ValidationReport {
699        provider,
700        component_dir,
701        artifact_path,
702        sha256,
703        world,
704        packages,
705    })
706}
707
708fn resolve_component_dir(path: &Path) -> Result<PathBuf> {
709    let dir = if path.is_absolute() {
710        path.to_path_buf()
711    } else {
712        env::current_dir()
713            .context("unable to determine current directory")?
714            .join(path)
715    };
716    dir.canonicalize()
717        .with_context(|| format!("failed to canonicalize {}", dir.display()))
718}
719
720fn resolve_path(base: &Path, raw: impl AsRef<Path>) -> PathBuf {
721    let raw_path = raw.as_ref();
722    if raw_path.is_absolute() {
723        raw_path.to_path_buf()
724    } else {
725        base.join(raw_path)
726    }
727}
728
729fn candidate_artifact_paths(original: &str) -> Vec<String> {
730    let mut paths = Vec::new();
731    paths.push(original.to_string());
732
733    for (from, to) in [
734        ("wasm32-wasip2", "wasm32-wasip1"),
735        ("wasm32-wasip2", "wasm32-wasi"),
736        ("wasm32-wasip1", "wasm32-wasip2"),
737        ("wasm32-wasip1", "wasm32-wasi"),
738        ("wasm32-wasi", "wasm32-wasip2"),
739        ("wasm32-wasi", "wasm32-wasip1"),
740    ] {
741        if original.contains(from) {
742            let candidate = original.replace(from, to);
743            if candidate != original && !paths.contains(&candidate) {
744                paths.push(candidate);
745            }
746        }
747    }
748
749    paths
750}
751
752fn ensure_cargo_component_installed() -> Result<()> {
753    let status = Command::new("cargo")
754        .arg("component")
755        .arg("--version")
756        .status();
757    match status {
758        Ok(status) if status.success() => Ok(()),
759        Ok(_) => bail!(
760            "cargo-component is required. Install with `cargo install cargo-component --locked`."
761        ),
762        Err(err) => Err(anyhow!(
763            "failed to execute `cargo component --version`: {err}. Install cargo-component with `cargo install cargo-component --locked`."
764        )),
765    }
766}
767
768fn run_cargo_component_build(component_dir: &Path) -> Result<()> {
769    let cache_dir = component_dir.join("target").join(".component-cache");
770    let status = Command::new("cargo")
771        .current_dir(component_dir)
772        .arg("component")
773        .arg("build")
774        .arg("--release")
775        .arg("--target")
776        .arg("wasm32-wasip2")
777        .env("CARGO_COMPONENT_CACHE_DIR", cache_dir.as_os_str())
778        .env("CARGO_NET_OFFLINE", "true")
779        .status()
780        .with_context(|| {
781            format!(
782                "failed to run `cargo component build` in {}",
783                component_dir.display()
784            )
785        })?;
786    if status.success() {
787        Ok(())
788    } else {
789        bail!("cargo component build failed")
790    }
791}
792
793fn load_provider(path: &Path) -> Result<ProviderMetadata> {
794    let contents = fs::read_to_string(path)
795        .with_context(|| format!("failed to read provider metadata {}", path.display()))?;
796    let provider: ProviderMetadata =
797        toml::from_str(&contents).context("provider.toml is not valid TOML")?;
798    if provider.artifact.format != "wasm-component" {
799        bail!(
800            "artifact.format must be `wasm-component`, found `{}`",
801            provider.artifact.format
802        );
803    }
804    Ok(provider)
805}
806
807fn ensure_version_alignment(provider: &ProviderMetadata, versions: &Versions) -> Result<()> {
808    if provider.abi.interfaces_version != versions.interfaces {
809        bail!(
810            "provider abi.interfaces_version `{}` does not match pinned `{}`",
811            provider.abi.interfaces_version,
812            versions.interfaces
813        );
814    }
815    if provider.abi.types_version != versions.types {
816        bail!(
817            "provider abi.types_version `{}` does not match pinned `{}`",
818            provider.abi.types_version,
819            versions.types
820        );
821    }
822    Ok(())
823}
824
825fn extract_wit_metadata(
826    resolve: &Resolve,
827    world_id: WorldId,
828) -> Result<(Vec<String>, String, Option<String>)> {
829    let mut packages = Vec::new();
830    for (_, package) in resolve.packages.iter() {
831        let name = &package.name;
832        if name.namespace == "root" {
833            continue;
834        }
835        if let Some(version) = &name.version {
836            packages.push(format!("{}:{}@{}", name.namespace, name.name, version));
837        } else {
838            packages.push(format!("{}:{}", name.namespace, name.name));
839        }
840    }
841    packages.sort();
842    packages.dedup();
843
844    let world = &resolve.worlds[world_id];
845    let mut export_package = None;
846    for item in world.exports.values() {
847        if let WorldItem::Interface { id, .. } = item {
848            let iface = &resolve.interfaces[*id];
849            if let Some(pkg_id) = iface.package {
850                let pkg = &resolve.packages[pkg_id].name;
851                if pkg.namespace != "root" {
852                    let mut ident = format!("{}:{}", pkg.namespace, pkg.name);
853                    if let Some(version) = &pkg.version {
854                        ident.push('@');
855                        ident.push_str(&version.to_string());
856                    }
857                    export_package.get_or_insert(ident);
858                }
859            }
860        }
861    }
862
863    let world_string = if let Some(pkg_id) = world.package {
864        let pkg = &resolve.packages[pkg_id];
865        if let Some(version) = &pkg.name.version {
866            format!(
867                "{}:{}/{}@{}",
868                pkg.name.namespace, pkg.name.name, world.name, version
869            )
870        } else {
871            format!("{}:{}/{}", pkg.name.namespace, pkg.name.name, world.name)
872        }
873    } else {
874        world.name.clone()
875    };
876
877    Ok((packages, world_string, export_package))
878}
879
880fn world_to_package_id(world: &str) -> Option<String> {
881    let (pkg_part, rest) = world.split_once('/')?;
882    let (_, version) = rest.rsplit_once('@')?;
883    Some(format!("{pkg_part}@{version}"))
884}