use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
#[path = "build_support/cli_aot_manifest.rs"]
#[allow(dead_code)]
mod cli_aot_manifest;
#[path = "../harn-vm/build_support/codegen_fingerprint.rs"]
#[allow(dead_code)]
mod codegen_fingerprint;
const CLI_AOT_MANIFEST: &str = "generated/cli-bytecode-manifest.json";
const CLI_BYTECODE_MAGIC: &[u8; 8] = b"HARNBC\0\0";
fn main() {
ensure_git_hooks_installed();
emit_cli_script_bytecode();
emit_demo_sibling_assets();
emit_check_fingerprint();
let manifest_dir =
PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let portal_dist = manifest_dir.join("portal-dist");
let index = portal_dist.join("index.html");
if !index.exists() {
fs::create_dir_all(&portal_dist).expect("create portal-dist");
fs::write(
&index,
"<!doctype html><html><head><title>Harn portal not built</title></head>\
<body><h1>Harn portal not built</h1>\
<p>Run <code>./scripts/dev_setup.sh</code> or <code>make setup</code> \
to install portal dependencies and build the frontend, or run \
<code>npm --prefix crates/harn-cli/portal run build</code> directly, \
to populate \
<code>crates/harn-cli/portal-dist</code>.</p></body></html>",
)
.expect("write placeholder portal index.html");
let assets = portal_dist.join("assets").join("portal");
fs::create_dir_all(&assets).expect("create portal-dist assets dir");
for stub in ["app.js", "api.js", "styles.css"] {
let path = assets.join(stub);
if !path.exists() {
fs::write(&path, b"").expect("write placeholder portal asset");
}
}
}
println!("cargo:rerun-if-changed=portal-dist");
}
fn emit_check_fingerprint() {
use sha2::{Digest, Sha256};
let manifest_dir =
PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let crates_dir = manifest_dir
.parent()
.expect("harn-cli sits under crates/")
.to_path_buf();
let roots = [
crates_dir.join("harn-lint").join("src"),
manifest_dir.join("src").join("commands").join("check"),
manifest_dir.join("src").join("package"),
];
let mut files: Vec<PathBuf> = Vec::new();
for root in &roots {
println!("cargo:rerun-if-changed={}", root.display());
collect_rs_files(root, &mut files);
}
files.sort();
let mut hasher = Sha256::new();
for file in &files {
println!("cargo:rerun-if-changed={}", file.display());
let content = fs::read(file).unwrap_or_default();
let logical = file
.strip_prefix(&crates_dir)
.unwrap_or(file)
.to_string_lossy()
.replace('\\', "/");
hasher.update(logical.as_bytes());
hasher.update([0u8]);
hasher.update(&content);
hasher.update([0u8]);
}
let digest = hasher.finalize();
let mut hex = String::with_capacity(64);
for byte in digest {
hex.push_str(&format!("{byte:02x}"));
}
println!("cargo:rustc-env=HARN_CHECK_FINGERPRINT={hex}");
}
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_rs_files(&path, out);
} else if path.extension().is_some_and(|ext| ext == "rs") {
out.push(path);
}
}
}
fn emit_rerun_if_changed_recursive(path: &Path) {
println!("cargo:rerun-if-changed={}", path.display());
let Ok(entries) = fs::read_dir(path) else {
return;
};
let mut children: Vec<PathBuf> = entries
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.collect();
children.sort();
for child in children {
if child.is_dir() {
emit_rerun_if_changed_recursive(&child);
} else {
println!("cargo:rerun-if-changed={}", child.display());
}
}
}
fn ensure_git_hooks_installed() {
if std::env::var_os("HARN_DISABLE_AUTO_HOOK_SETUP").is_some() {
return;
}
let Ok(top) = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
else {
return;
};
if !top.status.success() {
return;
}
let toplevel = String::from_utf8_lossy(&top.stdout).trim().to_string();
if toplevel.is_empty() {
return;
}
let hooks_dir = PathBuf::from(&toplevel).join(".githooks");
if !hooks_dir.is_dir() {
return;
}
let current = Command::new("git")
.args(["config", "--get", "core.hooksPath"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
if current == ".githooks" {
return;
}
let _ = Command::new("git")
.args(["config", "core.hooksPath", ".githooks"])
.status();
}
fn emit_cli_script_bytecode() {
let manifest_dir =
PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let manifest_path = manifest_dir.join(CLI_AOT_MANIFEST);
println!("cargo:rerun-if-changed={}", manifest_path.display());
let manifest = cli_aot_manifest::read_manifest(&manifest_path).unwrap_or_else(|error| {
panic!("invalid CLI AOT manifest: {error}; run `make gen-cli-aot`");
});
let package_version = std::env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION");
assert_eq!(
manifest.harn_version, package_version,
"CLI AOT artifacts target Harn {}; expected {package_version}; run `make gen-cli-aot`",
manifest.harn_version
);
let vm_manifest_dir = manifest_dir
.parent()
.expect("harn-cli manifest dir has a parent")
.join("harn-vm");
let compiler_inputs = codegen_fingerprint::compiler_inputs(&vm_manifest_dir);
for input in &compiler_inputs {
println!("cargo:rerun-if-changed={}", input.disk_path.display());
}
for root in codegen_fingerprint::watch_roots(&vm_manifest_dir) {
println!("cargo:rerun-if-changed={}", root.display());
}
let compiler_fingerprint = codegen_fingerprint::fingerprint_inputs(&compiler_inputs);
assert_eq!(
manifest.compiler_fingerprint, compiler_fingerprint,
"CLI AOT compiler fingerprint is stale; run `make gen-cli-aot`"
);
for script in &manifest.scripts {
let source_path = resolve_manifest_path(&manifest_dir, &script.source_path, true);
println!("cargo:rerun-if-changed={}", source_path.display());
let source_sha256 = cli_aot_manifest::sha256_source_file(&source_path)
.unwrap_or_else(|error| panic!("validate CLI AOT source: {error}"));
assert_eq!(
script.source_sha256, source_sha256,
"CLI AOT source `{}` is stale; run `make gen-cli-aot`",
script.name
);
if let Some(artifact) = &script.artifact {
assert!(
artifact.path.starts_with("generated/cli-bytecode/"),
"CLI AOT artifact `{}` must live under generated/cli-bytecode/",
artifact.path
);
let artifact_path = resolve_manifest_path(&manifest_dir, &artifact.path, false);
println!("cargo:rerun-if-changed={}", artifact_path.display());
let bytes = fs::read(&artifact_path).unwrap_or_else(|error| {
panic!("read CLI AOT artifact {}: {error}", artifact_path.display());
});
assert!(
bytes.starts_with(CLI_BYTECODE_MAGIC),
"CLI AOT artifact `{}` has an invalid bytecode header",
script.name
);
assert_eq!(
artifact.sha256,
cli_aot_manifest::sha256_bytes(&bytes),
"CLI AOT artifact `{}` is stale; run `make gen-cli-aot`",
script.name
);
}
}
}
fn emit_demo_sibling_assets() {
let manifest_dir =
PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let demo_root = manifest_dir.join("assets").join("demo");
emit_rerun_if_changed_recursive(&demo_root);
let mut entries: Vec<(String, String)> = Vec::new();
collect_demo_siblings(&demo_root, &demo_root, &mut entries);
entries.sort();
let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR"));
write_demo_assets_table(&out_dir.join("demo_assets_table.rs"), &entries);
}
fn collect_demo_siblings(root: &Path, dir: &Path, entries: &mut Vec<(String, String)>) {
let Ok(read) = fs::read_dir(dir) else {
return;
};
let mut children: Vec<PathBuf> = read.filter_map(|e| e.ok().map(|e| e.path())).collect();
children.sort();
for path in children {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if path.is_dir() {
if name == ".harn" {
continue;
}
collect_demo_siblings(root, &path, entries);
} else {
if name == "scenario.harn" || name == "tape.jsonl" {
continue;
}
let Ok(rel) = path.strip_prefix(root) else {
continue;
};
let rel = rel.to_string_lossy().replace('\\', "/");
entries.push((rel, path.to_string_lossy().into_owned()));
}
}
}
fn write_demo_assets_table(path: &Path, entries: &[(String, String)]) {
let mut body = String::new();
body.push_str("// @generated by build.rs. Demo sibling files embedded for\n");
body.push_str("// `harn demo` multi-file scenarios. Do not edit by hand.\n");
body.push_str("pub(crate) const DEMO_SIBLING_FILES: &[(&str, &[u8])] = &[\n");
for (rel, file_path) in entries {
body.push_str(" (\"");
body.push_str(&escape_str(rel));
body.push_str("\", include_bytes!(\"");
body.push_str(&escape_str(file_path));
body.push_str("\")),\n");
}
body.push_str("];\n");
fs::write(path, body).expect("write demo_assets_table.rs");
}
fn resolve_manifest_path(manifest_dir: &Path, relative: &str, allow_crates_root: bool) -> PathBuf {
let relative = Path::new(relative);
assert!(
!relative.is_absolute(),
"CLI AOT manifest path must be relative: {}",
relative.display()
);
let path = manifest_dir.join(relative);
let canonical = path.canonicalize().unwrap_or_else(|error| {
panic!("resolve CLI AOT path {}: {error}", path.display());
});
let allowed_root = if allow_crates_root {
manifest_dir.parent().expect("harn-cli is under crates/")
} else {
manifest_dir
};
let allowed_root = allowed_root
.canonicalize()
.expect("resolve CLI AOT allowed root");
assert!(
canonical.starts_with(&allowed_root),
"CLI AOT manifest path escapes {}: {}",
allowed_root.display(),
canonical.display()
);
canonical
}
fn escape_str(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
c => out.push(c),
}
}
out
}