ralph-coder 0.2.1

An agentic code generation CLI powered by multiple LLM backends
Documentation
use crate::errors::{RalphError, Result};
use ignore::WalkBuilder;
use regex::Regex;
use serde::Deserialize;
use std::path::Path;

const BRAVE_API_URL: &str = "https://api.search.brave.com/res/v1/web/search";
const SERP_API_URL: &str = "https://serpapi.com/search.json";

/// Returns true when web search is available:
/// either BRAVE_API_KEY or SERP_API_KEY is set in the environment.
/// Web search is provider-agnostic — the user must supply their own search API key.
pub fn search_available(brave_key_env: &str, serp_key_env: &str) -> bool {
    let has_brave = std::env::var(brave_key_env)
        .map(|v| !v.trim().is_empty())
        .unwrap_or(false);
    let has_serp = std::env::var(serp_key_env)
        .map(|v| !v.trim().is_empty())
        .unwrap_or(false);
    has_brave || has_serp
}

/// Perform a web search using whichever API key is available.
/// Brave takes priority over SerpAPI if both are set.
pub async fn search_web(
    query: &str,
    brave_key: Option<&str>,
    serp_key: Option<&str>,
) -> Result<String> {
    let brave = brave_key.filter(|k| !k.is_empty());
    let serp = serp_key.filter(|k| !k.is_empty());

    match (brave, serp) {
        (Some(key), _) => search_brave(query, key).await,
        (None, Some(key)) => search_serp(query, key).await,
        (None, None) => Ok("[search_web] No BRAVE_API_KEY or SERP_API_KEY set. \
             Cannot perform web search. Proceed with your best knowledge, \
             but note results may be outdated."
            .to_string()),
    }
}

async fn search_brave(query: &str, key: &str) -> Result<String> {
    let client = reqwest::Client::new();
    let resp = client
        .get(BRAVE_API_URL)
        .header("Accept", "application/json")
        .header("Accept-Encoding", "gzip")
        .header("X-Subscription-Token", key)
        .query(&[("q", query), ("count", "5")])
        .send()
        .await
        .map_err(|e| RalphError::SearchFailed(e.to_string()))?;

    if !resp.status().is_success() {
        let status = resp.status();
        let body = resp.text().await.unwrap_or_default();
        return Err(RalphError::SearchFailed(format!(
            "Brave HTTP {}: {}",
            status, body
        )));
    }

    let parsed: BraveResponse = resp
        .json()
        .await
        .map_err(|e| RalphError::SearchFailed(e.to_string()))?;

    let mut output = String::new();
    for (i, result) in parsed.web.results.iter().enumerate().take(5) {
        output.push_str(&format!(
            "{}. **{}**\n   {}\n   {}\n\n",
            i + 1,
            result.title,
            result.url,
            result.description.as_deref().unwrap_or(""),
        ));
    }

    if output.is_empty() {
        output = "No results found.".to_string();
    }
    Ok(output)
}

async fn search_serp(query: &str, key: &str) -> Result<String> {
    let client = reqwest::Client::new();
    let resp = client
        .get(SERP_API_URL)
        .query(&[("q", query), ("api_key", key), ("num", "5")])
        .send()
        .await
        .map_err(|e| RalphError::SearchFailed(e.to_string()))?;

    if !resp.status().is_success() {
        let status = resp.status();
        let body = resp.text().await.unwrap_or_default();
        return Err(RalphError::SearchFailed(format!(
            "SerpAPI HTTP {}: {}",
            status, body
        )));
    }

    let parsed: SerpResponse = resp
        .json()
        .await
        .map_err(|e| RalphError::SearchFailed(e.to_string()))?;

    let mut output = String::new();
    for (i, result) in parsed.organic_results.iter().enumerate().take(5) {
        output.push_str(&format!(
            "{}. **{}**\n   {}\n   {}\n\n",
            i + 1,
            result.title,
            result.link,
            result.snippet.as_deref().unwrap_or(""),
        ));
    }

    if output.is_empty() {
        output = "No results found.".to_string();
    }
    Ok(output)
}

/// List files matching a glob pattern. Returns sorted relative paths.
pub fn glob_files(pattern: &str, root: &Path) -> Result<String> {
    let mut overrides = ignore::overrides::OverrideBuilder::new(root);
    overrides.add(pattern).map_err(|e| RalphError::ToolFailed {
        tool: "glob".to_string(),
        message: format!("Invalid glob pattern: {}", e),
    })?;
    let overrides = overrides.build().map_err(|e| RalphError::ToolFailed {
        tool: "glob".to_string(),
        message: format!("Failed to build overrides: {}", e),
    })?;

    let walker = WalkBuilder::new(root)
        .hidden(false)
        .ignore(true)
        .git_ignore(true)
        .overrides(overrides)
        .build();

    let mut paths: Vec<String> = walker
        .flatten()
        .filter(|e| e.path().is_file())
        .filter_map(|e| {
            let rel = e.path().strip_prefix(root).ok()?;
            Some(rel.display().to_string())
        })
        .collect();
    paths.sort();

    if paths.is_empty() {
        Ok(format!("No files matched: {}", pattern))
    } else {
        Ok(format!("{} file(s):\n{}", paths.len(), paths.join("\n")))
    }
}

/// Search a single file for a regex pattern, returning matching lines with
/// optional context. Marks matching lines with `>`.
pub fn search_in_file(pattern: &str, path: &Path, context_lines: usize) -> Result<String> {
    let re = Regex::new(pattern).map_err(|e| RalphError::ToolFailed {
        tool: "search_in_file".to_string(),
        message: format!("Invalid regex: {}", e),
    })?;

    if !path.exists() {
        return Err(RalphError::FileNotFound(path.display().to_string()));
    }

    let content = std::fs::read_to_string(path).map_err(RalphError::Io)?;
    let lines: Vec<&str> = content.lines().collect();
    let total = lines.len();

    // Collect matching line indices.
    let match_indices: Vec<usize> = lines
        .iter()
        .enumerate()
        .filter(|(_, l)| re.is_match(l))
        .map(|(i, _)| i)
        .collect();

    if match_indices.is_empty() {
        return Ok(format!(
            "No matches for '{}' in file ({} lines)",
            pattern, total
        ));
    }

    // Build non-overlapping context ranges.
    let mut ranges: Vec<(usize, usize)> = Vec::new();
    for &m in &match_indices {
        let start = m.saturating_sub(context_lines);
        let end = (m + context_lines + 1).min(total);
        if let Some(last) = ranges.last_mut() {
            if start <= last.1 {
                last.1 = end.max(last.1);
                continue;
            }
        }
        ranges.push((start, end));
    }

    let mut out = String::new();
    for (start, end) in &ranges {
        if !out.is_empty() {
            out.push_str("---\n");
        }
        for i in *start..*end {
            let marker = if re.is_match(lines[i]) { ">" } else { " " };
            out.push_str(&format!("{}{:5}: {}\n", marker, i + 1, lines[i]));
        }
    }
    out.push_str(&format!(
        "\n({} match(es) in {} lines)",
        match_indices.len(),
        total
    ));
    Ok(out)
}

/// Search workspace with optional glob filter (e.g. `"*.py"`) and context lines.
pub fn search_codebase(pattern: &str, root: &Path) -> Result<String> {
    search_codebase_filtered(pattern, root, None, 0)
}

pub fn search_codebase_filtered(
    pattern: &str,
    root: &Path,
    glob_filter: Option<&str>,
    context_lines: usize,
) -> Result<String> {
    let re = Regex::new(pattern).map_err(|e| RalphError::ToolFailed {
        tool: "search_codebase".to_string(),
        message: format!("Invalid regex: {}", e),
    })?;

    let walker = if let Some(glob) = glob_filter {
        let mut overrides = ignore::overrides::OverrideBuilder::new(root);
        if overrides.add(glob).is_ok() {
            if let Ok(ov) = overrides.build() {
                WalkBuilder::new(root)
                    .hidden(false)
                    .ignore(true)
                    .git_ignore(true)
                    .overrides(ov)
                    .build()
            } else {
                WalkBuilder::new(root)
                    .hidden(false)
                    .ignore(true)
                    .git_ignore(true)
                    .build()
            }
        } else {
            WalkBuilder::new(root)
                .hidden(false)
                .ignore(true)
                .git_ignore(true)
                .build()
        }
    } else {
        WalkBuilder::new(root)
            .hidden(false)
            .ignore(true)
            .git_ignore(true)
            .build()
    };

    let mut matches: Vec<String> = Vec::new();

    for entry in walker.flatten() {
        let path = entry.path();
        if !path.is_file() {
            continue;
        }
        let rel = path.strip_prefix(root).unwrap_or(path);

        let content = match std::fs::read_to_string(path) {
            Ok(c) => c,
            Err(_) => continue,
        };
        let lines: Vec<&str> = content.lines().collect();

        if context_lines == 0 {
            for (line_no, line) in lines.iter().enumerate() {
                if re.is_match(line) {
                    matches.push(format!(
                        "{}:{}: {}",
                        rel.display(),
                        line_no + 1,
                        line.trim()
                    ));
                    if matches.len() >= 100 {
                        break;
                    }
                }
            }
        } else {
            // With context: emit file header + annotated blocks.
            let match_indices: Vec<usize> = lines
                .iter()
                .enumerate()
                .filter(|(_, l)| re.is_match(l))
                .map(|(i, _)| i)
                .collect();
            if match_indices.is_empty() {
                continue;
            }

            let mut ranges: Vec<(usize, usize)> = Vec::new();
            for &m in &match_indices {
                let s = m.saturating_sub(context_lines);
                let e = (m + context_lines + 1).min(lines.len());
                if let Some(last) = ranges.last_mut() {
                    if s <= last.1 {
                        last.1 = e.max(last.1);
                        continue;
                    }
                }
                ranges.push((s, e));
            }

            for (s, e) in ranges {
                if matches.len() > 0 {
                    matches.push("---".to_string());
                }
                matches.push(format!("{}:", rel.display()));
                for i in s..e {
                    let marker = if re.is_match(lines[i]) { ">" } else { " " };
                    matches.push(format!("{}{:5}: {}", marker, i + 1, lines[i]));
                }
            }
        }

        if matches.len() >= 100 {
            break;
        }
    }

    if matches.is_empty() {
        Ok(format!("No matches for pattern: {}", pattern))
    } else {
        Ok(matches.join("\n"))
    }
}

// ── Brave API types ───────────────────────────────────────────────────────────

#[derive(Deserialize)]
struct BraveResponse {
    web: BraveWeb,
}

#[derive(Deserialize)]
struct BraveWeb {
    results: Vec<BraveResult>,
}

#[derive(Deserialize)]
struct BraveResult {
    title: String,
    url: String,
    description: Option<String>,
}

// ── SerpAPI types ─────────────────────────────────────────────────────────────

#[derive(Deserialize)]
struct SerpResponse {
    #[serde(default)]
    organic_results: Vec<SerpResult>,
}

#[derive(Deserialize)]
struct SerpResult {
    title: String,
    link: String,
    snippet: Option<String>,
}