cargo-tangle 0.7.0

A command-line tool to create and deploy blueprints on Tangle Network
use super::{
    BinaryArtifactSpec, BlueprintSource, ContainerSourceSpec, FetcherKind, GithubArtifactSpec,
    ImageRegistrySource, NativeSource, NativeSourceOverride, NativeSourceSpec,
    OnChainBlueprintBinary, RemoteArtifactSpec, SourceKind, SourceSpec, SourceSummary,
    SourceSummaryDetails, TestingSource, TestingSourceSpec, WasmSource, WasmSourceSpec,
    blueprint_source_kind,
};
use alloy_primitives::FixedBytes;
use color_eyre::eyre::{Result, eyre};
use std::collections::HashSet;
use url::Url;

impl NativeSourceOverride {
    #[must_use]
    pub(crate) fn github(entrypoint: String, spec: GithubArtifactSpec) -> Self {
        let binaries = spec.binaries.clone();
        Self {
            fetcher: FetcherKind::Github,
            entrypoint,
            github: Some(spec),
            remote: None,
            binaries,
        }
    }

    #[must_use]
    pub(crate) fn remote(
        entrypoint: String,
        fetcher: FetcherKind,
        spec: RemoteArtifactSpec,
    ) -> Self {
        let binaries = spec.binaries.clone();
        Self {
            fetcher,
            entrypoint,
            github: None,
            remote: Some(spec),
            binaries,
        }
    }

    pub(super) fn into_spec(&self) -> Result<NativeSourceSpec> {
        if self.fetcher == FetcherKind::None {
            return Err(eyre!(
                "cannot override native source with fetcher `none`, please select github/http/ipfs"
            ));
        }

        Ok(NativeSourceSpec {
            fetcher: self.fetcher,
            artifact_uri: String::new(),
            entrypoint: self.entrypoint.clone(),
            github: self.github.clone(),
            remote: self.remote.clone(),
            binaries: self.binaries.clone(),
            testing: None,
        })
    }
}

impl SourceSpec {
    pub(super) fn into_blueprint_source(self) -> Result<BlueprintSource> {
        let mut source = BlueprintSource {
            kind: 0,
            container: ImageRegistrySource {
                registry: String::new(),
                image: String::new(),
                tag: String::new(),
            },
            wasm: WasmSource {
                runtime: 0,
                fetcher: 0,
                artifactUri: String::new(),
                entrypoint: String::new(),
            },
            native: NativeSource {
                fetcher: 0,
                artifactUri: String::new(),
                entrypoint: String::new(),
            },
            testing: TestingSource {
                cargoPackage: String::new(),
                cargoBin: String::new(),
                basePath: String::new(),
            },
            binaries: Vec::new(),
        };
        match self {
            SourceSpec::Container(spec) => {
                let (container, binaries, testing) = spec.into_parts()?;
                source.kind = blueprint_source_kind(SourceKind::Container);
                source.container = container;
                source.testing = testing.unwrap_or_default().into_testing();
                source.binaries = binaries;
            }
            SourceSpec::Native(spec) => {
                let (native, binaries, testing) = spec.into_parts()?;
                source.kind = blueprint_source_kind(SourceKind::Native);
                source.native = native;
                source.testing = testing.unwrap_or_default().into_testing();
                source.binaries = binaries;
            }
            SourceSpec::Wasm(spec) => {
                let (wasm, binaries, testing) = spec.into_parts()?;
                source.kind = blueprint_source_kind(SourceKind::Wasm);
                source.wasm = wasm;
                source.testing = testing.unwrap_or_default().into_testing();
                source.binaries = binaries;
            }
        }
        Ok(source)
    }

    pub(super) fn summary(&self, index: usize) -> SourceSummary {
        match self {
            SourceSpec::Container(spec) => SourceSummary {
                index,
                kind: SourceKind::Container,
                fetcher: None,
                entrypoint: None,
                details: SourceSummaryDetails::Container {
                    registry: spec.registry.clone(),
                    image: spec.image.clone(),
                    tag: spec.tag.clone(),
                },
            },
            SourceSpec::Native(spec) => SourceSummary {
                index,
                kind: SourceKind::Native,
                fetcher: Some(spec.fetcher),
                entrypoint: Some(spec.entrypoint.clone()),
                details: SourceSummaryDetails::Native {
                    has_testing: spec.testing.is_some(),
                },
            },
            SourceSpec::Wasm(spec) => SourceSummary {
                index,
                kind: SourceKind::Wasm,
                fetcher: Some(spec.fetcher),
                entrypoint: Some(spec.entrypoint.clone()),
                details: SourceSummaryDetails::Wasm {
                    runtime: spec.runtime,
                },
            },
        }
    }
}

impl ContainerSourceSpec {
    fn into_parts(
        self,
    ) -> Result<(
        ImageRegistrySource,
        Vec<OnChainBlueprintBinary>,
        Option<TestingSourceSpec>,
    )> {
        let container = ImageRegistrySource {
            registry: self.registry,
            image: self.image,
            tag: self.tag,
        };
        let binaries = convert_binary_specs("container source", self.binaries)?;
        Ok((container, binaries, self.testing))
    }
}

impl NativeSourceSpec {
    fn into_parts(
        self,
    ) -> Result<(
        NativeSource,
        Vec<OnChainBlueprintBinary>,
        Option<TestingSourceSpec>,
    )> {
        if self.entrypoint.trim().is_empty() {
            return Err(eyre!("native source requires a non-empty entrypoint"));
        }

        let binaries = convert_binary_specs("native source", self.binaries)?;

        let artifact_uri = match self.fetcher {
            FetcherKind::Github => {
                let spec = self
                    .github
                    .ok_or_else(|| eyre!("github native source requires `github` metadata"))?;
                spec.validate()?;
                serde_json::to_string(&spec)
                    .map_err(|err| eyre!("failed to serialize GitHub artifact: {err}"))?
            }
            FetcherKind::Http | FetcherKind::Ipfs => {
                let spec = self
                    .remote
                    .ok_or_else(|| eyre!("http/ipfs native source requires `remote` metadata"))?;
                spec.validate()?;
                serde_json::to_string(&spec)
                    .map_err(|err| eyre!("failed to serialize remote artifact metadata: {err}"))?
            }
            FetcherKind::None => {
                if self.artifact_uri.trim().is_empty() {
                    return Err(eyre!(
                        "native fetcher `none` requires `artifact_uri` to be configured"
                    ));
                }
                self.artifact_uri
            }
        };

        let native = NativeSource {
            fetcher: self.fetcher.into_fetcher(),
            artifactUri: artifact_uri,
            entrypoint: self.entrypoint,
        };
        Ok((native, binaries, self.testing))
    }
}

impl WasmSourceSpec {
    fn into_parts(
        self,
    ) -> Result<(
        WasmSource,
        Vec<OnChainBlueprintBinary>,
        Option<TestingSourceSpec>,
    )> {
        let wasm = WasmSource {
            runtime: self.runtime.into_runtime(),
            fetcher: self.fetcher.into_fetcher(),
            artifactUri: self.artifact_uri,
            entrypoint: self.entrypoint,
        };
        let binaries = convert_binary_specs("wasm source", self.binaries)?;
        Ok((wasm, binaries, self.testing))
    }
}

impl TestingSourceSpec {
    fn into_testing(self) -> TestingSource {
        TestingSource {
            cargoPackage: self.cargo_package,
            cargoBin: self.cargo_bin,
            basePath: self.base_path,
        }
    }
}

impl GithubArtifactSpec {
    fn validate(&self) -> Result<()> {
        if self.owner.trim().is_empty() {
            return Err(eyre!("github native source requires a non-empty owner"));
        }
        if self.repo.trim().is_empty() {
            return Err(eyre!("github native source requires a non-empty repo"));
        }
        if self.tag.trim().is_empty() {
            return Err(eyre!("github native source requires a non-empty tag"));
        }
        if self.binaries.is_empty() {
            return Err(eyre!(
                "github native source binaries list must not be empty"
            ));
        }
        for binary in &self.binaries {
            binary.validate()?;
        }
        Ok(())
    }
}

impl RemoteArtifactSpec {
    fn validate(&self) -> Result<()> {
        Url::parse(&self.dist_url)
            .map_err(|err| eyre!("invalid dist_url `{}`: {err}", self.dist_url))?;
        Url::parse(&self.archive_url)
            .map_err(|err| eyre!("invalid archive_url `{}`: {err}", self.archive_url))?;
        Ok(())
    }
}

impl BinaryArtifactSpec {
    fn validate(&self) -> Result<()> {
        if self.name.trim().is_empty() {
            return Err(eyre!("binary name must not be empty"));
        }
        if self.arch.trim().is_empty() {
            return Err(eyre!("binary arch must not be empty"));
        }
        if self.os.trim().is_empty() {
            return Err(eyre!("binary os must not be empty"));
        }
        let _ = Self::parse_digest(&self.sha256, "sha256")?;
        if let Some(blake3) = &self.blake3 {
            let _ = Self::parse_digest(blake3, "blake3")?;
        }
        Ok(())
    }

    pub(super) fn parse_digest(value: &str, label: &str) -> Result<FixedBytes<32>> {
        let trimmed = value.trim().trim_start_matches("0x");
        let bytes =
            hex::decode(trimmed).map_err(|err| eyre!("invalid {label} digest `{value}`: {err}"))?;
        if bytes.len() != 32 {
            return Err(eyre!(
                "{label} digest must be 32 bytes (got {} bytes)",
                bytes.len()
            ));
        }
        let mut buf = [0u8; 32];
        buf.copy_from_slice(&bytes);
        Ok(FixedBytes::from(buf))
    }

    fn into_blueprint_binary(self) -> Result<OnChainBlueprintBinary> {
        let sha256 = Self::parse_digest(&self.sha256, "sha256")?;
        let arch = parse_architecture(&self.arch)?;
        let os = parse_operating_system(&self.os)?;
        Ok(OnChainBlueprintBinary {
            arch,
            os,
            name: self.name,
            sha256,
        })
    }
}

fn convert_binary_specs(
    label: &str,
    specs: Vec<BinaryArtifactSpec>,
) -> Result<Vec<OnChainBlueprintBinary>> {
    if specs.is_empty() {
        return Err(eyre!("{label} requires at least one binary descriptor"));
    }
    let mut dedup = HashSet::new();
    let mut converted = Vec::new();
    for spec in specs {
        spec.validate()?;
        let binary = spec.into_blueprint_binary()?;
        let digest = binary.sha256;
        if dedup.insert(digest) {
            converted.push(binary);
        }
    }
    Ok(converted)
}

fn parse_architecture(value: &str) -> Result<u8> {
    let normalized = value.trim().to_lowercase();
    let variant = match normalized.as_str() {
        "wasm32" | "wasm-32" => 0,
        "wasm64" | "wasm-64" => 1,
        "wasi32" | "wasi-32" => 2,
        "wasi64" | "wasi-64" => 3,
        "amd32" | "x86" | "i386" | "ia32" | "x86_32" => 4,
        "amd64" | "x86_64" | "x64" => 5,
        "arm32" | "armv7" | "armv6" | "arm" => 6,
        "arm64" | "aarch64" | "armv8" => 7,
        "riscv32" | "risc-v32" | "riscv-32" => 8,
        "riscv64" | "risc-v64" | "riscv-64" => 9,
        other => {
            return Err(eyre!(
                "unsupported binary architecture `{other}`, expected wasm32/64, wasi32/64, amd32/64, arm32/64, or riscv32/64"
            ));
        }
    };
    Ok(variant)
}

fn parse_operating_system(value: &str) -> Result<u8> {
    let normalized = value.trim().to_lowercase();
    let variant = match normalized.as_str() {
        "" => return Err(eyre!("binary os must not be empty")),
        "unknown" => 0,
        "linux" => 1,
        "windows" | "win32" | "win64" => 2,
        "macos" | "mac" | "osx" | "darwin" => 3,
        "bsd" | "freebsd" | "openbsd" | "netbsd" => 4,
        other => {
            return Err(eyre!(
                "unsupported binary operating system `{other}`, expected linux/windows/macos/bsd/unknown"
            ));
        }
    };
    Ok(variant)
}