Skip to main content

greentic_component/cmd/
build.rs

1#![cfg(feature = "cli")]
2
3use std::env;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use anyhow::{Context, Result, anyhow, bail};
9use clap::Args;
10use serde_json::Value as JsonValue;
11use wasmtime::component::{Component, Linker, Val};
12use wasmtime::{Engine, Store};
13use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
14
15use crate::abi::{self, AbiError};
16use crate::cmd::component_world::{canonical_component_world, is_fallback_world};
17use crate::cmd::flow::{
18    FlowUpdateResult, manifest_component_id, resolve_operation, update_with_manifest,
19};
20use crate::cmd::i18n;
21use crate::config::{
22    ConfigInferenceOptions, ConfigSchemaSource, load_manifest_with_schema, resolve_manifest_path,
23};
24use crate::describe::{DescribePayload, from_wit_world};
25use crate::embedded_descriptor::embed_and_verify_wasm;
26use crate::parse_manifest;
27use crate::path_safety::normalize_under_root;
28use crate::schema_quality::{SchemaQualityMode, validate_operation_schemas};
29use greentic_types::cbor::canonical;
30use greentic_types::schemas::component::v0_6_0::ComponentDescribe;
31
32const DEFAULT_MANIFEST: &str = "component.manifest.json";
33
34#[derive(Args, Debug, Clone)]
35pub struct BuildArgs {
36    /// Path to component.manifest.json (or directory containing it)
37    #[arg(long = "manifest", value_name = "PATH", default_value = DEFAULT_MANIFEST)]
38    pub manifest: PathBuf,
39    /// Path to the cargo binary (fallback: $CARGO, then `cargo` on PATH)
40    #[arg(long = "cargo", value_name = "PATH")]
41    pub cargo_bin: Option<PathBuf>,
42    /// Skip flow regeneration
43    #[arg(long = "no-flow")]
44    pub no_flow: bool,
45    /// Skip config inference; fail if config_schema is missing
46    #[arg(long = "no-infer-config")]
47    pub no_infer_config: bool,
48    /// Do not write inferred config_schema back to the manifest
49    #[arg(long = "no-write-schema")]
50    pub no_write_schema: bool,
51    /// Overwrite existing config_schema with inferred schema
52    #[arg(long = "force-write-schema")]
53    pub force_write_schema: bool,
54    /// Skip schema validation
55    #[arg(long = "no-validate")]
56    pub no_validate: bool,
57    /// Emit machine-readable JSON summary
58    #[arg(long = "json")]
59    pub json: bool,
60    /// Allow empty operation schemas (warnings only)
61    #[arg(long)]
62    pub permissive: bool,
63}
64
65#[derive(Debug, serde::Serialize)]
66struct BuildSummary {
67    manifest: PathBuf,
68    wasm_path: PathBuf,
69    wasm_hash: String,
70    config_source: ConfigSchemaSource,
71    schema_written: bool,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    flows: Option<FlowUpdateResult>,
74}
75
76pub fn run(args: BuildArgs) -> Result<()> {
77    let manifest_path = resolve_manifest_path(&args.manifest);
78    let cwd = env::current_dir().context("failed to read current directory")?;
79    let manifest_path = if manifest_path.is_absolute() {
80        manifest_path
81    } else {
82        cwd.join(manifest_path)
83    };
84    if !manifest_path.exists() {
85        bail!(
86            "{}",
87            i18n::tr_lit("manifest not found at {}").replacen(
88                "{}",
89                &manifest_path.display().to_string(),
90                1
91            )
92        );
93    }
94    let cargo_bin = args
95        .cargo_bin
96        .clone()
97        .or_else(|| env::var_os("CARGO").map(PathBuf::from))
98        .unwrap_or_else(|| PathBuf::from("cargo"));
99    let inference_opts = ConfigInferenceOptions {
100        allow_infer: !args.no_infer_config,
101        write_schema: !args.no_write_schema,
102        force_write_schema: args.force_write_schema,
103        validate: !args.no_validate,
104    };
105    println!(
106        "Using manifest at {} (cargo: {})",
107        manifest_path.display(),
108        cargo_bin.display()
109    );
110
111    let config = load_manifest_with_schema(&manifest_path, &inference_opts)?;
112    let mode = if args.permissive {
113        SchemaQualityMode::Permissive
114    } else {
115        SchemaQualityMode::Strict
116    };
117    let manifest_component = parse_manifest(
118        &serde_json::to_string(&config.manifest)
119            .context("failed to serialize manifest for schema validation")?,
120    )
121    .context("failed to parse manifest for schema validation")?;
122    let schema_warnings = validate_operation_schemas(&manifest_component, mode)?;
123    for warning in schema_warnings {
124        eprintln!("warning[W_OP_SCHEMA_EMPTY]: {}", warning.message);
125    }
126    let component_id = manifest_component_id(&config.manifest)?;
127    let _operation = resolve_operation(&config.manifest, component_id)?;
128    let flow_outcome = if args.no_flow {
129        None
130    } else {
131        Some(update_with_manifest(&config)?)
132    };
133
134    let mut manifest_to_write = flow_outcome
135        .as_ref()
136        .map(|outcome| outcome.manifest.clone())
137        .unwrap_or_else(|| config.manifest.clone());
138    let canonical_manifest = parse_manifest(
139        &serde_json::to_string(&manifest_to_write)
140            .context("failed to serialize manifest for embedded descriptor")?,
141    )
142    .context("failed to parse canonical manifest for embedded descriptor")?;
143
144    let manifest_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
145    build_wasm(manifest_dir, &cargo_bin, &manifest_to_write)?;
146    check_canonical_world_export(manifest_dir, &manifest_to_write)?;
147    let wasm_path_for_embedding = resolve_wasm_path(manifest_dir, &manifest_to_write)?;
148    embed_and_verify_wasm(&wasm_path_for_embedding, &canonical_manifest)
149        .context("failed to embed canonical manifest into built wasm")?;
150
151    if !config.persist_schema {
152        manifest_to_write
153            .as_object_mut()
154            .map(|obj| obj.remove("config_schema"));
155    }
156    let (wasm_path, wasm_hash) = update_manifest_hashes(manifest_dir, &mut manifest_to_write)?;
157    emit_describe_artifacts(manifest_dir, &manifest_to_write, &wasm_path)?;
158    write_manifest(&manifest_path, &manifest_to_write)?;
159
160    if args.json {
161        let payload = BuildSummary {
162            manifest: manifest_path.clone(),
163            wasm_path,
164            wasm_hash,
165            config_source: config.source,
166            schema_written: config.schema_written && config.persist_schema,
167            flows: flow_outcome.as_ref().map(|outcome| outcome.result),
168        };
169        serde_json::to_writer_pretty(std::io::stdout(), &payload)?;
170        println!();
171    } else {
172        println!("Built wasm artifact at {}", wasm_path.display());
173        println!("Updated {} hashes (blake3)", manifest_path.display());
174        if config.schema_written && config.persist_schema {
175            println!(
176                "Updated {} with inferred config_schema ({:?})",
177                manifest_path.display(),
178                config.source
179            );
180        }
181        if let Some(outcome) = flow_outcome {
182            let flows = outcome.result;
183            println!(
184                "Flows updated (default: {}, custom: {})",
185                flows.default_updated, flows.custom_updated
186            );
187        } else {
188            println!("Flow regeneration skipped (--no-flow)");
189        }
190    }
191
192    Ok(())
193}
194
195fn build_wasm(manifest_dir: &Path, cargo_bin: &Path, manifest: &JsonValue) -> Result<()> {
196    let resolved_world = manifest.get("world").and_then(|v| v.as_str()).unwrap_or("");
197    if resolved_world.is_empty() {
198        println!("Resolved manifest world: <missing>");
199    } else {
200        println!("Resolved manifest world: {resolved_world}");
201    }
202    let require_component = resolved_world.contains("component@0.6.0");
203
204    if require_component {
205        if cargo_component_available(cargo_bin) {
206            println!(
207                "Running cargo component build via {} in {}",
208                cargo_bin.display(),
209                manifest_dir.display()
210            );
211            let mut cmd = Command::new(cargo_bin);
212            if let Some(flags) = resolved_wasm_rustflags() {
213                cmd.env("RUSTFLAGS", sanitize_wasm_rustflags(&flags));
214            }
215            let status = cmd
216                .arg("component")
217                .arg("build")
218                .arg("--target")
219                .arg("wasm32-wasip2")
220                .arg("--release")
221                .current_dir(manifest_dir)
222                .status()
223                .with_context(|| {
224                    format!(
225                        "failed to run cargo component build via {}",
226                        cargo_bin.display()
227                    )
228                })?;
229            if !status.success() {
230                bail!(
231                    "cargo component build --target wasm32-wasip2 --release failed with status {}",
232                    status
233                );
234            }
235            return Ok(());
236        }
237        bail!(
238            "component@0.6.0 manifests require cargo-component; install it with `cargo install cargo-component --locked`"
239        );
240    }
241
242    println!(
243        "Running cargo build via {} in {}",
244        cargo_bin.display(),
245        manifest_dir.display()
246    );
247    let mut cmd = Command::new(cargo_bin);
248    if let Some(flags) = resolved_wasm_rustflags() {
249        cmd.env("RUSTFLAGS", sanitize_wasm_rustflags(&flags));
250    }
251    let status = cmd
252        .arg("build")
253        .arg("--target")
254        .arg("wasm32-wasip2")
255        .arg("--release")
256        .current_dir(manifest_dir)
257        .status()
258        .with_context(|| format!("failed to run cargo build via {}", cargo_bin.display()))?;
259
260    if !status.success() {
261        bail!(
262            "cargo build --target wasm32-wasip2 --release failed with status {}",
263            status
264        );
265    }
266    Ok(())
267}
268
269fn cargo_component_available(cargo_bin: &Path) -> bool {
270    Command::new(cargo_bin)
271        .arg("component")
272        .arg("--version")
273        .status()
274        .map(|status| status.success())
275        .unwrap_or(false)
276}
277
278/// Reads the wasm-specific rustflags that CI exports for wasm builds.
279fn resolved_wasm_rustflags() -> Option<String> {
280    env::var("WASM_RUSTFLAGS")
281        .ok()
282        .or_else(|| env::var("RUSTFLAGS").ok())
283}
284
285/// Drops linker arguments that `wasm-component-ld` rejects and normalizes whitespace.
286fn sanitize_wasm_rustflags(flags: &str) -> String {
287    flags
288        .replace("-Wl,", "")
289        .replace("-C link-arg=--no-keep-memory", "")
290        .replace("-C link-arg=--threads=1", "")
291        .split_whitespace()
292        .collect::<Vec<_>>()
293        .join(" ")
294}
295
296fn check_canonical_world_export(manifest_dir: &Path, manifest: &JsonValue) -> Result<()> {
297    if env::var_os("GREENTIC_SKIP_NODE_EXPORT_CHECK").is_some() {
298        println!("World export check skipped (GREENTIC_SKIP_NODE_EXPORT_CHECK=1)");
299        return Ok(());
300    }
301    let wasm_path = resolve_wasm_path(manifest_dir, manifest)?;
302    let canonical_world = canonical_component_world();
303    match abi::check_world_base(&wasm_path, canonical_world) {
304        Ok(exported) => println!("Exported world: {exported}"),
305        Err(err) => match err {
306            AbiError::WorldMismatch { expected, found } if is_fallback_world(&found) => {
307                println!("Exported world: {expected} (compatible fallback export: {found})");
308            }
309            err => {
310                return Err(err)
311                    .with_context(|| format!("component must export world {canonical_world}"));
312            }
313        },
314    }
315    Ok(())
316}
317
318fn update_manifest_hashes(
319    manifest_dir: &Path,
320    manifest: &mut JsonValue,
321) -> Result<(PathBuf, String)> {
322    let artifact_path = resolve_wasm_path(manifest_dir, manifest)?;
323    let wasm_bytes = fs::read(&artifact_path)
324        .with_context(|| format!("failed to read wasm at {}", artifact_path.display()))?;
325    let digest = blake3::hash(&wasm_bytes).to_hex().to_string();
326
327    manifest["artifacts"]["component_wasm"] =
328        JsonValue::String(path_string_relative(manifest_dir, &artifact_path)?);
329    manifest["hashes"]["component_wasm"] = JsonValue::String(format!("blake3:{digest}"));
330
331    Ok((artifact_path, format!("blake3:{digest}")))
332}
333
334fn path_string_relative(base: &Path, target: &Path) -> Result<String> {
335    let rel = pathdiff::diff_paths(target, base).unwrap_or_else(|| target.to_path_buf());
336    rel.to_str()
337        .map(|s| s.to_string())
338        .ok_or_else(|| anyhow!("failed to stringify path {}", target.display()))
339}
340
341fn resolve_wasm_path(manifest_dir: &Path, manifest: &JsonValue) -> Result<PathBuf> {
342    let manifest_root = manifest_dir
343        .canonicalize()
344        .with_context(|| format!("failed to canonicalize {}", manifest_dir.display()))?;
345    let candidate = manifest
346        .get("artifacts")
347        .and_then(|a| a.get("component_wasm"))
348        .and_then(|v| v.as_str())
349        .map(PathBuf::from)
350        .unwrap_or_else(|| {
351            let raw_name = manifest
352                .get("name")
353                .and_then(|v| v.as_str())
354                .or_else(|| manifest.get("id").and_then(|v| v.as_str()))
355                .unwrap_or("component");
356            let sanitized = raw_name.replace(['-', '.'], "_");
357            manifest_dir.join(format!("target/wasm32-wasip2/release/{sanitized}.wasm"))
358        });
359    if candidate.exists() {
360        let normalized = normalize_under_root(&manifest_root, &candidate).or_else(|_| {
361            if candidate.is_absolute() {
362                candidate
363                    .canonicalize()
364                    .with_context(|| format!("failed to canonicalize {}", candidate.display()))
365            } else {
366                normalize_under_root(&manifest_root, &candidate)
367            }
368        })?;
369        return Ok(normalized);
370    }
371
372    if let Some(cargo_target_dir) = env::var_os("CARGO_TARGET_DIR") {
373        let relative = candidate
374            .strip_prefix(manifest_dir)
375            .unwrap_or(&candidate)
376            .to_path_buf();
377        if relative.starts_with("target") {
378            let alt =
379                PathBuf::from(cargo_target_dir).join(relative.strip_prefix("target").unwrap());
380            if alt.exists() {
381                return alt
382                    .canonicalize()
383                    .with_context(|| format!("failed to canonicalize {}", alt.display()));
384            }
385        }
386    }
387
388    let normalized = normalize_under_root(&manifest_root, &candidate).or_else(|_| {
389        if candidate.is_absolute() {
390            candidate
391                .canonicalize()
392                .with_context(|| format!("failed to canonicalize {}", candidate.display()))
393        } else {
394            normalize_under_root(&manifest_root, &candidate)
395        }
396    })?;
397    Ok(normalized)
398}
399
400fn write_manifest(manifest_path: &Path, manifest: &JsonValue) -> Result<()> {
401    let formatted = serde_json::to_string_pretty(manifest)?;
402    fs::write(manifest_path, formatted + "\n")
403        .with_context(|| format!("failed to write {}", manifest_path.display()))
404}
405
406fn emit_describe_artifacts(
407    manifest_dir: &Path,
408    manifest: &JsonValue,
409    wasm_path: &Path,
410) -> Result<()> {
411    let abi_version = read_abi_version(manifest_dir);
412    let require_describe = abi_version.as_deref() == Some("0.6.0");
413    let manifest_model = parse_manifest(
414        &serde_json::to_string(manifest).context("failed to serialize manifest for describe")?,
415    )
416    .context("failed to parse manifest for describe")?;
417
418    let describe_bytes = match call_describe(wasm_path) {
419        Ok(bytes) => bytes,
420        Err(err) => {
421            if require_describe {
422                match from_wit_world(wasm_path, manifest_model.world.as_str()) {
423                    Ok(payload) => {
424                        write_wit_describe_artifacts(
425                            manifest_dir,
426                            manifest,
427                            wasm_path,
428                            abi_version.as_deref(),
429                            &payload,
430                        )?;
431                        eprintln!(
432                            "warning: describe export unavailable, emitted WIT-derived describe.json instead ({err})"
433                        );
434                        return Ok(());
435                    }
436                    Err(wit_err) => {
437                        return Err(anyhow!(
438                            "describe failed: {err}; WIT fallback failed: {wit_err}"
439                        ));
440                    }
441                }
442            }
443            eprintln!("warning: skipping describe artifacts ({err})");
444            return Ok(());
445        }
446    };
447
448    let payload = strip_self_describe_tag(&describe_bytes);
449    let canonical_bytes = canonical::canonicalize_allow_floats(payload)
450        .map_err(|err| anyhow!("describe canonicalization failed: {err}"))?;
451    let describe: ComponentDescribe = canonical::from_cbor(&canonical_bytes)
452        .map_err(|err| anyhow!("describe decode failed: {err}"))?;
453
454    let dist_dir = manifest_dir.join("dist");
455    fs::create_dir_all(&dist_dir)
456        .with_context(|| format!("failed to create {}", dist_dir.display()))?;
457
458    let (name, abi_underscore) = artifact_basename(manifest, wasm_path, abi_version.as_deref());
459    let base = format!("{name}__{abi_underscore}");
460    let describe_cbor_path = dist_dir.join(format!("{base}.describe.cbor"));
461    fs::write(&describe_cbor_path, &canonical_bytes)
462        .with_context(|| format!("failed to write {}", describe_cbor_path.display()))?;
463
464    let describe_json_path = dist_dir.join(format!("{base}.describe.json"));
465    let json = serde_json::to_string_pretty(&describe)?;
466    fs::write(&describe_json_path, json + "\n")
467        .with_context(|| format!("failed to write {}", describe_json_path.display()))?;
468
469    let wasm_out = dist_dir.join(format!("{base}.wasm"));
470    if wasm_out != wasm_path {
471        let _ = fs::copy(wasm_path, &wasm_out);
472    }
473
474    Ok(())
475}
476
477fn write_wit_describe_artifacts(
478    manifest_dir: &Path,
479    manifest: &JsonValue,
480    wasm_path: &Path,
481    abi_version: Option<&str>,
482    payload: &DescribePayload,
483) -> Result<()> {
484    let dist_dir = manifest_dir.join("dist");
485    fs::create_dir_all(&dist_dir)
486        .with_context(|| format!("failed to create {}", dist_dir.display()))?;
487
488    let (name, abi_underscore) = artifact_basename(manifest, wasm_path, abi_version);
489    let base = format!("{name}__{abi_underscore}");
490    let describe_cbor_path = dist_dir.join(format!("{base}.describe.cbor"));
491    let cbor = canonical::to_canonical_cbor_allow_floats(payload)
492        .map_err(|err| anyhow!("describe fallback canonicalization failed: {err}"))?;
493    fs::write(&describe_cbor_path, cbor)
494        .with_context(|| format!("failed to write {}", describe_cbor_path.display()))?;
495
496    let describe_json_path = dist_dir.join(format!("{base}.describe.json"));
497    let json = serde_json::to_string_pretty(payload)?;
498    fs::write(&describe_json_path, json + "\n")
499        .with_context(|| format!("failed to write {}", describe_json_path.display()))?;
500
501    let wasm_out = dist_dir.join(format!("{base}.wasm"));
502    if wasm_out != wasm_path {
503        let _ = fs::copy(wasm_path, &wasm_out);
504    }
505
506    Ok(())
507}
508
509fn read_abi_version(manifest_dir: &Path) -> Option<String> {
510    let cargo_path = manifest_dir.join("Cargo.toml");
511    let contents = fs::read_to_string(cargo_path).ok()?;
512    let doc: toml::Value = toml::from_str(&contents).ok()?;
513    doc.get("package")
514        .and_then(|pkg| pkg.get("metadata"))
515        .and_then(|meta| meta.get("greentic"))
516        .and_then(|g| g.get("abi_version"))
517        .and_then(|v| v.as_str())
518        .map(|s| s.to_string())
519}
520
521fn artifact_basename(
522    manifest: &JsonValue,
523    wasm_path: &Path,
524    abi_version: Option<&str>,
525) -> (String, String) {
526    let name = manifest
527        .get("name")
528        .and_then(|v| v.as_str())
529        .or_else(|| manifest.get("id").and_then(|v| v.as_str()))
530        .map(sanitize_name)
531        .unwrap_or_else(|| {
532            wasm_path
533                .file_stem()
534                .and_then(|s| s.to_str())
535                .map(sanitize_name)
536                .unwrap_or_else(|| "component".to_string())
537        });
538    let abi = abi_version.unwrap_or("0.6.0").replace('.', "_");
539    (name, abi)
540}
541
542fn sanitize_name(raw: &str) -> String {
543    raw.chars()
544        .map(|ch| {
545            if ch.is_ascii_alphanumeric() || ch == '-' {
546                ch
547            } else {
548                '_'
549            }
550        })
551        .collect::<String>()
552        .trim_matches('_')
553        .to_string()
554}
555
556fn call_describe(wasm_path: &Path) -> Result<Vec<u8>> {
557    let mut config = wasmtime::Config::new();
558    config.wasm_component_model(true);
559    let engine = Engine::new(&config).map_err(|err| anyhow!("failed to create engine: {err}"))?;
560    let component = Component::from_file(&engine, wasm_path)
561        .map_err(|err| anyhow!("failed to load component {}: {err}", wasm_path.display()))?;
562    let mut linker = Linker::new(&engine);
563    wasmtime_wasi::p2::add_to_linker_sync(&mut linker)
564        .map_err(|err| anyhow!("failed to add wasi: {err}"))?;
565    let mut store = Store::new(&engine, BuildWasi::new()?);
566    let instance = linker
567        .instantiate(&mut store, &component)
568        .map_err(|err| anyhow!("failed to instantiate component: {err}"))?;
569    let instance_index = resolve_interface_index(&instance, &mut store, "component-descriptor")
570        .ok_or_else(|| anyhow!("missing export interface component-descriptor"))?;
571    let func_index = instance
572        .get_export_index(&mut store, Some(&instance_index), "describe")
573        .ok_or_else(|| anyhow!("missing export component-descriptor.describe"))?;
574    let func = instance
575        .get_func(&mut store, func_index)
576        .ok_or_else(|| anyhow!("describe export is not callable"))?;
577    let mut results = vec![Val::Bool(false); func.ty(&mut store).results().len()];
578    func.call(&mut store, &[], &mut results)
579        .map_err(|err| anyhow!("describe call failed: {err}"))?;
580    let val = results
581        .first()
582        .ok_or_else(|| anyhow!("describe returned no value"))?;
583    val_to_bytes(val).map_err(|err| anyhow!(err))
584}
585
586fn resolve_interface_index(
587    instance: &wasmtime::component::Instance,
588    store: &mut Store<BuildWasi>,
589    interface: &str,
590) -> Option<wasmtime::component::ComponentExportIndex> {
591    for candidate in interface_candidates(interface) {
592        if let Some(index) = instance.get_export_index(&mut *store, None, &candidate) {
593            return Some(index);
594        }
595    }
596    None
597}
598
599fn interface_candidates(interface: &str) -> [String; 3] {
600    [
601        interface.to_string(),
602        format!("greentic:component/{interface}@0.6.0"),
603        format!("greentic:component/{interface}"),
604    ]
605}
606
607fn val_to_bytes(val: &Val) -> Result<Vec<u8>, String> {
608    match val {
609        Val::List(items) => {
610            let mut out = Vec::with_capacity(items.len());
611            for item in items {
612                match item {
613                    Val::U8(byte) => out.push(*byte),
614                    _ => return Err("expected list<u8>".to_string()),
615                }
616            }
617            Ok(out)
618        }
619        _ => Err("expected list<u8>".to_string()),
620    }
621}
622
623fn strip_self_describe_tag(bytes: &[u8]) -> &[u8] {
624    const SELF_DESCRIBE_TAG: [u8; 3] = [0xd9, 0xd9, 0xf7];
625    if bytes.starts_with(&SELF_DESCRIBE_TAG) {
626        &bytes[SELF_DESCRIBE_TAG.len()..]
627    } else {
628        bytes
629    }
630}
631
632struct BuildWasi {
633    ctx: WasiCtx,
634    table: ResourceTable,
635}
636
637impl BuildWasi {
638    fn new() -> Result<Self> {
639        let ctx = WasiCtxBuilder::new().build();
640        Ok(Self {
641            ctx,
642            table: ResourceTable::new(),
643        })
644    }
645}
646
647impl WasiView for BuildWasi {
648    fn ctx(&mut self) -> WasiCtxView<'_> {
649        WasiCtxView {
650            ctx: &mut self.ctx,
651            table: &mut self.table,
652        }
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::sanitize_name;
659
660    #[test]
661    fn sanitize_name_preserves_hyphens_for_dist_artifacts() {
662        assert_eq!(
663            sanitize_name("wizard-smoke-advanced"),
664            "wizard-smoke-advanced"
665        );
666        assert_eq!(
667            sanitize_name("wizard_smoke_advanced"),
668            "wizard_smoke_advanced"
669        );
670    }
671}