bctx-weave 0.1.9

bctx-weave — FilterMesh lens pipeline, CLI interception, domain compression
Documentation
use forge::signal::compactor;

pub fn compress_buf(subcmd: &str, raw: &str) -> String {
    let sub = subcmd.trim();
    match sub {
        "lint" => compress_lint(raw),
        "breaking" => compress_breaking(raw),
        "generate" => compress_generate(raw),
        "build" => compress_build(raw),
        _ => compactor::normalise(raw),
    }
}

fn compress_lint(raw: &str) -> String {
    let cleaned = compactor::normalise(raw);
    if cleaned.trim().is_empty() {
        return "buf lint: no issues".to_string();
    }
    // Each finding: "proto/foo.proto:10:5:FIELD_NAMES_LOWER_SNAKE_CASE: ..."
    // Group by file
    let mut by_file: std::collections::HashMap<String, Vec<String>> =
        std::collections::HashMap::new();

    for line in cleaned.lines() {
        let t = line.trim();
        if t.is_empty() {
            continue;
        }
        // Extract file name (first component before first colon that ends in .proto)
        let file = t
            .split(':')
            .next()
            .filter(|s| s.ends_with(".proto"))
            .unwrap_or("unknown")
            .to_string();
        by_file.entry(file).or_default().push(t.to_string());
    }

    if by_file.is_empty() {
        return compactor::collapse_blanks(&cleaned);
    }

    let mut result: Vec<String> = Vec::new();
    let mut files: Vec<&String> = by_file.keys().collect();
    files.sort();
    let total: usize = by_file.values().map(|v| v.len()).sum();

    for file in &files {
        let findings = &by_file[*file];
        result.push(format!("{file}{} issue(s)", findings.len()));
        for f in findings.iter().take(4) {
            result.push(format!("  {f}"));
        }
        if findings.len() > 4 {
            result.push(format!("{} more", findings.len() - 4));
        }
    }
    result.push(format!(
        "buf lint: {total} issues across {} files",
        files.len()
    ));
    result.join("\n")
}

fn compress_breaking(raw: &str) -> String {
    let cleaned = compactor::normalise(raw);
    if cleaned.trim().is_empty() {
        return "buf breaking: no breaking changes".to_string();
    }
    // Breaking change lines: "FILE_NO_DELETE:proto/foo.proto:File \"foo.proto\" was deleted."
    // Keep only the change description, strip rule prefix code
    let mut changes: Vec<String> = Vec::new();
    for line in cleaned.lines() {
        let t = line.trim();
        if t.is_empty() {
            continue;
        }
        // Rule code is all-uppercase with underscores at the start
        let msg = if let Some(colon_pos) = t.find(':') {
            let prefix = &t[..colon_pos];
            if prefix.chars().all(|c| c.is_uppercase() || c == '_') {
                t[colon_pos + 1..].trim()
            } else {
                t
            }
        } else {
            t
        };
        changes.push(msg.to_string());
    }
    let n = changes.len();
    let mut result = changes;
    result.push(format!("buf breaking: {n} breaking change(s)"));
    result.join("\n")
}

fn compress_generate(raw: &str) -> String {
    let cleaned = compactor::normalise(raw);
    let errors: Vec<&str> = cleaned
        .lines()
        .filter(|l| l.to_lowercase().contains("error") || l.to_lowercase().contains("failed"))
        .collect();
    if errors.is_empty() {
        if cleaned.trim().is_empty() {
            return "buf generate: completed".to_string();
        }
        // Strip timing/progress lines, keep only meaningful output
        let kept: Vec<&str> = cleaned
            .lines()
            .filter(|l| !l.trim().starts_with("Generated") || !l.contains("ms"))
            .filter(|l| !l.trim().is_empty())
            .collect();
        return if kept.is_empty() {
            "buf generate: completed".to_string()
        } else {
            kept.join("\n")
        };
    }
    errors.join("\n")
}

fn compress_build(raw: &str) -> String {
    let cleaned = compactor::normalise(raw);
    if cleaned.trim().is_empty() {
        return "buf build: succeeded".to_string();
    }
    compactor::collapse_blanks(&cleaned)
}

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

    #[test]
    fn lint_groups_by_file() {
        let raw = "proto/foo.proto:10:5:FIELD_NAMES_LOWER_SNAKE_CASE: Field name \"myField\" should be lower_snake_case.\nproto/foo.proto:20:1:ENUM_VALUE_PREFIX: Enum value should be prefixed.\nproto/bar.proto:5:3:PACKAGE_SAME_DIRECTORY: Files in package must be in same directory.\n";
        let out = compress_buf("lint", raw);
        assert!(out.contains("foo.proto"), "{out}");
        assert!(out.contains("bar.proto"), "{out}");
        assert!(out.contains("3 issues") || out.contains("issue"), "{out}");
    }

    #[test]
    fn lint_no_issues_clean() {
        let out = compress_buf("lint", "");
        assert!(out.contains("no issues"), "{out}");
    }

    #[test]
    fn breaking_strips_rule_codes() {
        let raw = "FILE_NO_DELETE:proto/foo.proto:1:1:File \"foo.proto\" was deleted.\nFIELD_SAME_TYPE:proto/bar.proto:5:3:Field \"id\" changed type.\n";
        let out = compress_buf("breaking", raw);
        assert!(
            out.contains("deleted") || out.contains("foo.proto"),
            "{out}"
        );
        assert!(out.contains("breaking"), "{out}");
    }
}