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 New(NewComponentArgs),
181 Validate(ValidateArgs),
183 Pack(PackArgs),
185}
186
187#[derive(Args, Debug, Clone)]
188pub struct NewComponentArgs {
189 name: String,
191 #[arg(long, value_name = "DIR")]
193 dir: Option<PathBuf>,
194}
195
196#[derive(Args, Debug, Clone)]
197pub struct ValidateArgs {
198 #[arg(long, value_name = "PATH", default_value = ".")]
200 path: PathBuf,
201 #[arg(long)]
203 skip_build: bool,
204}
205
206#[derive(Args, Debug, Clone)]
207pub struct PackArgs {
208 #[arg(long, value_name = "PATH", default_value = ".")]
210 path: PathBuf,
211 #[arg(long, value_name = "DIR")]
213 out_dir: Option<PathBuf>,
214 #[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(®istry_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}