bctx 0.1.22

bctx CLI — intercept CLI commands and compress output for LLM coding agents
use anyhow::Result;
use std::io::IsTerminal;
use weave::ReadMode;

pub fn handle(json: bool, demo: Option<String>) -> Result<()> {
    if let Some(mode_str) = demo {
        return run_demo(&mode_str);
    }

    if json {
        return print_json();
    }

    print_table();
    Ok(())
}

fn print_table() {
    let is_tty = std::io::stdout().is_terminal();
    let sep = "  ──────────────────────────────────────────────────────────────────────────";

    println!();
    if is_tty {
        println!("  \x1b[1mbctx lens modes\x1b[0m  — 10 named modes, one per use case");
    } else {
        println!("  bctx lens modes  — 10 named modes, one per use case");
    }
    println!("{sep}");
    println!(
        "  {:<13}  {:<22}  {:<14}  USE CASE",
        "MODE", "LENS STACK", "SAVINGS"
    );
    println!("{sep}");

    for mode in ReadMode::all_named() {
        let name = mode.name();
        let stack = mode.lens_stack();
        let savings = mode.savings_estimate();
        let use_case = mode.use_case();

        let name_col = if is_tty && name != "auto" {
            format!("\x1b[36m{:<13}\x1b[0m", name)
        } else {
            format!("{:<13}", name)
        };

        println!(
            "  {}  {:<22}  {:<14}  {}",
            name_col, stack, savings, use_case
        );
    }

    println!("{sep}");
    println!("  + lines:N-M   line-range extractor    depends on range  Focus on a known region of a file");
    println!();
    println!("  Usage:");
    println!("    bctx read --mode signatures -- cat src/lib.rs");
    println!("    bctx read --mode aggressive -- cargo test 2>&1");
    println!("    bctx read --mode lines:10-40 -- cat main.rs");
    println!("    BCTX_MODE=map bctx git log --oneline -30");
    println!();
    println!("  Demo a mode against sample input:");
    println!("    bctx modes --demo signatures");
    println!();
}

fn print_json() -> Result<()> {
    let arr: Vec<serde_json::Value> = ReadMode::all_named()
        .iter()
        .map(|m| {
            serde_json::json!({
                "name": m.name(),
                "description": m.description(),
                "lens_stack": m.lens_stack(),
                "savings_estimate": m.savings_estimate(),
                "use_case": m.use_case()
            })
        })
        .collect();
    println!("{}", serde_json::to_string_pretty(&arr)?);
    Ok(())
}

// Sample Rust source used for demo output — compact enough to fit in a terminal.
const DEMO_SOURCE: &str = r#"use std::collections::HashMap;

/// Counts word frequencies in text.
pub fn word_freq(text: &str) -> HashMap<String, usize> {
    let mut map: HashMap<String, usize> = HashMap::new();
    for word in text.split_whitespace() {
        *map.entry(word.to_lowercase()).or_insert(0) += 1;
    }
    map
}

// TODO: add stemming support
// FIXME: doesn't handle punctuation properly

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

    #[test]
    fn counts_single_word() {
        let freq = word_freq("hello hello world");
        assert_eq!(freq["hello"], 2);
        assert_eq!(freq["world"], 1);
    }

    #[test]
    fn empty_string_returns_empty_map() {
        assert!(word_freq("").is_empty());
    }
}
"#;

fn run_demo(mode_str: &str) -> Result<()> {
    let mode = ReadMode::parse(mode_str).ok_or_else(|| {
        anyhow::anyhow!("unknown mode '{mode_str}' — run `bctx modes` to list all")
    })?;

    let ctx = weave::LensContext::new(2000);
    let out = mode.apply(DEMO_SOURCE, &ctx);

    let is_tty = std::io::stdout().is_terminal();
    let sep = "  ─────────────────────────────────────────────────────";

    println!();
    if is_tty {
        println!("  \x1b[1mbctx modes --demo {mode_str}\x1b[0m");
    } else {
        println!("  bctx modes --demo {mode_str}");
    }
    println!("  Mode:     {}", mode.name());
    println!("  Stack:    {}", mode.lens_stack());
    println!("  Savings:  {}", mode.savings_estimate());
    println!("{sep}");
    println!("  INPUT ({} tokens):", out.tokens_before);
    println!("{sep}");
    for line in DEMO_SOURCE.lines() {
        println!("  {line}");
    }
    println!("{sep}");
    println!(
        "  OUTPUT ({} tokens — {} lenses applied):",
        out.tokens_after,
        out.applied.len()
    );
    println!("{sep}");
    if out.content.is_empty() {
        println!("  (empty — all content filtered)");
    } else {
        for line in out.content.lines() {
            println!("  {line}");
        }
    }
    println!("{sep}");

    let saved = out.tokens_before.saturating_sub(out.tokens_after);
    let pct = (saved * 100).checked_div(out.tokens_before).unwrap_or(0);
    println!("  Saved {} tokens ({}%) via {:?}", saved, pct, out.applied);
    println!();
    Ok(())
}

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

    #[test]
    fn all_named_modes_have_non_empty_metadata() {
        for mode in ReadMode::all_named() {
            assert!(
                !mode.description().is_empty(),
                "description empty for {}",
                mode.name()
            );
            assert!(
                !mode.use_case().is_empty(),
                "use_case empty for {}",
                mode.name()
            );
            assert!(
                !mode.lens_stack().is_empty(),
                "lens_stack empty for {}",
                mode.name()
            );
            assert!(
                !mode.savings_estimate().is_empty(),
                "savings_estimate empty for {}",
                mode.name()
            );
        }
    }

    #[test]
    fn all_named_modes_have_unique_names() {
        let names: Vec<_> = ReadMode::all_named().iter().map(|m| m.name()).collect();
        let unique: std::collections::HashSet<_> = names.iter().collect();
        assert_eq!(names.len(), unique.len(), "duplicate mode names: {names:?}");
    }

    #[test]
    fn demo_unknown_mode_errors() {
        assert!(run_demo("nonexistent").is_err());
    }

    #[test]
    fn demo_signatures_reduces_tokens() {
        let ctx = weave::LensContext::new(2000);
        let out = ReadMode::Signatures.apply(DEMO_SOURCE, &ctx);
        // signatures mode should strip test bodies — output must be shorter
        assert!(
            out.tokens_after <= out.tokens_before,
            "signatures mode must not expand output"
        );
    }

    #[test]
    fn demo_aggressive_reduces_tokens() {
        let ctx = weave::LensContext::new(2000);
        let out = ReadMode::Aggressive.apply(DEMO_SOURCE, &ctx);
        assert!(
            out.tokens_after <= out.tokens_before,
            "aggressive mode must not expand output"
        );
    }

    #[test]
    fn demo_full_is_passthrough() {
        let ctx = weave::LensContext::new(2000);
        let out = ReadMode::Full.apply(DEMO_SOURCE, &ctx);
        assert_eq!(out.content, DEMO_SOURCE);
    }

    #[test]
    fn all_named_returns_nine_modes() {
        // lines:N-M is parameterised separately — all_named() covers the static ones
        // (9 original + Truncate/"narrow" added in v0.1.22 = 10)
        assert_eq!(ReadMode::all_named().len(), 10);
    }

    #[test]
    fn json_output_is_parseable_and_complete() {
        // Simulate what print_json() does and verify the resulting JSON
        let arr: Vec<serde_json::Value> = ReadMode::all_named()
            .iter()
            .map(|m| {
                serde_json::json!({
                    "name": m.name(),
                    "description": m.description(),
                    "lens_stack": m.lens_stack(),
                    "savings_estimate": m.savings_estimate(),
                    "use_case": m.use_case()
                })
            })
            .collect();
        let text = serde_json::to_string_pretty(&arr).expect("serialise");
        let parsed: Vec<serde_json::Value> = serde_json::from_str(&text).expect("parse");
        assert_eq!(parsed.len(), 10);
        for item in &parsed {
            assert!(item["name"].is_string() && !item["name"].as_str().unwrap().is_empty());
            assert!(item["description"].is_string());
            assert!(item["lens_stack"].is_string());
            assert!(item["savings_estimate"].is_string());
            assert!(item["use_case"].is_string());
        }
    }

    #[test]
    fn all_modes_can_be_parsed_back_from_name() {
        for mode in ReadMode::all_named() {
            let name = mode.name();
            assert!(
                ReadMode::parse(&name).is_some(),
                "ReadMode::parse failed for '{name}'"
            );
        }
    }

    #[test]
    fn demo_source_is_non_empty_rust() {
        assert!(
            DEMO_SOURCE.contains("fn "),
            "DEMO_SOURCE should contain a fn definition"
        );
        assert!(
            DEMO_SOURCE.contains("#[test]"),
            "DEMO_SOURCE should contain a test"
        );
    }

    #[test]
    fn every_mode_name_matches_all_named_index() {
        // Guard against mismatches between all_named() order and parse() output
        let modes = ReadMode::all_named();
        let names: Vec<_> = modes.iter().map(|m| m.name()).collect();
        assert!(names.contains(&"auto".to_string()));
        assert!(names.contains(&"aggressive".to_string()));
        assert!(names.contains(&"signatures".to_string()));
    }
}