crosslink 0.5.2

A synced issue tracker CLI for multi-agent AI development
Documentation
//! Build script to track include_str! dependencies, inject git metadata,
//! and auto-generate rule file includes from resources/crosslink/rules/.

use std::fs;
use std::io::Write;
use std::path::Path;

fn main() {
    // Inject git commit hash into the binary for `crosslink --version`
    println!("cargo:rerun-if-changed=../.git/HEAD");
    println!("cargo:rerun-if-changed=../.git/refs/");
    if let Ok(output) = std::process::Command::new("git")
        .args(["rev-parse", "--short", "HEAD"])
        .output()
    {
        if output.status.success() {
            let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
            let dirty = std::process::Command::new("git")
                .args(["status", "--porcelain"])
                .output()
                .map(|o| !o.stdout.is_empty())
                .unwrap_or(false);
            let suffix = if dirty {
                format!("{}+{}-dirty", env!("CARGO_PKG_VERSION"), hash)
            } else {
                format!("{}+{}", env!("CARGO_PKG_VERSION"), hash)
            };
            println!("cargo:rustc-env=CROSSLINK_VERSION={}", suffix);
        }
    }

    // Track claude resource files
    println!("cargo:rerun-if-changed=resources/claude/settings.json");
    println!("cargo:rerun-if-changed=resources/claude/hooks/prompt-guard.py");
    println!("cargo:rerun-if-changed=resources/claude/hooks/post-edit-check.py");
    println!("cargo:rerun-if-changed=resources/claude/hooks/session-start.py");
    println!("cargo:rerun-if-changed=resources/claude/hooks/pre-web-check.py");
    println!("cargo:rerun-if-changed=resources/claude/hooks/work-check.py");
    println!("cargo:rerun-if-changed=resources/claude/mcp/safe-fetch-server.py");
    println!("cargo:rerun-if-changed=resources/mcp.json");
    println!("cargo:rerun-if-changed=resources/claude/commands/workflow.md");

    // Track crosslink config
    println!("cargo:rerun-if-changed=resources/crosslink/hook-config.json");

    // Auto-discover and track all rule files in resources/crosslink/rules/
    println!("cargo:rerun-if-changed=resources/crosslink/rules/");
    let rules_dir = Path::new("resources/crosslink/rules");
    if rules_dir.is_dir() {
        if let Err(e) = generate_rules_file(rules_dir) {
            eprintln!("cargo:warning=Failed to generate rules_gen.rs: {}", e);
        }
    }

    // Auto-discover and track all command files in resources/claude/commands/
    println!("cargo:rerun-if-changed=resources/claude/commands/");
    let commands_dir = Path::new("resources/claude/commands");
    if commands_dir.is_dir() {
        if let Err(e) = generate_commands_file(commands_dir) {
            eprintln!("cargo:warning=Failed to generate commands_gen.rs: {}", e);
        }
    }
}

fn generate_commands_file(commands_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let mut cmd_entries: Vec<(String, String)> = Vec::new();

    let mut entries: Vec<_> = fs::read_dir(commands_dir)?
        .filter_map(|e| e.ok())
        .filter(|e| e.file_name().to_string_lossy().ends_with(".md"))
        .collect();
    entries.sort_by_key(|e| e.file_name());

    for entry in entries {
        let filename = entry.file_name().to_string_lossy().to_string();
        let rel_path = format!("resources/claude/commands/{}", filename);
        println!("cargo:rerun-if-changed={}", rel_path);

        // Generate a const name: crosslink-guide.md -> CMD_CROSSLINK_GUIDE
        let const_name = filename
            .trim_end_matches(".md")
            .to_uppercase()
            .replace('-', "_");
        let const_name = format!("CMD_{}", const_name);

        cmd_entries.push((filename, const_name));
    }

    let out_dir = std::env::var("OUT_DIR")?;
    let gen_path = Path::new(&out_dir).join("commands_gen.rs");
    let mut gen_file = fs::File::create(&gen_path)?;

    writeln!(
        gen_file,
        "// Auto-generated by build.rs — do not edit manually"
    )?;
    writeln!(gen_file, "// Generated from resources/claude/commands/")?;
    writeln!(gen_file)?;

    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?;
    let abs_commands_dir = Path::new(&manifest_dir).join("resources/claude/commands");

    for (filename, const_name) in &cmd_entries {
        let abs_path = abs_commands_dir.join(filename);
        // Use forward slashes for include_str! paths — backslashes on Windows
        // are interpreted as escape sequences inside string literals.
        let abs_path_str = abs_path.to_string_lossy().replace('\\', "/");
        writeln!(
            gen_file,
            "pub(crate) const {}: &str = include_str!(\"{}\");",
            const_name, abs_path_str
        )?;
    }

    writeln!(gen_file)?;
    writeln!(
        gen_file,
        "pub(crate) const COMMAND_FILES: &[(&str, &str)] = &["
    )?;
    for (filename, const_name) in &cmd_entries {
        writeln!(gen_file, "    (\"{}\", {}),", filename, const_name)?;
    }
    writeln!(gen_file, "];")?;

    Ok(())
}

fn generate_rules_file(rules_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let mut rule_entries: Vec<(String, String)> = Vec::new();

    let mut entries: Vec<_> = fs::read_dir(rules_dir)?
        .filter_map(|e| e.ok())
        .filter(|e| {
            let name = e.file_name().to_string_lossy().to_string();
            name.ends_with(".md") || name.ends_with(".txt")
        })
        .collect();
    entries.sort_by_key(|e| e.file_name());

    for entry in entries {
        let filename = entry.file_name().to_string_lossy().to_string();
        let rel_path = format!("resources/crosslink/rules/{}", filename);
        println!("cargo:rerun-if-changed={}", rel_path);

        // Generate a const name from filename: quality.md -> RULE_QUALITY
        let const_name = filename
            .trim_end_matches(".md")
            .trim_end_matches(".txt")
            .to_uppercase()
            .replace('-', "_");
        let const_name = format!("RULE_{}", const_name);

        rule_entries.push((filename, const_name));
    }

    // Generate rules_gen.rs with all includes and the RULE_FILES array
    let out_dir = std::env::var("OUT_DIR")?;
    let gen_path = Path::new(&out_dir).join("rules_gen.rs");
    let mut gen_file = fs::File::create(&gen_path)?;

    writeln!(
        gen_file,
        "// Auto-generated by build.rs — do not edit manually"
    )?;
    writeln!(gen_file, "// Generated from resources/crosslink/rules/")?;
    writeln!(gen_file)?;

    // Use absolute path for include_str! since OUT_DIR is deep in target/
    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?;
    let abs_rules_dir = Path::new(&manifest_dir).join("resources/crosslink/rules");

    // Write include_str! constants
    for (filename, const_name) in &rule_entries {
        let abs_path = abs_rules_dir.join(filename);
        // Use forward slashes for include_str! paths — backslashes on Windows
        // are interpreted as escape sequences inside string literals.
        let abs_path_str = abs_path.to_string_lossy().replace('\\', "/");
        writeln!(
            gen_file,
            "pub(crate) const {}: &str = include_str!(\"{}\");",
            const_name, abs_path_str
        )?;
    }

    writeln!(gen_file)?;

    // Write the RULE_FILES array
    writeln!(
        gen_file,
        "pub(crate) const RULE_FILES: &[(&str, &str)] = &["
    )?;
    for (filename, const_name) in &rule_entries {
        writeln!(gen_file, "    (\"{}\", {}),", filename, const_name)?;
    }
    writeln!(gen_file, "];")?;

    Ok(())
}