virke-core 0.3.2

Core library for Virke — installs GSD to any supported AI coding runtime
Documentation
// build.rs — asset discovery and code generation for virke-core
// Walks 4 source directories from the workspace root (local dev) or crate directory
// (cargo install from registry), filters eligible files, and generates
// OUT_DIR/assets.rs containing the ASSETS static array with include_bytes!() for each file.

use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

fn main() {
    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
    let asset_root = find_asset_root(&manifest_dir);
    let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set");

    // D-03: Emit rerun-if-changed with ABSOLUTE paths (Pitfall 3 — relative paths may not work)
    // Note: Using single-colon `cargo:` syntax for MSRV 1.75 compatibility
    // (cargo:: double-colon syntax requires Rust 1.77+)
    for dir in &["commands", "agents", "hooks", "get-shit-done"] {
        println!(
            "cargo:rerun-if-changed={}",
            asset_root.join(dir).display()
        );
    }
    println!(
        "cargo:rerun-if-changed={}",
        manifest_dir.join("build.rs").display()
    );

    let source_dirs = ["commands", "agents", "hooks", "get-shit-done"];
    let mut entries: Vec<(String, PathBuf)> = Vec::new();

    for dir in &source_dirs {
        let abs_dir = asset_root.join(dir);
        if !abs_dir.exists() {
            panic!(
                "build.rs: required source directory not found: {}",
                abs_dir.display()
            );
        }
        for entry in WalkDir::new(&abs_dir).follow_links(false) {
            let entry = entry.expect("Failed to read directory entry");
            if !entry.file_type().is_file() {
                continue;
            }
            let abs_path = entry.path();
            let rel_path = abs_path
                .strip_prefix(&asset_root)
                .expect("File not under asset root");

            if !is_eligible(abs_path, rel_path) {
                continue;
            }

            // Convert to forward-slash string for cross-platform include_bytes!
            let rel_str = rel_path.to_string_lossy().replace('\\', "/");
            entries.push((rel_str, abs_path.to_path_buf()));
        }
    }

    if entries.is_empty() {
        panic!("build.rs: no assets found — check source directory paths and filter rules");
    }

    // Sort by relative path for deterministic output across builds
    entries.sort_by(|a, b| a.0.cmp(&b.0));

    let mut code = String::from("// Generated by build.rs — do not edit\n\n");

    // Category enum (D-07)
    code.push_str("#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n");
    code.push_str("pub enum Category {\n");
    code.push_str("    Command,\n");
    code.push_str("    Workflow,\n");
    code.push_str("    Agent,\n");
    code.push_str("    Hook,\n");
    code.push_str("    Other,\n");
    code.push_str("}\n\n");

    // Asset struct (D-04)
    code.push_str("pub struct Asset {\n");
    code.push_str("    pub path: &'static str,\n");
    code.push_str("    pub content: &'static [u8],\n");
    code.push_str("    pub category: Category,\n");
    code.push_str("}\n\n");

    // ASSETS static array
    code.push_str("pub static ASSETS: &[Asset] = &[\n");
    for (rel_str, abs_path) in &entries {
        // CRITICAL (Pitfall 1 + 2): Use absolute forward-slash paths in include_bytes!
        let abs_str = abs_path.to_string_lossy().replace('\\', "/");
        let category = classify(rel_str);
        code.push_str("    Asset {\n");
        code.push_str(&format!("        path: {:?},\n", rel_str));
        code.push_str(&format!(
            "        content: include_bytes!({:?}),\n",
            abs_str
        ));
        code.push_str(&format!("        category: {},\n", category));
        code.push_str("    },\n");
    }
    code.push_str("];\n");

    let dest = Path::new(&out_dir).join("assets.rs");
    fs::write(&dest, code).expect("Failed to write assets.rs");
}

/// Find the root directory that contains the 4 GSD asset directories.
///
/// In a crates.io install context (`cargo install virke`), the assets are
/// copied alongside the crate by the publish workflow, so they live directly
/// in CARGO_MANIFEST_DIR.
///
/// In a local workspace build, the assets live at the workspace root
/// (two directories above the crate's Cargo.toml).
fn find_asset_root(manifest_dir: &Path) -> PathBuf {
    // Crate-local assets (cargo install from registry — assets copied into crate dir by publish workflow)
    if manifest_dir.join("agents").exists() {
        return manifest_dir.to_path_buf();
    }
    // Workspace root (local development — assets at workspace root two levels up)
    match manifest_dir.join("../..").canonicalize() {
        Ok(root) if root.join("agents").exists() => root,
        _ => panic!(
            "build.rs: cannot find asset directories. Checked:\n  1. {}/agents\n  2. {}/../../agents",
            manifest_dir.display(),
            manifest_dir.display()
        ),
    }
}

/// Returns true if the file at abs_path/rel_path should be included in the asset bundle.
/// Applies rules D-08, D-09, D-10, D-11.
fn is_eligible(abs_path: &Path, rel_path: &Path) -> bool {
    // D-09: Skip dotfiles (any path component starting with '.')
    for component in rel_path.components() {
        let s = component.as_os_str().to_string_lossy();
        if s.starts_with('.') {
            return false;
        }
    }

    // D-09: Skip node_modules/, dist/, tests/ directories
    let rel_str = rel_path.to_string_lossy();
    if rel_str.contains("node_modules/")
        || rel_str.contains("/dist/")
        || rel_str.contains("/tests/")
    {
        return false;
    }

    // D-11: Exclude specific filenames
    let file_name = abs_path.file_name().unwrap_or_default().to_string_lossy();
    if file_name == "README.md" || file_name == "package.json" || file_name == "package-lock.json" {
        return false;
    }

    // D-08 + config.json edge case: allowlist by extension
    // .json added to include get-shit-done/templates/config.json (legitimate GSD content)
    // package.json and package-lock.json are excluded by name above
    matches!(
        abs_path.extension().and_then(|e| e.to_str()),
        Some("md") | Some("cjs") | Some("js") | Some("json")
    )
}

/// Classify a file by its relative path prefix into a Category variant.
/// Uses path-prefix matching per Claude's discretion (see CONTEXT.md).
fn classify(rel_path: &str) -> &'static str {
    if rel_path.starts_with("commands/") {
        "Category::Command"
    } else if rel_path.starts_with("get-shit-done/workflows/") {
        "Category::Workflow"
    } else if rel_path.starts_with("agents/") {
        "Category::Agent"
    } else if rel_path.starts_with("hooks/") {
        "Category::Hook"
    } else {
        // get-shit-done root .md files (gsd-*.md), templates, references, bin/ all → Other
        // This is consistent with the installer treating all get-shit-done/ as a single tree
        "Category::Other"
    }
}