veryl 0.20.3

A modern hardware description language
//! Published component artifacts generated by `veryl publish`:
//! - Prebuilt wasm for components declaring `wasm =`, built for
//!   wasm32-unknown-unknown, stamped with a `veryl.source_hash` custom
//!   section, and copied to the declared path.
//! - A committed `veryl.manifest.json` interface sidecar per component
//!   crate, so a fresh checkout — or a source-only / native component with
//!   no prebuilt wasm — stays analyzable without building.
//!
//! Both are keyed on a source hash so regeneration is idempotent: an
//! unchanged crate leaves the committed files untouched, keeping a clean
//! tree clean.

use crate::cmd_test::build_component_artifact;
use log::{info, warn};
use miette::{IntoDiagnostic, Result, WrapErr, bail};
use std::fs;
use std::path::{Path, PathBuf};
use veryl_metadata::Metadata;
use veryl_metadata::{append_wasm_custom_section, wasm_custom_section};

/// Files that `cargo package --list` reports but that must not enter the
/// source hash: cargo's synthetic packaging entries, and the committed
/// interface manifest (derived from these very sources, so hashing it would
/// make regeneration mark itself stale).
const NON_SOURCE_PACKAGE_FILES: &[&str] = &[
    "Cargo.lock",
    "Cargo.toml.orig",
    ".cargo_vcs_info.json",
    veryl_metadata::COMMITTED_MANIFEST_FILE,
];

/// Deterministic content hash of a component crate's packaged sources. The
/// file set comes from `cargo package --list`, so it honours the crate's
/// `include`/`exclude` and `.gitignore` rather than guessing at `src/**`
/// (a `build.rs` reading extra in-package files is now covered). Cargo's
/// synthetic entries and the committed `veryl.manifest.json` are excluded
/// (see [`NON_SOURCE_PACKAGE_FILES`]). Path dependencies still live outside
/// the package and are not covered.
pub fn component_source_hash(crate_dir: &Path) -> Result<String> {
    let output = std::process::Command::new("cargo")
        .args(["package", "--list", "--allow-dirty"])
        .current_dir(crate_dir)
        .output()
        .into_diagnostic()
        .wrap_err("running `cargo package --list`")?;
    if !output.status.success() {
        bail!(
            "`cargo package --list` failed in {}: {}",
            crate_dir.display(),
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }

    let mut files: Vec<String> = String::from_utf8_lossy(&output.stdout)
        .lines()
        .map(|line| line.trim().replace('\\', "/"))
        .filter(|line| !line.is_empty() && !NON_SOURCE_PACKAGE_FILES.contains(&line.as_str()))
        .collect();
    files.sort();

    let mut hasher = blake3::Hasher::new();
    for rel in &files {
        let content = fs::read(crate_dir.join(rel))
            .into_diagnostic()
            .wrap_err_with(|| format!("reading {rel}"))?;
        hasher.update(&(rel.len() as u64).to_le_bytes());
        hasher.update(rel.as_bytes());
        hasher.update(&(content.len() as u64).to_le_bytes());
        hasher.update(&content);
    }
    Ok(hasher.finalize().to_hex().to_string())
}

/// Payload of the `veryl.source_hash` custom section: the source hash and
/// the veryl version that generated the binary, newline-separated. The
/// version is stored beside the hash rather than mixed into it so that
/// consumers can tell a source change apart from a toolchain-only
/// difference (which they cannot fix for a dependency's prebuilt).
pub fn encode_source_stamp(hash: &str, version: &str) -> Vec<u8> {
    format!("{hash}\n{version}").into_bytes()
}

pub fn decode_source_stamp(payload: &[u8]) -> Option<(&str, &str)> {
    std::str::from_utf8(payload).ok()?.split_once('\n')
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrebuiltFreshness {
    Fresh,
    SourcesChanged,
    VerylVersionChanged,
}

/// Compares a stored source stamp against the current sources and veryl
/// version. An unreadable stamp counts as a source change. A version-only
/// difference is reported separately: it warrants regeneration of the
/// project's own prebuilts but is harmless for dependency packages.
pub fn prebuilt_freshness(stored: &[u8], hash: &str, version: &str) -> PrebuiltFreshness {
    match decode_source_stamp(stored) {
        Some((h, _)) if h != hash => PrebuiltFreshness::SourcesChanged,
        Some((_, v)) if v != version => PrebuiltFreshness::VerylVersionChanged,
        Some(_) => PrebuiltFreshness::Fresh,
        None => PrebuiltFreshness::SourcesChanged,
    }
}

/// Whether any `[dependencies]`-like table (including `[target.*]` ones)
/// declares a `path` dependency. Those sources live outside the component
/// crate, so the source hash cannot cover them.
fn cargo_toml_has_path_dependency(text: &str) -> bool {
    fn tables_have_path_dep(table: &toml::value::Table) -> bool {
        ["dependencies", "dev-dependencies", "build-dependencies"]
            .iter()
            .filter_map(|kind| table.get(*kind)?.as_table())
            .any(|deps| deps.values().any(|dep| dep.get("path").is_some()))
    }

    let Ok(root) = text.parse::<toml::value::Table>() else {
        return false;
    };
    tables_have_path_dep(&root)
        || root
            .get("target")
            .and_then(|t| t.as_table())
            .is_some_and(|targets| {
                targets
                    .values()
                    .filter_map(|t| t.as_table())
                    .any(tables_have_path_dep)
            })
}

/// Rebuilds every stale prebuilt wasm declared with `wasm =`. Returns the
/// wasm files that were (re)written (empty when all are fresh); the caller
/// stops publishing when any changed so the update gets committed first.
pub fn update_prebuilt_wasm(metadata: &Metadata) -> Result<Vec<PathBuf>> {
    let root = metadata.project_path();
    let target_dir = root.join("target/veryl-components");

    let veryl_version = env!("CARGO_PKG_VERSION");
    let mut written = Vec::new();
    for def in &metadata.components {
        let Some(wasm_rel) = &def.wasm else {
            continue;
        };
        let name = def.path.display();
        let crate_dir = root.join(&def.path);
        if let Ok(manifest) = fs::read_to_string(crate_dir.join("Cargo.toml"))
            && cargo_toml_has_path_dependency(&manifest)
        {
            warn!(
                "Component package ({name}) has path dependencies; they are not covered by staleness detection"
            );
        }
        let hash = component_source_hash(&crate_dir)
            .wrap_err_with(|| format!("hashing component package ({name}) sources"))?;
        let wasm_path = root.join(wasm_rel);
        if let Ok(existing) = fs::read(&wasm_path)
            && let Some(stored) =
                wasm_custom_section(&existing, veryl_component_sys::VRL_WASM_SOURCE_HASH_SECTION)
            && prebuilt_freshness(stored, &hash, veryl_version) == PrebuiltFreshness::Fresh
        {
            info!("Component package ({name}) prebuilt wasm is up to date");
            continue;
        }

        let artifact = build_component_artifact(&name.to_string(), &crate_dir, &target_dir, true);
        let Some((artifact, _)) = artifact else {
            bail!("component package ({name}) wasm build failed");
        };
        let mut bytes = fs::read(&artifact).into_diagnostic()?;
        append_wasm_custom_section(
            &mut bytes,
            veryl_component_sys::VRL_WASM_SOURCE_HASH_SECTION,
            &encode_source_stamp(&hash, veryl_version),
        );
        if let Some(parent) = wasm_path.parent() {
            fs::create_dir_all(parent).into_diagnostic()?;
        }
        fs::write(&wasm_path, bytes).into_diagnostic()?;
        info!(
            "Component package ({name}) prebuilt wasm written ({})",
            wasm_rel.display()
        );
        written.push(wasm_path);
    }
    Ok(written)
}

/// The `source_hash` stamp of a committed manifest JSON, if present.
fn committed_manifest_hash(json: &str) -> Option<String> {
    let v: serde_json::Value = serde_json::from_str(json).ok()?;
    v.get("source_hash")?.as_str().map(str::to_string)
}

/// Adds the `source_hash` stamp beside the manifest's `types` map. The
/// manifest reader ignores unknown top-level fields, so the stamp stays
/// invisible to the interface checks.
fn stamp_committed_manifest(json: &str, hash: &str) -> Result<String> {
    let mut v: serde_json::Value = serde_json::from_str(json).into_diagnostic()?;
    if let Some(obj) = v.as_object_mut() {
        obj.insert(
            "source_hash".to_string(),
            serde_json::Value::String(hash.to_string()),
        );
    }
    let mut out = serde_json::to_string_pretty(&v).into_diagnostic()?;
    out.push('\n');
    Ok(out)
}

/// Writes the committed `veryl.manifest.json` interface sidecar for every
/// component crate, so a fresh checkout — or a source-only / native
/// component that ships no prebuilt wasm — stays analyzable without
/// building. The interface is read from the crate's prebuilt wasm when one
/// exists (no extra build, and it covers wasm-only crates); otherwise a
/// native build extracts it. Skipped when the crate sources are unchanged.
/// Returns the files (re)written.
pub fn update_committed_manifests(metadata: &Metadata) -> Result<Vec<PathBuf>> {
    use veryl_metadata::COMMITTED_MANIFEST_FILE;

    let root = metadata.project_path();
    let target_dir = root.join("target/veryl-components");

    let mut written = Vec::new();
    for def in &metadata.components {
        let name = def.path.display();
        let crate_dir = root.join(&def.path);
        let manifest_path = crate_dir.join(COMMITTED_MANIFEST_FILE);
        let hash = component_source_hash(&crate_dir)
            .wrap_err_with(|| format!("hashing component package ({name}) sources"))?;
        if let Ok(existing) = fs::read_to_string(&manifest_path)
            && committed_manifest_hash(&existing).as_deref() == Some(hash.as_str())
        {
            info!("Component package ({name}) committed manifest is up to date");
            continue;
        }
        // Prefer the prebuilt wasm's own manifest (`update_prebuilt_wasm`
        // has already refreshed it); fall back to a native build for
        // source-only crates.
        let wasm = def
            .wasm
            .as_ref()
            .map(|w| root.join(w))
            .filter(|p| p.is_file());
        let json = match &wasm {
            Some(path) => veryl_simulator::component::loader::library_manifest(path),
            None => {
                match build_component_artifact(&name.to_string(), &crate_dir, &target_dir, false) {
                    Some((_, json)) => json,
                    None => {
                        warn!(
                            "Component package ({name}) build failed; committed manifest not written"
                        );
                        continue;
                    }
                }
            }
        };
        let Some(json) = json else {
            warn!(
                "Component package ({name}) library does not export a veryl manifest; committed manifest not written"
            );
            continue;
        };
        let contents = stamp_committed_manifest(&json, &hash)?;
        // Skip the rewrite when only the stamp would differ from an
        // identical on-disk file (keeps a clean tree clean).
        if fs::read_to_string(&manifest_path).ok().as_deref() != Some(contents.as_str()) {
            fs::write(&manifest_path, &contents).into_diagnostic()?;
            info!(
                "Component package ({name}) committed manifest written ({})",
                manifest_path.display()
            );
            written.push(manifest_path);
        }
    }
    Ok(written)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn source_hash_tracks_content() {
        let dir = std::env::temp_dir().join(format!("veryl_src_hash_{}", std::process::id()));
        let src = dir.join("src");
        fs::create_dir_all(&src).unwrap();
        // A valid manifest so `cargo package --list` can resolve the crate.
        fs::write(
            dir.join("Cargo.toml"),
            "[package]\nname = \"veryl-src-hash-test\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
        )
        .unwrap();
        fs::write(src.join("lib.rs"), "fn a() {}").unwrap();

        let h1 = component_source_hash(&dir).unwrap();
        assert_eq!(h1, component_source_hash(&dir).unwrap());

        fs::write(src.join("lib.rs"), "fn b() {}").unwrap();
        let h2 = component_source_hash(&dir).unwrap();
        assert_ne!(h1, h2);

        // A committed manifest is derived from the sources, so it must not
        // change the hash (else regeneration marks itself stale).
        fs::write(dir.join("veryl.manifest.json"), r#"{"types":{}}"#).unwrap();
        assert_eq!(h2, component_source_hash(&dir).unwrap());

        fs::write(dir.join("build.rs"), "fn main() {}").unwrap();
        assert_ne!(h2, component_source_hash(&dir).unwrap());

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn source_stamp_roundtrip() {
        let payload = encode_source_stamp("abc123", "0.20.2");
        assert_eq!(decode_source_stamp(&payload), Some(("abc123", "0.20.2")));
        assert_eq!(decode_source_stamp(b"no-newline"), None);
        assert_eq!(decode_source_stamp(&[0xff, b'\n', 0xfe]), None);
    }

    #[test]
    fn freshness_separates_sources_from_version() {
        let stamp = encode_source_stamp("h1", "0.20.2");
        assert_eq!(
            prebuilt_freshness(&stamp, "h1", "0.20.2"),
            PrebuiltFreshness::Fresh
        );
        assert_eq!(
            prebuilt_freshness(&stamp, "h2", "0.20.2"),
            PrebuiltFreshness::SourcesChanged
        );
        assert_eq!(
            prebuilt_freshness(&stamp, "h1", "0.20.3"),
            PrebuiltFreshness::VerylVersionChanged
        );
        // A source mismatch dominates a version mismatch.
        assert_eq!(
            prebuilt_freshness(&stamp, "h2", "0.20.3"),
            PrebuiltFreshness::SourcesChanged
        );
        assert_eq!(
            prebuilt_freshness(b"legacy-bare-hash", "h1", "0.20.2"),
            PrebuiltFreshness::SourcesChanged
        );
    }

    #[test]
    fn committed_manifest_stamp_is_transparent_to_readers() {
        let raw =
            r#"{"types":{"widget":{"kind":"method_only","methods":[{"name":"get","args":[]}]}}}"#;
        let stamped = stamp_committed_manifest(raw, "abc123").unwrap();

        // The stamp is recoverable for the freshness check.
        assert_eq!(committed_manifest_hash(&stamped).as_deref(), Some("abc123"));
        assert_eq!(committed_manifest_hash(raw), None);

        // The manifest reader ignores the stamp and still finds the type.
        let m = veryl_metadata::ComponentManifest::parse_from_library(&stamped, "widget").unwrap();
        assert_eq!(m.kind.as_deref(), Some("method_only"));
        assert!(m.method("get").is_some());
    }

    #[test]
    fn path_dependency_detection() {
        assert!(!cargo_toml_has_path_dependency(
            r#"
[package]
name = "c"
[dependencies]
serde = "1"
detailed = { version = "1", features = ["x"] }
"#
        ));
        assert!(cargo_toml_has_path_dependency(
            r#"
[dependencies]
local = { path = "../local" }
"#
        ));
        assert!(cargo_toml_has_path_dependency(
            r#"
[build-dependencies.gen]
path = "../gen"
"#
        ));
        assert!(cargo_toml_has_path_dependency(
            r#"
[target.'cfg(unix)'.dependencies]
local = { path = "../local" }
"#
        ));
        assert!(!cargo_toml_has_path_dependency("not toml ["));
    }
}