bctx-weave 0.1.29

bctx-weave — FilterMesh lens pipeline, CLI interception, domain compression
Documentation
use regex::Regex;

use super::Recipe;

const VALID_LENSES: &[&str] = &["clarity", "focus", "narrow", "depth", "wide"];

/// Validate a recipe and return a list of human-readable error strings.
/// An empty return means the recipe is valid.
pub fn validate(recipe: &Recipe) -> Vec<String> {
    let mut errors = Vec::new();

    if recipe.name.is_empty() {
        errors.push("recipe name is empty".into());
    }
    if recipe.match_command.is_empty() {
        errors.push("match_command is empty".into());
    } else if let Err(e) = Regex::new(&recipe.match_command) {
        errors.push(format!("match_command is not a valid regex: {e}"));
    }

    for (i, pat) in recipe.strip_lines.iter().enumerate() {
        if let Err(e) = Regex::new(pat) {
            errors.push(format!(
                "strip_lines[{i}] \"{pat}\" is not a valid regex: {e}"
            ));
        }
    }

    for (i, rule) in recipe.replace.iter().enumerate() {
        if let Err(e) = Regex::new(&rule.from) {
            errors.push(format!(
                "replace[{i}].from \"{}\" is not a valid regex: {e}",
                rule.from
            ));
        }
    }

    for lens in &recipe.lens {
        if !VALID_LENSES.contains(&lens.as_str()) {
            errors.push(format!(
                "unknown lens \"{lens}\" — valid values: {}",
                VALID_LENSES.join(", ")
            ));
        }
    }

    if let Some(budget) = recipe.budget_tokens {
        if budget == 0 {
            errors.push("budget_tokens must be > 0".into());
        }
    }

    errors
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::recipe::RecipeReplace;

    fn base_recipe() -> Recipe {
        Recipe {
            name: "test".into(),
            match_command: "my-tool build".into(),
            lens: vec![],
            budget_tokens: None,
            strip_lines: vec![],
            replace: vec![],
            on_empty: None,
            scan_secrets: true,
        }
    }

    #[test]
    fn valid_recipe_no_errors() {
        let r = base_recipe();
        assert!(validate(&r).is_empty());
    }

    #[test]
    fn empty_name_error() {
        let mut r = base_recipe();
        r.name = String::new();
        assert!(validate(&r).iter().any(|e| e.contains("name")));
    }

    #[test]
    fn invalid_match_command_regex() {
        let mut r = base_recipe();
        r.match_command = "[[invalid".into();
        assert!(validate(&r).iter().any(|e| e.contains("match_command")));
    }

    #[test]
    fn invalid_strip_line_regex() {
        let mut r = base_recipe();
        r.strip_lines = vec!["[[bad".into()];
        assert!(validate(&r).iter().any(|e| e.contains("strip_lines")));
    }

    #[test]
    fn invalid_replace_from_regex() {
        let mut r = base_recipe();
        r.replace = vec![RecipeReplace {
            from: "[[bad".into(),
            to: "ok".into(),
        }];
        assert!(validate(&r).iter().any(|e| e.contains("replace")));
    }

    #[test]
    fn unknown_lens_name() {
        let mut r = base_recipe();
        r.lens = vec!["nonexistent".into()];
        assert!(validate(&r).iter().any(|e| e.contains("lens")));
    }

    #[test]
    fn zero_budget_error() {
        let mut r = base_recipe();
        r.budget_tokens = Some(0);
        assert!(validate(&r).iter().any(|e| e.contains("budget")));
    }
}