bctx-weave 0.1.14

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

use forge::budget::estimator::TokenEstimator;
use forge::signal::compactor;
use regex::Regex;

use super::Recipe;
use crate::lenses::clarity::ClarityLens;
use crate::lenses::depth::DepthLens;
use crate::lenses::focus::FocusLens;
use crate::lenses::narrow::NarrowLens;
use crate::lenses::wide::WideLens;
use crate::lenses::{Lens, LensContext};
use crate::mesh::node::{FallbackPolicy, MeshNode};

/// A recipe compiled into executable form.
pub struct CompiledRecipe {
    pub recipe: Recipe,
    /// Regex matching the full command string.
    pub matcher: Regex,
    /// Compiled strip patterns.
    strip_res: Vec<Regex>,
    /// Compiled replace patterns with their substitution strings.
    replace_res: Vec<(Regex, String)>,
}

impl CompiledRecipe {
    /// Compile a `Recipe` into executable form.  Returns an error if any
    /// regex fails to compile.
    pub fn compile(recipe: Recipe) -> anyhow::Result<Self> {
        let matcher = Regex::new(&recipe.match_command)?;

        let mut strip_res = Vec::new();
        for pat in &recipe.strip_lines {
            strip_res.push(Regex::new(pat)?);
        }

        let mut replace_res = Vec::new();
        for rule in &recipe.replace {
            replace_res.push((Regex::new(&rule.from)?, rule.to.clone()));
        }

        Ok(Self {
            recipe,
            matcher,
            strip_res,
            replace_res,
        })
    }

    /// Returns true if this recipe handles the given full command string.
    pub fn matches(&self, command: &str) -> bool {
        self.matcher.is_match(command)
    }

    /// Apply the recipe's strip/replace/budget logic to raw output.
    pub fn apply(&self, raw: &str) -> String {
        let cleaned = compactor::normalise(raw);

        // Strip matching lines
        let mut lines: Vec<String> = cleaned
            .lines()
            .filter(|l| {
                let t = l.trim();
                !t.is_empty() && !self.strip_res.iter().any(|re| re.is_match(t))
            })
            .map(|l| l.to_string())
            .collect();

        // Apply replacements in-place
        for line in &mut lines {
            for (re, rep) in &self.replace_res {
                *line = re.replace_all(line, rep.as_str()).into_owned();
            }
        }

        // Budget truncation — drop lines from the tail until under budget
        if let Some(budget) = self.recipe.budget_tokens {
            while !lines.is_empty() {
                let current = lines.join("\n");
                if TokenEstimator::count_nonblocking(&current) <= budget {
                    break;
                }
                lines.pop();
            }
        }

        let result = lines.join("\n");
        if result.trim().is_empty() {
            if let Some(msg) = &self.recipe.on_empty {
                return msg.clone();
            }
        }
        result
    }

    /// Convert into a `MeshNode` that can be registered with `FilterMesh`.
    /// Recipe nodes use their own `apply` as the preprocessor, then run
    /// the declared lens stack on top.
    pub fn to_mesh_node(self) -> MeshNode {
        let arc = Arc::new(self);

        let name = arc.recipe.name.clone();

        // Build lens stack from the recipe's `lens` field.
        let lens_stack: Vec<Box<dyn Lens>> = arc
            .recipe
            .lens
            .iter()
            .filter_map(|l| lens_by_name(l.as_str()))
            .collect();

        // Clone the Arc for the matcher closure.
        let arc_match = Arc::clone(&arc);
        let matcher: crate::mesh::node::MatcherFn = Box::new(move |prog: &str, args: &[String]| {
            let full = format!("{} {}", prog, args.join(" "));
            arc_match.matches(full.trim())
        });

        // Clone the Arc for the preprocessor closure.
        let arc_pre = Arc::clone(&arc);
        let preprocessor: crate::mesh::node::PreprocessorFn =
            Box::new(move |raw: &str, _ctx: &LensContext| arc_pre.apply(raw));

        MeshNode {
            name,
            matcher,
            lens_stack,
            fallback: FallbackPolicy::Passthrough,
            preprocessor: Some(preprocessor),
        }
    }
}

fn lens_by_name(name: &str) -> Option<Box<dyn Lens>> {
    match name {
        "clarity" => Some(Box::new(ClarityLens)),
        "focus" => Some(Box::new(FocusLens)),
        "narrow" => Some(Box::new(NarrowLens)),
        "depth" => Some(Box::new(DepthLens)),
        "wide" => Some(Box::new(WideLens)),
        _ => None,
    }
}

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

    fn make_recipe(
        strip_lines: Vec<String>,
        replace: Vec<RecipeReplace>,
        budget: Option<usize>,
        on_empty: Option<String>,
    ) -> CompiledRecipe {
        CompiledRecipe::compile(Recipe {
            name: "test".into(),
            match_command: "my-tool build".into(),
            lens: vec![],
            budget_tokens: budget,
            strip_lines,
            replace,
            on_empty,
            scan_secrets: true,
        })
        .unwrap()
    }

    #[test]
    fn strips_matching_lines() {
        let c = make_recipe(vec!["^Downloading.*".into()], vec![], None, None);
        let raw = "Downloading foo.tar.gz\nDownloading bar.tar.gz\nInstalled 2 packages\n";
        let out = c.apply(raw);
        assert!(!out.contains("Downloading"), "{out}");
        assert!(out.contains("Installed"), "{out}");
    }

    #[test]
    fn applies_replace_rules() {
        let c = make_recipe(
            vec![],
            vec![RecipeReplace {
                from: r"error\[E\d+\]".into(),
                to: "ERROR".into(),
            }],
            None,
            None,
        );
        let raw = "error[E0001]: something went wrong\n";
        let out = c.apply(raw);
        assert!(out.contains("ERROR: something went wrong"), "{out}");
        assert!(!out.contains("E0001"), "{out}");
    }

    #[test]
    fn respects_budget_tokens() {
        let c = make_recipe(vec![], vec![], Some(10), None);
        let raw: String = (0..50)
            .map(|i| format!("line {i} with padding text\n"))
            .collect();
        let out = c.apply(&raw);
        assert!(TokenEstimator::count_nonblocking(&out) <= 10, "{out}");
    }

    #[test]
    fn on_empty_fallback() {
        let c = make_recipe(
            vec![".*".into()],
            vec![],
            None,
            Some("my-tool: completed successfully".into()),
        );
        let raw = "some noisy line that gets stripped\n";
        let out = c.apply(raw);
        assert_eq!(out, "my-tool: completed successfully");
    }

    #[test]
    fn matcher_checks_full_command() {
        let c = CompiledRecipe::compile(Recipe {
            name: "x".into(),
            match_command: r"^my-tool build".into(),
            lens: vec![],
            budget_tokens: None,
            strip_lines: vec![],
            replace: vec![],
            on_empty: None,
            scan_secrets: true,
        })
        .unwrap();
        assert!(c.matches("my-tool build --release"));
        assert!(!c.matches("other-tool build"));
    }

    #[test]
    fn to_mesh_node_roundtrip() {
        let recipe = Recipe {
            name: "roundtrip".into(),
            match_command: "mytool run".into(),
            lens: vec!["clarity".into()],
            budget_tokens: None,
            strip_lines: vec!["^DEBUG.*".into()],
            replace: vec![],
            on_empty: None,
            scan_secrets: true,
        };
        let node = CompiledRecipe::compile(recipe).unwrap().to_mesh_node();
        assert_eq!(node.name, "roundtrip");
        assert!((node.matcher)("mytool", &["run".into(), "--flag".into()]));
        assert!(!(node.matcher)("other", &["run".into()]));
        let ctx = LensContext::new(2000);
        let out = node.preprocessor.as_ref().unwrap()("DEBUG: verbose\nResult: ok\n", &ctx);
        assert!(!out.contains("DEBUG"), "{out}");
        assert!(out.contains("Result"), "{out}");
    }
}