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