use std::path::Path;
use crate::build::{plan_packed_entries, BuildError, PackedPlan, PACKED_MANIFEST_ENTRY};
use crate::manifest::{load_manifest, Manifest};
use crate::manifest_path::resolve_manifest_path;
use crate::payload_shape::{is_root_wasm_candidate, CAPSULE_WASM_ENTRY};
use crate::registry::RuntimeType;
pub const W_BLD_001: &str = "W-BLD-001";
pub const W_BLD_002: &str = "W-BLD-002";
pub const W_BLD_003: &str = "W-BLD-003";
pub const RESERVED_ROOT_ENTRIES: [&str; 2] = [PACKED_MANIFEST_ENTRY, CAPSULE_WASM_ENTRY];
const DIAGNOSTICS_DOC_URL: &str =
"https://docs.murmur.nexus/murmur-nexus/murmur/reference/diagnostics/";
pub fn build_warning_link(code: &str) -> String {
format!("{DIAGNOSTICS_DOC_URL}#{}", code.to_lowercase())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BuildWarning {
pub code: &'static str,
pub message: String,
pub hint: String,
}
pub fn lint_build_warnings(
source_dir: &Path,
output_path: &Path,
) -> Result<Vec<BuildWarning>, BuildError> {
let manifest = load_manifest(&resolve_manifest_path(source_dir))?;
let plan = plan_packed_entries(source_dir, &manifest, output_path)?;
Ok(lint_plan(&manifest, &plan))
}
fn lint_plan(manifest: &Manifest, plan: &PackedPlan) -> Vec<BuildWarning> {
let mut warnings = Vec::new();
warnings.extend(reserved_name_shadowing(plan));
warnings.extend(capsule_wasm_ambiguity(plan));
warnings.extend(packaged_build_inputs(manifest, plan));
warnings
}
fn reserved_name_shadowing(plan: &PackedPlan) -> Vec<BuildWarning> {
plan.shadowed
.iter()
.filter(|dropped| RESERVED_ROOT_ENTRIES.contains(&dropped.archive_name.as_str()))
.map(|dropped| BuildWarning {
code: W_BLD_001,
message: format!(
"requires_files entry '{}' names the reserved archive entry '{}', which mur build already packs",
dropped.declared, dropped.archive_name
),
hint: format!(
"remove '{}' from requires_files: — the artifact is packed identically without it",
dropped.declared
),
})
.collect()
}
fn capsule_wasm_ambiguity(plan: &PackedPlan) -> Vec<BuildWarning> {
let root_wasm: Vec<&str> = plan
.entries
.iter()
.map(|entry| entry.archive_name.as_str())
.filter(|name| is_root_wasm_candidate(name))
.collect();
if !root_wasm.contains(&CAPSULE_WASM_ENTRY) {
return Vec::new();
}
let shadowed: Vec<&str> = root_wasm
.into_iter()
.filter(|name| *name != CAPSULE_WASM_ENTRY)
.collect();
if shadowed.is_empty() {
return Vec::new();
}
vec![BuildWarning {
code: W_BLD_002,
message: format!(
"root '{CAPSULE_WASM_ENTRY}' is always selected as the payload, so {} ships but never runs",
shadowed.join(", ")
),
hint: format!(
"keep exactly one root *.wasm: drop {CAPSULE_WASM_ENTRY}, or rename the payload you meant to run to {CAPSULE_WASM_ENTRY}"
),
}]
}
fn packaged_build_inputs(manifest: &Manifest, plan: &PackedPlan) -> Vec<BuildWarning> {
if !matches!(
manifest.registry_runtime(),
RuntimeType::Wasm | RuntimeType::Native
) {
return Vec::new();
}
let offenders: Vec<&str> = plan
.entries
.iter()
.map(|entry| entry.archive_name.as_str())
.filter(|name| is_build_input(name))
.collect();
if offenders.is_empty() {
return Vec::new();
}
vec![BuildWarning {
code: W_BLD_003,
message: format!(
"compiled artifact packages build inputs: {}",
offenders.join(", ")
),
hint: "remove them from requires_files: — a compiled artifact ships its payload, not the sources it was built from".to_string(),
}]
}
fn is_build_input(archive_name: &str) -> bool {
if archive_name.split('/').any(|segment| segment == "target") {
return true;
}
let file_name = archive_name.rsplit('/').next().unwrap_or(archive_name);
file_name == "Cargo.toml" || file_name == "Cargo.lock" || file_name.ends_with(".rs")
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
use crate::manifest_path::MANIFEST_FILENAME;
fn lint(manifest_yaml: &str, files: &[(&str, &[u8])]) -> Vec<BuildWarning> {
let dir = tempdir().unwrap();
fs::write(dir.path().join(MANIFEST_FILENAME), manifest_yaml).unwrap();
for (rel, bytes) in files {
let path = dir.path().join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
lint_build_warnings(dir.path(), &dir.path().join("out.mur.zip")).unwrap()
}
#[test]
fn link_lowercases_the_code_into_the_anchor() {
assert_eq!(
build_warning_link(W_BLD_001),
"https://docs.murmur.nexus/murmur-nexus/murmur/reference/diagnostics/#w-bld-001"
);
}
#[test]
fn a_clean_wasm_artifact_warns_about_nothing() {
let warnings = lint(
"name: clean-tool\nversion: 0.1.0\nruntime: wasm\nrequires_files:\n - tool.wasm\n",
&[("tool.wasm", b"\0asm")],
);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn declaring_the_manifest_warns_that_the_declaration_is_redundant() {
let warnings = lint(
"name: redundant\nversion: 0.1.0\nruntime: wasm\nrequires_files:\n - murmur.yaml\n - tool.wasm\n",
&[("tool.wasm", b"\0asm")],
);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert_eq!(warnings[0].code, W_BLD_001);
assert!(
warnings[0].message.contains(MANIFEST_FILENAME),
"warning should name the redundant declaration; got: {}",
warnings[0].message
);
}
#[test]
fn capsule_wasm_beside_another_root_wasm_warns_about_the_shadowed_payload() {
let warnings = lint(
"name: two-wasm\nversion: 0.1.0\nruntime: wasm\nrequires_files:\n - capsule.wasm\n - tool.wasm\n",
&[("capsule.wasm", b"\0asm"), ("tool.wasm", b"\0asm")],
);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert_eq!(warnings[0].code, W_BLD_002);
assert!(
warnings[0].message.contains("tool.wasm"),
"warning should name the shadowed payload; got: {}",
warnings[0].message
);
}
#[test]
fn a_lone_capsule_wasm_is_not_ambiguous() {
let warnings = lint(
"name: single\nversion: 0.1.0\nruntime: wasm\nrequires_files:\n - capsule.wasm\n - assets/nested.wasm\n",
&[("capsule.wasm", b"\0asm"), ("assets/nested.wasm", b"\0asm")],
);
assert!(
warnings.is_empty(),
"a nested wasm is not a root payload: {warnings:?}"
);
}
#[test]
fn packaged_build_inputs_are_named_in_one_warning() {
let warnings = lint(
"name: sources\nversion: 0.1.0\nruntime: wasm\nrequires_files:\n - tool.wasm\n - Cargo.toml\n - Cargo.lock\n - src/lib.rs\n - target/debug/stale.txt\n",
&[
("tool.wasm", b"\0asm"),
("Cargo.toml", b"[package]"),
("Cargo.lock", b"# lock"),
("src/lib.rs", b"fn main() {}"),
("target/debug/stale.txt", b"stale"),
],
);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert_eq!(warnings[0].code, W_BLD_003);
for expected in [
"Cargo.toml",
"Cargo.lock",
"src/lib.rs",
"target/debug/stale.txt",
] {
assert!(
warnings[0].message.contains(expected),
"warning should name {expected}; got: {}",
warnings[0].message
);
}
assert!(
!warnings[0].message.contains("tool.wasm"),
"the payload is not a build input; got: {}",
warnings[0].message
);
}
#[test]
fn a_static_artifact_may_ship_sources_without_a_warning() {
let warnings = lint(
"name: sample-skill\nversion: 0.1.0\nruntime: skill\nrequires_files:\n - skill.md\n - examples/demo.rs\n",
&[("skill.md", b"# skill"), ("examples/demo.rs", b"fn main(){}")],
);
assert!(
warnings.is_empty(),
"a skill's sources are its content: {warnings:?}"
);
}
#[test]
fn a_native_artifact_shipping_cargo_toml_warns() {
let warnings = lint(
"name: native-tool\nversion: 0.1.0\nruntime: tool\nimplementation: native\nrequires_files:\n - bin/native-tool\n - Cargo.toml\n",
&[("bin/native-tool", b"ELF"), ("Cargo.toml", b"[package]")],
);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert_eq!(warnings[0].code, W_BLD_003);
}
#[test]
fn a_file_merely_named_like_a_build_input_deeper_in_the_tree_still_warns() {
assert!(is_build_input("Cargo.toml"));
assert!(is_build_input("crates/inner/Cargo.toml"));
assert!(is_build_input("src/lib.rs"));
assert!(is_build_input("target/release/tool"));
assert!(!is_build_input("tool.wasm"));
assert!(!is_build_input("assets/targeting.txt"));
assert!(!is_build_input("notes/cargo.toml"));
}
}