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 = format!("{:x}", Sha256::digest(&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 resolve_path(base: &Path, raw: impl AsRef<Path>) -> PathBuf {
725    let raw_path = raw.as_ref();
726    if raw_path.is_absolute() {
727        raw_path.to_path_buf()
728    } else {
729        base.join(raw_path)
730    }
731}
732
733fn candidate_artifact_paths(original: &str) -> Vec<String> {
734    let mut paths = Vec::new();
735    paths.push(original.to_string());
736
737    for (from, to) in [
738        ("wasm32-wasip2", "wasm32-wasip1"),
739        ("wasm32-wasip2", "wasm32-wasi"),
740        ("wasm32-wasip1", "wasm32-wasip2"),
741        ("wasm32-wasip1", "wasm32-wasi"),
742        ("wasm32-wasi", "wasm32-wasip2"),
743        ("wasm32-wasi", "wasm32-wasip1"),
744    ] {
745        if original.contains(from) {
746            let candidate = original.replace(from, to);
747            if candidate != original && !paths.contains(&candidate) {
748                paths.push(candidate);
749            }
750        }
751    }
752
753    paths
754}
755
756fn ensure_cargo_component_installed() -> Result<()> {
757    let status = Command::new("cargo")
758        .arg("component")
759        .arg("--version")
760        .status();
761    match status {
762        Ok(status) if status.success() => Ok(()),
763        Ok(_) => bail!(
764            "cargo-component is required. Install with `cargo install cargo-component --locked`."
765        ),
766        Err(err) => Err(anyhow!(
767            "failed to execute `cargo component --version`: {err}. Install cargo-component with `cargo install cargo-component --locked`."
768        )),
769    }
770}
771
772fn run_cargo_component_build(component_dir: &Path) -> Result<()> {
773    let cache_dir = component_dir.join("target").join(".component-cache");
774    let status = Command::new("cargo")
775        .current_dir(component_dir)
776        .arg("component")
777        .arg("build")
778        .arg("--release")
779        .arg("--target")
780        .arg("wasm32-wasip2")
781        .env("CARGO_COMPONENT_CACHE_DIR", cache_dir.as_os_str())
782        .env("CARGO_NET_OFFLINE", "true")
783        .status()
784        .with_context(|| {
785            format!(
786                "failed to run `cargo component build` in {}",
787                component_dir.display()
788            )
789        })?;
790    if status.success() {
791        Ok(())
792    } else {
793        bail!("cargo component build failed")
794    }
795}
796
797fn load_provider(path: &Path) -> Result<ProviderMetadata> {
798    let contents = fs::read_to_string(path)
799        .with_context(|| format!("failed to read provider metadata {}", path.display()))?;
800    let provider: ProviderMetadata =
801        toml::from_str(&contents).context("provider.toml is not valid TOML")?;
802    if provider.artifact.format != "wasm-component" {
803        bail!(
804            "artifact.format must be `wasm-component`, found `{}`",
805            provider.artifact.format
806        );
807    }
808    Ok(provider)
809}
810
811fn ensure_version_alignment(provider: &ProviderMetadata, versions: &Versions) -> Result<()> {
812    if provider.abi.interfaces_version != versions.interfaces {
813        bail!(
814            "provider abi.interfaces_version `{}` does not match pinned `{}`",
815            provider.abi.interfaces_version,
816            versions.interfaces
817        );
818    }
819    if provider.abi.types_version != versions.types {
820        bail!(
821            "provider abi.types_version `{}` does not match pinned `{}`",
822            provider.abi.types_version,
823            versions.types
824        );
825    }
826    Ok(())
827}
828
829fn extract_wit_metadata(
830    resolve: &Resolve,
831    world_id: WorldId,
832) -> Result<(Vec<String>, String, Option<String>)> {
833    let mut packages = Vec::new();
834    for (_, package) in resolve.packages.iter() {
835        let name = &package.name;
836        if name.namespace == "root" {
837            continue;
838        }
839        if let Some(version) = &name.version {
840            packages.push(format!("{}:{}@{}", name.namespace, name.name, version));
841        } else {
842            packages.push(format!("{}:{}", name.namespace, name.name));
843        }
844    }
845    packages.sort();
846    packages.dedup();
847
848    let world = &resolve.worlds[world_id];
849    let mut export_package = None;
850    for item in world.exports.values() {
851        if let WorldItem::Interface { id, .. } = item {
852            let iface = &resolve.interfaces[*id];
853            if let Some(pkg_id) = iface.package {
854                let pkg = &resolve.packages[pkg_id].name;
855                if pkg.namespace != "root" {
856                    let mut ident = format!("{}:{}", pkg.namespace, pkg.name);
857                    if let Some(version) = &pkg.version {
858                        ident.push('@');
859                        ident.push_str(&version.to_string());
860                    }
861                    export_package.get_or_insert(ident);
862                }
863            }
864        }
865    }
866
867    let world_string = if let Some(pkg_id) = world.package {
868        let pkg = &resolve.packages[pkg_id];
869        if let Some(version) = &pkg.name.version {
870            format!(
871                "{}:{}/{}@{}",
872                pkg.name.namespace, pkg.name.name, world.name, version
873            )
874        } else {
875            format!("{}:{}/{}", pkg.name.namespace, pkg.name.name, world.name)
876        }
877    } else {
878        world.name.clone()
879    };
880
881    Ok((packages, world_string, export_package))
882}
883
884fn world_to_package_id(world: &str) -> Option<String> {
885    let (pkg_part, rest) = world.split_once('/')?;
886    let (_, version) = rest.rsplit_once('@')?;
887    Some(format!("{pkg_part}@{version}"))
888}
889
890fn workspace_root() -> Result<PathBuf> {
891    env::current_dir()
892        .context("unable to determine current directory")?
893        .canonicalize()
894        .context("failed to canonicalize workspace root")
895}