use std::env;
use std::fs;
use std::path::PathBuf;
#[path = "build_support/parser.rs"]
mod parser;
fn main() {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let spec_dir = manifest_dir.join("src/principles/spec");
let principles_dir = spec_dir.join("principles");
println!("cargo:rerun-if-changed=src/principles/spec/");
println!("cargo:rerun-if-changed=build_support/parser.rs");
emit_build_info(&manifest_dir);
emit_skill_hosts(&manifest_dir);
let spec_version = match fs::read_to_string(spec_dir.join("VERSION")) {
Ok(s) => s.trim().to_string(),
Err(_) => {
println!(
"cargo:warning=src/principles/spec/VERSION missing — emitting SPEC_VERSION = \"unknown\""
);
"unknown".to_string()
}
};
let entries = fs::read_dir(&principles_dir).unwrap_or_else(|e| {
panic!("cannot read {}: {e}", principles_dir.display());
});
let mut files: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
let name = match p.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => return false,
};
name.starts_with('p') && name.contains('-') && p.extension().is_some_and(|x| x == "md")
})
.collect();
files.sort();
if files.is_empty() {
panic!(
"no `p*-*.md` files in {} — did you run scripts/sync-spec.sh?",
principles_dir.display()
);
}
let mut parsed_per_file = Vec::with_capacity(files.len());
for path in &files {
let name = path
.file_name()
.expect("glob match always has a file name component")
.to_str()
.expect("principle filenames are ASCII per spec contract")
.to_string();
let content = fs::read_to_string(path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
let reqs = parser::parse_principle_file(&name, &content)
.unwrap_or_else(|e| panic!("\n spec parse error: {e}\n"));
parsed_per_file.push((name, reqs));
}
let aggregated = parser::aggregate(parsed_per_file)
.unwrap_or_else(|e| panic!("\n spec aggregate error: {e}\n"));
let rust_src = parser::emit_rust(&aggregated, &spec_version);
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
let out_path = out_dir.join("generated_requirements.rs");
fs::write(&out_path, rust_src)
.unwrap_or_else(|e| panic!("cannot write {}: {e}", out_path.display()));
}
fn emit_build_info(_manifest_dir: &std::path::Path) {
let version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".to_string());
let mut src = String::new();
src.push_str("// @generated by build.rs. Do not edit by hand.\n\n");
src.push_str("/// Crate version (`CARGO_PKG_VERSION`) at build time.\n");
src.push_str(&format!("pub const ANC_VERSION: &str = \"{version}\";\n"));
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
let out_path = out_dir.join("build_info.rs");
fs::write(&out_path, src)
.unwrap_or_else(|e| panic!("cannot write {}: {e}", out_path.display()));
}
fn emit_skill_hosts(manifest_dir: &std::path::Path) {
let skill_json_path = manifest_dir.join("src/skill_install/skill.json");
println!("cargo:rerun-if-changed=src/skill_install/skill.json");
let content = fs::read_to_string(&skill_json_path)
.unwrap_or_else(|e| panic!("read {}: {e}", skill_json_path.display()));
let manifest: serde_json::Value = serde_json::from_str(&content)
.unwrap_or_else(|e| panic!("parse {}: {e}", skill_json_path.display()));
let install = manifest
.get("install")
.and_then(|v| v.as_object())
.unwrap_or_else(|| {
panic!(
"{}: \"install\" must be an object (host -> command map)",
skill_json_path.display()
)
});
if install.is_empty() {
panic!(
"{}: install map is empty — at least one host required",
skill_json_path.display()
);
}
let mut hosts: Vec<(String, String, String, String)> = Vec::with_capacity(install.len());
for (key, cmd_value) in install {
let cmd = cmd_value.as_str().unwrap_or_else(|| {
panic!(
"{}: install.{key:?} must be a string",
skill_json_path.display()
)
});
let tokens: Vec<&str> = cmd.split_whitespace().collect();
if tokens.len() != 6
|| tokens[0] != "git"
|| tokens[1] != "clone"
|| tokens[2] != "--depth"
|| tokens[3] != "1"
{
panic!(
"{}: install.{key:?} must match `git clone --depth 1 <url> <dest>` (got {} tokens: {cmd:?})",
skill_json_path.display(),
tokens.len(),
);
}
let url = tokens[4].to_string();
let dest = tokens[5].to_string();
if dest.ends_with(".git") {
panic!(
"{}: install.{key:?} dest {dest:?} ends in `.git` — host commands must terminate with an explicit destination, not the bare repo name",
skill_json_path.display()
);
}
let variant = pascal_case(key).unwrap_or_else(|e| {
panic!(
"{}: install.{key:?} is not a valid Rust identifier: {e}",
skill_json_path.display()
)
});
hosts.push((key.clone(), variant, url, dest));
}
hosts.sort_by(|a, b| a.0.cmp(&b.0));
let mut src = String::new();
src.push_str(
"// @generated by build.rs from src/skill_install/skill.json. Do not edit by hand.\n",
);
src.push_str(
"// Add or remove hosts via the JSON file (or `bash scripts/sync-skill-fixture.sh`)\n",
);
src.push_str("// and `cargo build` regenerates this file.\n\n");
src.push_str("/// Hosts the binary knows how to install into. Surface names match\n");
src.push_str("/// `agentnative-site/src/data/skill.json` keys verbatim via\n");
src.push_str("/// `rename_all = \"snake_case\"`.\n");
src.push_str("#[derive(Clone, Copy, Debug, PartialEq, Eq, ::clap::ValueEnum)]\n");
src.push_str("#[value(rename_all = \"snake_case\")]\n");
src.push_str("pub enum SkillHost {\n");
for (_, variant, _, _) in &hosts {
src.push_str(&format!(" {variant},\n"));
}
src.push_str("}\n\n");
src.push_str(
"/// Host names accepted by `anc skill install <host>`, in JSON-key sort order.\n",
);
src.push_str("/// Surfaces externally for shell-completion enumeration and as the seed\n");
src.push_str("/// for a future `anc skill list` verb. Stays in lockstep with [`SkillHost`]\n");
src.push_str("/// variants because both are generated from the same source.\n");
src.push_str("#[allow(dead_code)]\n");
src.push_str("pub const KNOWN_HOSTS: &[&str] = &[\n");
for (key, _, _, _) in &hosts {
src.push_str(&format!(" {key:?},\n"));
}
src.push_str("];\n\n");
src.push_str("/// Resolve a host enum to its `(url, dest_template)` pair, parsed\n");
src.push_str("/// at build time from the install command in src/skill_install/skill.json.\n");
src.push_str("/// Pure function — no I/O, no side effects.\n");
src.push_str("pub fn resolve_host(host: SkillHost) -> (&'static str, &'static str) {\n");
src.push_str(" match host {\n");
for (_, variant, url, dest) in &hosts {
src.push_str(&format!(
" SkillHost::{variant} => ({url:?}, {dest:?}),\n"
));
}
src.push_str(" }\n");
src.push_str("}\n\n");
src.push_str("/// JSON-key string for the envelope's `host` field. Generated alongside\n");
src.push_str("/// the enum so the surface stays in lockstep with the JSON contract.\n");
src.push_str("pub fn host_envelope_str(host: SkillHost) -> &'static str {\n");
src.push_str(" match host {\n");
for (key, variant, _, _) in &hosts {
src.push_str(&format!(" SkillHost::{variant} => {key:?},\n"));
}
src.push_str(" }\n");
src.push_str("}\n");
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
let out_path = out_dir.join("generated_hosts.rs");
fs::write(&out_path, src)
.unwrap_or_else(|e| panic!("cannot write {}: {e}", out_path.display()));
}
fn pascal_case(snake: &str) -> Result<String, String> {
if snake.is_empty() {
return Err("empty identifier".into());
}
if snake.starts_with(|c: char| c.is_ascii_digit()) {
return Err(format!("{snake:?} starts with a digit"));
}
if !snake
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
return Err(format!(
"{snake:?} contains non-snake_case ASCII characters"
));
}
let mut out = String::with_capacity(snake.len());
for word in snake.split('_') {
let mut chars = word.chars();
if let Some(first) = chars.next() {
out.push(first.to_ascii_uppercase());
out.push_str(chars.as_str());
}
}
Ok(out)
}