Skip to main content

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