bctx-weave 0.1.25

bctx-weave — FilterMesh lens pipeline, CLI interception, domain compression
Documentation
use forge::signal::compactor;
use once_cell::sync::Lazy;
use regex::Regex;

// gh pr list / issue list — strip noisy metadata columns
static TABLE_SEPARATOR_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^[-─]+\n?").unwrap());
// gh run list verbose columns (e.g. timing, runner info) — remove runner OS tokens
static RUN_META_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(
        r"\b(ubuntu-latest|ubuntu-22\.04|ubuntu-20\.04|macos-latest|macos-14|windows-latest)\b",
    )
    .unwrap()
});
// gh api JSON — large responses
static JSON_BLANK_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r#"(?m)^\s+"[a-z_]+": null,?\n?"#).unwrap());

// ── gh pr ─────────────────────────────────────────────────────────────────────

pub fn compress_pr_list(raw: &str) -> String {
    let cleaned = compactor::normalise(raw);
    let s = TABLE_SEPARATOR_RE.replace_all(&cleaned, "");
    // Keep: ID, title, state, author — strip URL/branch columns
    let kept: Vec<&str> = s
        .lines()
        .filter(|l| !l.trim().is_empty())
        .take(30)
        .collect();
    kept.join("\n")
}

pub fn compress_pr_view(raw: &str) -> String {
    // PR view — strip boilerplate, keep title/state/body/comments summary
    let cleaned = compactor::normalise(raw);
    let lines: Vec<&str> = cleaned.lines().collect();
    if lines.len() > 60 {
        let head = &lines[..20];
        let tail = &lines[lines.len() - 10..];
        let omitted = lines.len() - 30;
        return format!(
            "{}\n... [{} more lines omitted] ...\n{}",
            head.join("\n"),
            omitted,
            tail.join("\n")
        );
    }
    cleaned
}

pub fn compress_pr_checks(raw: &str) -> String {
    let cleaned = compactor::normalise(raw);
    let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();

    // Detect if any check is failing — if so, drop all passing checks
    let has_failure = lines.iter().any(|l| {
        let t = l.to_lowercase();
        t.contains("fail") || t.contains("error") || t.contains("") || t.contains("×")
    });
    if has_failure {
        let failing: Vec<&str> = lines
            .iter()
            .copied()
            .filter(|l| {
                let t = l.to_lowercase();
                t.contains("fail") || t.contains("error") || t.contains("") || t.contains("×")
            })
            .collect();
        let pass_count = lines.len().saturating_sub(failing.len());
        let mut result = failing;
        if pass_count > 0 {
            result.push("… (passing checks hidden)");
        }
        return result.join("\n");
    }
    lines.join("\n")
}

// ── gh issue ──────────────────────────────────────────────────────────────────

pub fn compress_issue_list(raw: &str) -> String {
    let cleaned = compactor::normalise(raw);
    let s = TABLE_SEPARATOR_RE.replace_all(&cleaned, "");
    let kept: Vec<&str> = s
        .lines()
        .filter(|l| !l.trim().is_empty())
        .take(30)
        .collect();
    kept.join("\n")
}

// ── gh run ────────────────────────────────────────────────────────────────────

pub fn compress_run_list(raw: &str) -> String {
    let cleaned = compactor::normalise(raw);
    // Strip runner OS, commit SHA columns — keep: status, name, branch, elapsed
    let s = RUN_META_RE.replace_all(&cleaned, "");
    // Also strip long hex commit SHAs (7–40 hex chars)
    let sha_re = once_cell::sync::Lazy::force(&RUN_SHA_RE);
    let s = sha_re.replace_all(&s, "");
    let kept: Vec<&str> = s
        .lines()
        .filter(|l| !l.trim().is_empty())
        .take(20)
        .collect();
    kept.join("\n")
}

pub fn compress_run_view(raw: &str) -> String {
    let cleaned = compactor::normalise(raw);
    let lines: Vec<&str> = cleaned.lines().collect();

    // Keep job name lines (contain status icons or "✓"/"✗"/"●") + error output
    let job_lines: Vec<&str> = lines
        .iter()
        .copied()
        .filter(|l| {
            let t = l.trim();
            !t.is_empty()
                && (t.contains('')
                    || t.contains('')
                    || t.contains('')
                    || t.contains("pass")
                    || t.contains("fail")
                    || t.contains("skip")
                    || t.starts_with("Jobs:")
                    || t.starts_with("Run ")
                    || t.contains("Run ID")
                    || t.contains("Branch:")
                    || t.contains("Triggered")
                    // Strip step-level timing lines: "  1.234s" or "  ✓ Set up job  0.5s"
                    || t.starts_with("")
                    || t.starts_with(""))
        })
        .collect();

    if job_lines.len() > 5 && job_lines.len() < lines.len() / 2 {
        // Significant compression possible
        return job_lines.join("\n");
    }

    if lines.len() > 50 {
        let omitted = lines.len() - 50;
        return format!(
            "{}\n… [{} more log lines] …",
            lines[..50].join("\n"),
            omitted
        );
    }
    cleaned
}

pub fn compress_run_watch(raw: &str) -> String {
    let cleaned = compactor::normalise(raw);
    // Strip "Refreshing run status every N seconds" polling lines
    let lines: Vec<&str> = cleaned
        .lines()
        .filter(|l| !l.trim().starts_with("Refreshing run status"))
        .filter(|l| !l.trim().is_empty())
        .collect();
    if lines.is_empty() {
        return "(run watch: no output)".to_string();
    }
    // Keep last 10 lines (most recent status)
    let start = lines.len().saturating_sub(10);
    lines[start..].join("\n")
}

static RUN_SHA_RE: once_cell::sync::Lazy<regex::Regex> =
    once_cell::sync::Lazy::new(|| regex::Regex::new(r"\b[0-9a-f]{7,40}\b").unwrap());

// ── gh api ────────────────────────────────────────────────────────────────────

pub fn compress_api(raw: &str) -> String {
    let cleaned = compactor::normalise(raw);
    // Strip null fields, truncate large responses
    let s = JSON_BLANK_RE.replace_all(&cleaned, "");
    let lines: Vec<&str> = s.lines().collect();
    if lines.len() > 60 {
        return format!(
            "{}\n... [{} more lines — use --jq to filter] ...",
            lines[..60].join("\n"),
            lines.len() - 60
        );
    }
    s.into_owned()
}

// ── gh repo ───────────────────────────────────────────────────────────────────

pub fn compress_repo(raw: &str) -> String {
    // gh repo view / clone: keep essential info, strip boilerplate
    let cleaned = compactor::normalise(raw);
    let lines: Vec<&str> = cleaned.lines().collect();
    if lines.len() > 30 {
        return lines[..30].join("\n");
    }
    cleaned
}

// ── top-level dispatcher ──────────────────────────────────────────────────────

pub fn compress_gh(subcmd: &str, raw: &str) -> String {
    let sub = subcmd.trim();
    if sub.starts_with("pr list") || sub == "pr" {
        return compress_pr_list(raw);
    }
    if sub.starts_with("pr view") {
        return compress_pr_view(raw);
    }
    if sub.starts_with("pr checks") {
        return compress_pr_checks(raw);
    }
    if sub.starts_with("issue list") || sub == "issue" {
        return compress_issue_list(raw);
    }
    if sub.starts_with("run list") {
        return compress_run_list(raw);
    }
    if sub.starts_with("run view") {
        return compress_run_view(raw);
    }
    if sub.starts_with("run watch") {
        return compress_run_watch(raw);
    }
    if sub.starts_with("api") {
        return compress_api(raw);
    }
    if sub.starts_with("repo") {
        return compress_repo(raw);
    }
    // Default: normalise + light truncation
    let cleaned = compactor::normalise(raw);
    let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
    if lines.len() > 50 {
        return format!(
            "{}\n... [{} more lines] ...",
            lines[..50].join("\n"),
            lines.len() - 50
        );
    }
    cleaned
}

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

    #[test]
    fn pr_list_keeps_entries() {
        let raw =
            "#42  Fix the bug            OPEN   user1\n#41  Update readme          MERGED user2\n";
        let out = compress_pr_list(raw);
        assert!(out.contains("#42"), "{out}");
        assert!(out.contains("#41"), "{out}");
    }

    #[test]
    fn pr_list_truncates_at_30() {
        let raw = (0..40)
            .map(|i| format!("#{i}  PR {i}  OPEN  user\n"))
            .collect::<String>();
        let out = compress_pr_list(raw.as_str());
        assert!(!out.contains("#39"), "{out}");
    }

    #[test]
    fn run_list_strips_runner_os() {
        let raw = "completed  success  CI  main  ubuntu-latest  2m\n";
        let out = compress_run_list(raw);
        assert!(!out.contains("ubuntu-latest"), "{out}");
    }

    #[test]
    fn api_truncates_large_response() {
        let raw = (0..80)
            .map(|i| format!("  \"field{i}\": \"value{i}\",\n"))
            .collect::<String>();
        let out = compress_api(&raw);
        assert!(out.contains("more lines"), "{out}");
    }

    #[test]
    fn run_list_strips_sha() {
        let raw = "completed  success  CI  main  a3f9b2c  2m45s\ncompleted  failure  lint  main  deadbeef1234  1m10s\n";
        let out = compress_run_list(raw);
        assert!(!out.contains("a3f9b2c"), "{out}");
        assert!(out.contains("success") || out.contains("failure"), "{out}");
    }

    #[test]
    fn pr_checks_hides_passing_when_failures_exist() {
        let raw = "✓  lint     pass  30s\n✓  test     pass  2m\n✗  deploy   fail  10s\n";
        let out = compress_pr_checks(raw);
        assert!(out.contains("fail") || out.contains(""), "{out}");
        assert!(out.contains("hidden") || !out.contains("✓  lint"), "{out}");
    }

    #[test]
    fn pr_checks_passthrough_when_all_pass() {
        let raw = "✓  lint  pass  30s\n✓  test  pass  2m\n";
        let out = compress_pr_checks(raw);
        assert!(out.contains("lint"), "{out}");
        assert!(out.contains("test"), "{out}");
    }

    #[test]
    fn run_watch_strips_polling_lines() {
        let raw = "Refreshing run status every 3 seconds\nRefreshing run status every 3 seconds\n✓ CI completed\n";
        let out = compress_run_watch(raw);
        assert!(!out.contains("Refreshing"), "{out}");
        assert!(out.contains("CI"), "{out}");
    }

    #[test]
    fn run_view_keeps_job_status_lines() {
        let raw = "Run ID: 123456\nBranch: main\n✓ build  2m30s\n✗ test   1m00s\nsome verbose log line\nanother log line\n";
        let out = compress_run_view(raw);
        assert!(out.contains("build") || out.contains("test"), "{out}");
    }
}