bird 0.2.0

X API CLI with entity caching, search, threads, and watchlists
Documentation
//! Build script. Codegen `SkillHost` enum, `KNOWN_HOSTS` const, and
//! `resolve_host` / `host_envelope_str` functions from
//! `src/skill_install/skill.json`.
//!
//! The manifest's `install` map is the single source of truth — updating the
//! JSON regenerates the Rust map on the next `cargo build` (via
//! `cargo:rerun-if-changed`). Mirrors the pattern in
//! `agentnative-cli/build.rs`.

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);
}

/// Emit `$OUT_DIR/generated_hosts.rs` from `src/skill_install/skill.json`.
///
/// Reads the manifest's `install` map and emits, for every `<host>` key:
///
/// - a `SkillHost` enum variant (PascalCase of the snake_case key) with
///   `clap::ValueEnum` derive + `#[value(rename_all = "snake_case")]` so
///   surface names round-trip back to the JSON key verbatim;
/// - an entry in `KNOWN_HOSTS: &[&str]`;
/// - a match arm in `resolve_host(SkillHost) -> (&'static str, &'static str)`
///   returning the `(url, dest_template)` parsed from the host's install
///   command;
/// - a match arm in `host_envelope_str(SkillHost) -> &'static str` returning
///   the canonical JSON-key surface name.
///
/// Each install command MUST have the canonical shape
/// `git clone --depth 1 <url> <dest>` — six whitespace-separated tokens.
/// Anything else panics the build with the offending host and command.
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()
        );
    }

    // Sorted by JSON key so the generated source has stable byte output.
    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()));
}

/// Convert a snake_case ASCII identifier to PascalCase. Rejects empty
/// strings, leading digits, and any character outside `[a-z0-9_]` so the
/// emitted variant is always a valid Rust identifier.
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)
}