use std::env;
use std::fs;
use std::path::{Path, PathBuf};
fn main() {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
emit_skill_hosts(&manifest_dir);
}
fn emit_skill_hosts(manifest_dir: &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 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("/// `src/skill_install/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 `xr skill install <host>`, in JSON-key sort order.\n");
src.push_str("/// Stays in lockstep with [`SkillHost`] because both are generated from the\n");
src.push_str("/// same source.\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 at build\n");
src.push_str("/// time from the install command in `src/skill_install/skill.json`. Pure\n");
src.push_str("/// 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 the\n");
src.push_str("/// 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)
}