harness-grep 0.1.1

Grep tool for AI agent harnesses — ripgrep-library content search with structured output modes, .gitignore respect, alias pushback, and fuzzy NOT_FOUND siblings
Documentation
use crate::types::RgCount;

#[derive(Debug, Clone, Copy)]
pub struct ZeroMatchContext<'a> {
    pub case_insensitive: bool,
    pub glob: Option<&'a str>,
    pub r#type: Option<&'a str>,
}

fn zero_match_hint(ctx: ZeroMatchContext) -> String {
    let mut suggestions: Vec<String> = Vec::new();
    if !ctx.case_insensitive {
        suggestions.push("case_insensitive: true".to_string());
    }
    if let Some(g) = ctx.glob {
        suggestions.push(format!("remove glob='{}'", g));
    }
    if let Some(t) = ctx.r#type {
        suggestions.push(format!("remove type='{}'", t));
    }
    suggestions.push("broaden the pattern".to_string());
    suggestions.push("try a different path".to_string());
    format!("(No files matched. Try: {}.)", suggestions.join("; "))
}

fn kb_label(bytes: usize) -> String {
    format!("{} KB", bytes / 1024)
}

pub struct FilesBlock<'a> {
    pub pattern: &'a str,
    pub paths: &'a [String],
    pub total: usize,
    pub offset: usize,
    pub more: bool,
    pub zero_match_context: ZeroMatchContext<'a>,
}

pub fn format_files_with_matches(p: FilesBlock) -> String {
    let header = format!("<pattern>{}</pattern>\n<matches>", p.pattern);
    if p.paths.is_empty() {
        let hint = zero_match_hint(p.zero_match_context);
        return format!("{}\n{}\n</matches>", header, hint);
    }
    let body = p.paths.join("\n");
    let next = p.offset + p.paths.len();
    let hint = if p.more {
        format!(
            "(Showing files {}-{} of {}. Next offset: {}.)",
            p.offset + 1,
            next,
            p.total,
            next
        )
    } else {
        format!("(Found {} file(s) matching the pattern.)", p.total)
    };
    format!("{}\n{}\n\n{}\n</matches>", header, body, hint)
}

pub struct ContentLine<'a> {
    pub path: &'a str,
    pub line: u64,
    pub text: &'a str,
}

pub struct ContentBlock<'a> {
    pub pattern: &'a str,
    pub matches: &'a [ContentLine<'a>],
    pub total_matches: usize,
    pub offset: usize,
    pub more: bool,
    pub byte_cap: bool,
    pub max_bytes: usize,
    pub zero_match_context: ZeroMatchContext<'a>,
}

pub fn format_content(p: ContentBlock) -> String {
    let header = format!("<pattern>{}</pattern>\n<matches>", p.pattern);
    if p.matches.is_empty() {
        let hint = zero_match_hint(p.zero_match_context).replace("No files matched", "No matches");
        return format!("{}\n{}\n</matches>", header, hint);
    }
    let mut chunks: Vec<String> = Vec::new();
    let mut current: &str = "";
    for m in p.matches {
        if m.path != current {
            if !current.is_empty() {
                chunks.push(String::new());
            }
            chunks.push(m.path.to_string());
            current = m.path;
        }
        chunks.push(format!("  {}: {}", m.line, m.text));
    }
    let body = chunks.join("\n");
    let next = p.offset + p.matches.len();
    let hint = if p.byte_cap {
        format!(
            "(Output capped at {}. Showing matches {}-{} of {}. Next offset: {}.)",
            kb_label(p.max_bytes),
            p.offset + 1,
            next,
            p.total_matches,
            next
        )
    } else if p.more {
        format!(
            "(Showing matches {}-{} of {}. Next offset: {}.)",
            p.offset + 1,
            next,
            p.total_matches,
            next
        )
    } else {
        let file_count: std::collections::HashSet<&str> =
            p.matches.iter().map(|m| m.path).collect();
        format!(
            "(Found {} match(es) across {} file(s).)",
            p.total_matches,
            file_count.len()
        )
    };
    format!("{}\n{}\n\n{}\n</matches>", header, body, hint)
}

pub struct CountBlock<'a> {
    pub pattern: &'a str,
    pub counts: &'a [RgCount],
    pub total: usize,
    pub offset: usize,
    pub more: bool,
    pub zero_match_context: ZeroMatchContext<'a>,
}

pub fn format_count(p: CountBlock) -> String {
    let header = format!("<pattern>{}</pattern>\n<counts>", p.pattern);
    if p.counts.is_empty() {
        let hint = zero_match_hint(p.zero_match_context).replace("No files matched", "No matches");
        return format!("{}\n{}\n</counts>", header, hint);
    }
    let body = p
        .counts
        .iter()
        .map(|c| format!("{}: {}", c.path, c.count))
        .collect::<Vec<_>>()
        .join("\n");
    let next = p.offset + p.counts.len();
    let hint = if p.more {
        format!(
            "(Showing files {}-{} of {}. Next offset: {}.)",
            p.offset + 1,
            next,
            p.total,
            next
        )
    } else {
        format!("({} file(s) with matches.)", p.total)
    };
    format!("{}\n{}\n\n{}\n</counts>", header, body, hint)
}