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};
const NON_SOURCE_PACKAGE_FILES: &[&str] = &[
"Cargo.lock",
"Cargo.toml.orig",
".cargo_vcs_info.json",
veryl_metadata::COMMITTED_MANIFEST_FILE,
];
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())
}
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,
}
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,
}
}
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)
})
}
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)
}
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)
}
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)
}
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;
}
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)?;
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();
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);
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
);
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();
assert_eq!(committed_manifest_hash(&stamped).as_deref(), Some("abc123"));
assert_eq!(committed_manifest_hash(raw), None);
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 ["));
}
}