lingshu-tools 0.10.0

Tool registry, ToolHandler trait, and 50+ tool implementations
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug)]
struct SkillData {
    name: String,
    files: Vec<(String, String)>,
}

fn main() {
    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
    let workspace_root = manifest_dir.join("../..");
    let bundled_root = workspace_root.join("skills");
    let optional_root = workspace_root.join("optional-skills");
    let out_path = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")).join("embedded_skills.rs");

    println!("cargo:rerun-if-changed={}", bundled_root.display());
    println!("cargo:rerun-if-changed={}", optional_root.display());
    println!("cargo:rerun-if-env-changed=PROFILE");

    let generated = if should_embed_skills() {
        let bundled = collect_skills(&bundled_root);
        let optional = collect_skills(&optional_root);
        render_embedded_skills(&bundled, &optional)
    } else {
        render_empty_embedded_skills()
    };

    fs::write(&out_path, generated).expect("write embedded skills");
}

/// Release-only embedding. Debug builds read `skills/` from disk via
/// `skills_sync::bundled_skills_dir()` — skipping embed avoids compiling ~7MB
/// of string literals on every `lingshu-tools` rebuild.
fn should_embed_skills() -> bool {
    env::var("PROFILE").is_ok_and(|p| p == "release")
}

fn collect_skills(root: &Path) -> Vec<SkillData> {
    let mut skills = Vec::new();
    if !root.is_dir() {
        return skills;
    }

    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let entries = match fs::read_dir(&dir) {
            Ok(entries) => entries,
            Err(_) => continue,
        };

        for entry in entries.flatten() {
            let path = entry.path();
            if !path.is_dir() {
                continue;
            }
            if should_skip_dir(&path) {
                continue;
            }

            let skill_md = path.join("SKILL.md");
            if skill_md.is_file() {
                let name = path
                    .strip_prefix(root)
                    .unwrap_or(&path)
                    .to_string_lossy()
                    .replace('\\', "/");
                let files = collect_skill_files(&path);
                skills.push(SkillData { name, files });
            } else {
                stack.push(path);
            }
        }
    }

    skills.sort_by(|a, b| a.name.cmp(&b.name));
    skills
}

fn should_skip_dir(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .map(|name| name == "__pycache__")
        .unwrap_or(false)
}

fn should_skip_file(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .map(|name| name.ends_with(".pyc"))
        .unwrap_or(false)
}

fn collect_skill_files(root: &Path) -> Vec<(String, String)> {
    let mut stack = vec![root.to_path_buf()];
    let mut files = BTreeMap::new();

    while let Some(dir) = stack.pop() {
        let entries = match fs::read_dir(&dir) {
            Ok(entries) => entries,
            Err(_) => continue,
        };

        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                if !should_skip_dir(&path) {
                    stack.push(path);
                }
                continue;
            }
            if !path.is_file() || should_skip_file(&path) {
                continue;
            }

            let rel = path
                .strip_prefix(root)
                .unwrap_or(&path)
                .to_string_lossy()
                .replace('\\', "/");

            if let Ok(content) = fs::read_to_string(&path) {
                files.insert(rel, content);
            }
        }
    }

    files.into_iter().collect()
}

fn render_empty_embedded_skills() -> String {
    "// @generated by build.rs; do not edit manually.\n\
     // Debug builds: empty — skills loaded from repo `skills/` at runtime.\n\
     const EMBEDDED_BUNDLED_SKILLS: &[EmbeddedSkill] = &[];\n\
     const EMBEDDED_OPTIONAL_SKILLS: &[EmbeddedSkill] = &[];\n"
        .to_string()
}

fn render_embedded_skills(bundled: &[SkillData], optional: &[SkillData]) -> String {
    let mut out = String::new();
    out.push_str("// @generated by build.rs; do not edit manually.\n");
    out.push_str("const EMBEDDED_BUNDLED_SKILLS: &[EmbeddedSkill] = &[\n");
    for skill in bundled {
        render_skill(&mut out, skill);
    }
    out.push_str("];\n\n");
    out.push_str("const EMBEDDED_OPTIONAL_SKILLS: &[EmbeddedSkill] = &[\n");
    for skill in optional {
        render_skill(&mut out, skill);
    }
    out.push_str("];\n");
    out
}

fn render_skill(out: &mut String, skill: &SkillData) {
    out.push_str("    EmbeddedSkill {\n");
    out.push_str(&format!("        name: {:?},\n", skill.name));
    out.push_str("        files: &[\n");
    for (rel, content) in &skill.files {
        out.push_str("            EmbeddedSkillFile {\n");
        out.push_str(&format!("                relative_path: {:?},\n", rel));
        out.push_str(&format!("                content: {:?},\n", content));
        out.push_str("            },\n");
    }
    out.push_str("        ],\n");
    out.push_str("    },\n");
}