use std::path::Path;
use std::process::Command;
use rmcp::schemars;
use serde::Serialize;
use crate::error::PawError;
use super::resolve_under_root;
const SEARCH_MATCH_CAP: usize = 200;
#[derive(Debug, Clone, Serialize, schemars::JsonSchema, PartialEq, Eq)]
pub struct CodeMatch {
pub path: String,
pub line_number: u64,
pub line: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadOutcome {
pub content: Option<String>,
pub message: Option<String>,
}
fn git(repo_root: &Path, args: &[&str]) -> Option<String> {
let out = Command::new("git")
.current_dir(repo_root)
.args(args)
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
#[must_use]
pub fn list_files(repo_root: &Path, subpath: Option<&str>) -> Vec<String> {
let mut args = vec!["ls-files", "--cached", "--others", "--exclude-standard"];
if let Some(sub) = subpath {
args.push("--");
args.push(sub);
}
let Some(raw) = git(repo_root, &args) else {
return Vec::new();
};
raw.lines()
.filter(|l| !l.is_empty())
.map(ToString::to_string)
.collect()
}
fn is_gitignored(repo_root: &Path, path: &str) -> bool {
Command::new("git")
.current_dir(repo_root)
.args(["check-ignore", "-q", "--", path])
.output()
.ok()
.is_some_and(|out| out.status.success())
}
pub fn read_file(repo_root: &Path, path: &str) -> Result<ReadOutcome, PawError> {
let refused = |reason: &str| {
Ok(ReadOutcome {
content: None,
message: Some(reason.to_string()),
})
};
let Ok(canonical_root) = repo_root.canonicalize() else {
return refused("repository root could not be resolved");
};
let requested = resolve_under_root(repo_root, Path::new(path));
let Ok(canonical) = requested.canonicalize() else {
return refused(&format!("file not found within the repository: {path:?}"));
};
if !canonical.starts_with(&canonical_root) {
return refused(&format!(
"path {path:?} resolves outside the repository root and was refused"
));
}
if is_gitignored(repo_root, path) {
return refused(&format!("path {path:?} is gitignored and was refused"));
}
match std::fs::read_to_string(&canonical) {
Ok(content) => Ok(ReadOutcome {
content: Some(content),
message: None,
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
refused(&format!("file not found within the repository: {path:?}"))
}
Err(e) => Err(PawError::McpError(format!(
"file {} could not be read: {e}",
canonical.display()
))),
}
}
#[must_use]
pub fn search_code(repo_root: &Path, query: &str, subpath: Option<&str>) -> (Vec<CodeMatch>, bool) {
let mut args = vec!["grep", "-n", "-I", "--untracked", "-e", query];
if let Some(sub) = subpath {
args.push("--");
args.push(sub);
}
let Some(raw) = git(repo_root, &args) else {
return (Vec::new(), false);
};
let mut matches = Vec::new();
let mut truncated = false;
for line in raw.lines() {
let mut parts = line.splitn(3, ':');
let (Some(path), Some(num), Some(content)) = (parts.next(), parts.next(), parts.next())
else {
continue;
};
let Ok(line_number) = num.parse::<u64>() else {
continue;
};
if matches.len() >= SEARCH_MATCH_CAP {
truncated = true;
break;
}
matches.push(CodeMatch {
path: path.to_string(),
line_number,
line: content.to_string(),
});
}
(matches, truncated)
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn init_repo() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
for args in [
vec!["init", "-q", "-b", "main"],
vec!["config", "user.email", "t@example.com"],
vec!["config", "user.name", "Test"],
] {
assert!(
Command::new("git")
.current_dir(dir)
.args(&args)
.status()
.unwrap()
.success()
);
}
tmp
}
fn git_run(dir: &Path, args: &[&str]) {
assert!(
Command::new("git")
.current_dir(dir)
.args(args)
.status()
.unwrap()
.success(),
"git {args:?} failed"
);
}
fn fixture() -> tempfile::TempDir {
let tmp = init_repo();
let dir = tmp.path();
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(
dir.join("src/main.rs"),
"fn main() {\n register_watch_target_http();\n}\n",
)
.unwrap();
std::fs::write(dir.join(".gitignore"), "target/\n").unwrap();
git_run(dir, &["add", "src/main.rs", ".gitignore"]);
git_run(dir, &["commit", "-q", "-m", "first"]);
std::fs::write(dir.join("notes.txt"), "loose notes\n").unwrap();
std::fs::create_dir_all(dir.join("target/debug")).unwrap();
std::fs::write(dir.join("target/debug/foo"), "build artifact\n").unwrap();
tmp
}
#[test]
fn list_files_includes_tracked_and_untracked_excludes_gitignored() {
let tmp = fixture();
let files = list_files(tmp.path(), None);
assert!(files.iter().any(|f| f == "src/main.rs"), "tracked listed");
assert!(
files.iter().any(|f| f == "notes.txt"),
"untracked-not-ignored listed"
);
assert!(
!files.iter().any(|f| f.starts_with("target/")),
"gitignored excluded: {files:?}"
);
}
#[test]
fn list_files_scopes_to_subpath() {
let tmp = fixture();
let files = list_files(tmp.path(), Some("src"));
assert_eq!(files, vec!["src/main.rs".to_string()]);
}
#[test]
fn list_files_empty_when_not_a_git_repo() {
let tmp = tempfile::tempdir().unwrap();
assert!(list_files(tmp.path(), None).is_empty());
}
#[test]
fn read_file_happy_path_returns_working_tree_content() {
let tmp = fixture();
let out = read_file(tmp.path(), "src/main.rs").unwrap();
assert!(
out.content
.as_deref()
.unwrap()
.contains("register_watch_target_http")
);
assert!(out.message.is_none());
}
#[test]
fn read_file_refuses_dotdot_traversal() {
let tmp = fixture();
let parent = tmp.path().parent().unwrap();
std::fs::write(parent.join("paw-secret.txt"), "TOPSECRET").unwrap();
let out = read_file(tmp.path(), "../paw-secret.txt").unwrap();
assert!(out.content.is_none(), "traversal must be refused");
assert!(out.message.is_some());
}
#[test]
fn read_file_refuses_absolute_path_outside_root() {
let tmp = fixture();
let parent = tmp.path().parent().unwrap();
let secret = parent.join("paw-secret-abs.txt");
std::fs::write(&secret, "TOPSECRET").unwrap();
let abs = secret.to_string_lossy().into_owned();
let out = read_file(tmp.path(), &abs).unwrap();
assert!(out.content.is_none(), "absolute escape must be refused");
assert!(out.message.is_some());
}
#[test]
fn read_file_refuses_gitignored_path() {
let tmp = fixture();
let out = read_file(tmp.path(), "target/debug/foo").unwrap();
assert!(out.content.is_none(), "gitignored path must be refused");
assert!(
out.message.as_deref().unwrap().contains("gitignored"),
"message: {:?}",
out.message
);
}
#[test]
fn read_file_missing_file_yields_none() {
let tmp = fixture();
let out = read_file(tmp.path(), "src/does-not-exist.rs").unwrap();
assert!(out.content.is_none());
assert!(out.message.is_some());
}
#[test]
fn search_code_finds_known_string() {
let tmp = fixture();
let (matches, truncated) = search_code(tmp.path(), "register_watch_target_http", None);
assert!(!truncated);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].path, "src/main.rs");
assert_eq!(matches[0].line_number, 2);
assert!(matches[0].line.contains("register_watch_target_http"));
}
#[test]
fn search_code_empty_when_no_match() {
let tmp = fixture();
let (matches, truncated) = search_code(tmp.path(), "a-string-that-appears-nowhere", None);
assert!(matches.is_empty());
assert!(!truncated);
}
#[test]
fn search_code_empty_when_not_a_git_repo() {
let tmp = tempfile::tempdir().unwrap();
let (matches, truncated) = search_code(tmp.path(), "anything", None);
assert!(matches.is_empty());
assert!(!truncated);
}
#[test]
fn search_code_truncates_beyond_cap() {
let tmp = init_repo();
let dir = tmp.path();
let mut body = String::new();
for _ in 0..(SEARCH_MATCH_CAP + 50) {
body.push_str("needle\n");
}
std::fs::write(dir.join("big.txt"), body).unwrap();
git_run(dir, &["add", "big.txt"]);
let (matches, truncated) = search_code(dir, "needle", None);
assert_eq!(matches.len(), SEARCH_MATCH_CAP);
assert!(truncated);
}
}