use crate::error::{Result, SofosError};
use crate::tools::utils::{MAX_TOOL_OUTPUT_TOKENS, TruncationKind, truncate_for_context};
use std::path::PathBuf;
use std::process::Command;
pub const SEARCH_RESULTS_PREFIX: &str = "Code search results:\n\n";
pub const DEFAULT_EXCLUDE_DIRS: &[&str] = &["target", "node_modules", ".git", "dist", "build"];
pub const DEFAULT_MAX_RESULTS_PER_FILE: usize = 50;
const MAX_COLUMNS_FLAG: &str = "--max-columns=300";
const MAX_FILESIZE_FLAG: &str = "--max-filesize=1M";
pub fn default_exclude_dirs_human() -> String {
DEFAULT_EXCLUDE_DIRS
.iter()
.map(|d| format!("{}/", d))
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Clone)]
pub struct CodeSearchTool {
workspace: PathBuf,
rg_path: PathBuf,
}
impl CodeSearchTool {
pub fn new(workspace: PathBuf) -> Result<Self> {
let env_override = std::env::var_os("SOFOS_RG_PATH").map(PathBuf::from);
let fallback_paths = ["/opt/homebrew/bin/rg", "/usr/local/bin/rg", "/usr/bin/rg"];
let try_path = |p: &PathBuf| Command::new(p).arg("--version").output();
if let Some(p) = env_override {
if try_path(&p).is_ok() {
return Ok(Self {
workspace,
rg_path: p,
});
}
}
let default_rg = PathBuf::from("rg");
if try_path(&default_rg).is_ok() {
return Ok(Self {
workspace,
rg_path: default_rg,
});
}
for path in fallback_paths.iter().map(PathBuf::from) {
if try_path(&path).is_ok() {
return Ok(Self {
workspace,
rg_path: path,
});
}
}
let path_env = std::env::var("PATH").unwrap_or_else(|_| "<unset>".to_string());
Err(SofosError::Config(format!(
"ripgrep (rg) not found. Checked SOFOS_RG_PATH, PATH, and common locations.\nPATH seen by Sofos: {}\nInstall ripgrep: https://github.com/BurntSushi/ripgrep#installation",
path_env
)))
}
pub fn search(
&self,
pattern: &str,
file_type: Option<&str>,
max_results: Option<usize>,
include_ignored: bool,
) -> Result<String> {
let mut cmd = Command::new(&self.rg_path);
cmd.arg("--heading")
.arg("--line-number")
.arg("--color=never")
.arg("--no-messages")
.arg("--with-filename")
.arg(MAX_COLUMNS_FLAG)
.arg("--max-columns-preview")
.arg(MAX_FILESIZE_FLAG);
if include_ignored {
cmd.arg("--no-ignore");
} else {
for dir in DEFAULT_EXCLUDE_DIRS {
cmd.arg("--glob").arg(format!("!{}/**", dir));
}
}
let max_count = max_results.unwrap_or(DEFAULT_MAX_RESULTS_PER_FILE);
cmd.arg("--max-count").arg(max_count.to_string());
if let Some(ft) = file_type {
if !ft.trim().is_empty() {
cmd.arg("--type").arg(ft);
}
}
cmd.arg("--").arg(pattern);
cmd.current_dir(&self.workspace);
let output = cmd
.output()
.map_err(|e| SofosError::ToolExecution(format!("Failed to execute ripgrep: {}", e)))?;
let stdout = String::from_utf8_lossy(&output.stdout);
if output.status.success() && !stdout.trim().is_empty() {
return Ok(truncate_for_context(
&stdout,
MAX_TOOL_OUTPUT_TOKENS,
TruncationKind::SearchOutput,
));
}
let stderr = String::from_utf8_lossy(&output.stderr);
let is_no_match =
output.status.success() || stderr.is_empty() || stderr.contains("No matches found");
if is_no_match {
Ok(format!("No matches found for pattern: '{}'", pattern))
} else {
Err(SofosError::ToolExecution(format!(
"ripgrep error: {}",
stderr
)))
}
}
pub fn _list_file_types(&self) -> Result<String> {
let output = Command::new(&self.rg_path)
.arg("--type-list")
.output()
.map_err(|e| SofosError::ToolExecution(format!("Failed to list file types: {}", e)))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(SofosError::ToolExecution(
"Failed to get ripgrep file types".to_string(),
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn default_exclude_dirs_human_renders_every_entry() {
let rendered = default_exclude_dirs_human();
for dir in DEFAULT_EXCLUDE_DIRS {
let expected = format!("{}/", dir);
assert!(
rendered.contains(&expected),
"human rendering '{}' missing entry '{}'",
rendered,
expected
);
}
assert_eq!(
rendered.matches(", ").count(),
DEFAULT_EXCLUDE_DIRS.len() - 1
);
assert!(!rendered.ends_with(','));
}
#[test]
fn test_code_search_creation() {
let temp = TempDir::new().unwrap();
let result = CodeSearchTool::new(temp.path().to_path_buf());
if let Ok(tool) = result {
assert_eq!(tool.workspace, temp.path());
}
}
#[test]
fn test_search_functionality() {
let temp = TempDir::new().unwrap();
let test_file = temp.path().join("test.txt");
fs::write(&test_file, "Hello World\nTest Pattern\nAnother Line").unwrap();
if let Ok(tool) = CodeSearchTool::new(temp.path().to_path_buf()) {
let result = tool.search("Pattern", None, None, false);
if let Ok(output) = result {
assert!(output.contains("Pattern") || output.contains("No matches"));
}
}
}
#[test]
fn search_treats_flag_like_pattern_as_literal() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join("notes.txt"),
"release --files checklist\nsome -v output\n",
)
.unwrap();
let Ok(tool) = CodeSearchTool::new(temp.path().to_path_buf()) else {
return;
};
let out = tool.search("--files", None, None, false).unwrap();
assert!(
out.contains("--files checklist"),
"pattern '--files' must be treated as literal; got: {}",
out
);
let out = tool.search("-v", None, None, false).unwrap();
assert!(
out.contains("some -v output"),
"pattern '-v' must be treated as literal; got: {}",
out
);
}
#[test]
fn search_default_excludes_target_directory() {
let temp = TempDir::new().unwrap();
let target_dir = temp.path().join("target");
fs::create_dir_all(&target_dir).unwrap();
fs::write(target_dir.join("junk.rs"), "unique_marker_xyz\n").unwrap();
let Ok(tool) = CodeSearchTool::new(temp.path().to_path_buf()) else {
return;
};
let default_output = tool.search("unique_marker_xyz", None, None, false).unwrap();
assert!(
default_output.contains("No matches"),
"target/ should be excluded by default; got: {}",
default_output
);
let override_output = tool.search("unique_marker_xyz", None, None, true).unwrap();
assert!(
override_output.contains("unique_marker_xyz"),
"include_ignored=true must surface files under target/; got: {}",
override_output
);
}
}