bctx-weave 0.1.16

bctx-weave — FilterMesh lens pipeline, CLI interception, domain compression
Documentation
use std::path::Path;

use super::Recipe;
use crate::recipe::compiler::CompiledRecipe;

/// Load all `*.toml` files from `dir` as `Recipe` values.
/// Files that fail to parse are silently skipped with a warning on stderr.
pub fn load_from_dir(dir: &Path) -> anyhow::Result<Vec<Recipe>> {
    let mut recipes = Vec::new();
    if !dir.exists() {
        return Ok(recipes);
    }
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        if path.extension().map(|e| e == "toml").unwrap_or(false) {
            match std::fs::read_to_string(&path) {
                Ok(content) => match toml::from_str::<Recipe>(&content) {
                    Ok(r) => recipes.push(r),
                    Err(e) => {
                        eprintln!("bctx recipe: skipping {:?}: {e}", path.file_name());
                    }
                },
                Err(e) => {
                    eprintln!("bctx recipe: cannot read {:?}: {e}", path.file_name());
                }
            }
        }
    }
    Ok(recipes)
}

/// Load and compile recipes from `{root}/.bctx/recipes/`.
pub fn load_project_recipes(root: &Path) -> Vec<CompiledRecipe> {
    let dir = root.join(".bctx").join("recipes");
    compile_dir(&dir)
}

/// Load and compile recipes from `~/.config/bctx/recipes/`.
pub fn load_user_recipes() -> Vec<CompiledRecipe> {
    let home = std::env::var("HOME").unwrap_or_default();
    if home.is_empty() {
        return Vec::new();
    }
    let dir = std::path::PathBuf::from(home)
        .join(".config")
        .join("bctx")
        .join("recipes");
    compile_dir(&dir)
}

/// Project recipes first (higher priority), then user-level recipes.
pub fn load_all_recipes(root: &Path) -> Vec<CompiledRecipe> {
    let mut recipes = load_project_recipes(root);
    recipes.extend(load_user_recipes());
    recipes
}

fn compile_dir(dir: &Path) -> Vec<CompiledRecipe> {
    let raw = match load_from_dir(dir) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("bctx recipe: cannot read directory {dir:?}: {e}");
            return Vec::new();
        }
    };
    let mut compiled = Vec::new();
    for recipe in raw {
        match CompiledRecipe::compile(recipe) {
            Ok(c) => compiled.push(c),
            Err(e) => eprintln!("bctx recipe: compile error: {e}"),
        }
    }
    compiled
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::TempDir;

    fn write_recipe(dir: &Path, name: &str, content: &str) {
        let path = dir.join(name);
        let mut f = std::fs::File::create(path).unwrap();
        f.write_all(content.as_bytes()).unwrap();
    }

    #[test]
    fn load_valid_toml() {
        let tmp = TempDir::new().unwrap();
        write_recipe(
            tmp.path(),
            "my-tool.toml",
            "name = \"my-tool build\"\nmatch_command = \"my-tool build\"\nlens = [\"clarity\"]\nstrip_lines = [\"^Compiling.*\"]\non_empty = \"my-tool: built\"\n",
        );
        let recipes = load_from_dir(tmp.path()).unwrap();
        assert_eq!(recipes.len(), 1);
        assert_eq!(recipes[0].name, "my-tool build");
        assert_eq!(recipes[0].strip_lines, vec!["^Compiling.*"]);
    }

    #[test]
    fn skips_invalid_toml() {
        let tmp = TempDir::new().unwrap();
        write_recipe(tmp.path(), "bad.toml", "not valid toml [[[");
        let recipes = load_from_dir(tmp.path()).unwrap();
        assert!(recipes.is_empty());
    }

    #[test]
    fn load_empty_dir() {
        let tmp = TempDir::new().unwrap();
        let recipes = load_from_dir(tmp.path()).unwrap();
        assert!(recipes.is_empty());
    }

    #[test]
    fn load_nonexistent_dir() {
        let dir = std::path::PathBuf::from("/nonexistent/path/recipes");
        let recipes = load_from_dir(&dir).unwrap();
        assert!(recipes.is_empty());
    }

    #[test]
    fn compile_dir_skips_bad_regex() {
        let tmp = TempDir::new().unwrap();
        write_recipe(
            tmp.path(),
            "bad_regex.toml",
            "name = \"broken\"\nmatch_command = \"[[invalid regex\"\nstrip_lines = []\n",
        );
        let compiled = compile_dir(tmp.path());
        assert!(compiled.is_empty());
    }

    #[test]
    fn load_all_project_takes_priority() {
        let tmp = TempDir::new().unwrap();
        let bctx_dir = tmp.path().join(".bctx").join("recipes");
        std::fs::create_dir_all(&bctx_dir).unwrap();
        write_recipe(
            &bctx_dir,
            "proj.toml",
            "name = \"project recipe\"\nmatch_command = \"my-tool\"\nstrip_lines = []\n",
        );
        let all = load_all_recipes(tmp.path());
        assert!(!all.is_empty());
        assert_eq!(all[0].recipe.name, "project recipe");
    }
}