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