| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550 |
- use std::cmp::Reverse;
- use std::fs;
- use std::io;
- use std::path::{Path, PathBuf};
- use std::time::Instant;
- use glob::Pattern;
- use regex::RegexBuilder;
- use serde::{Deserialize, Serialize};
- use walkdir::WalkDir;
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
- pub struct TextFilePayload {
- #[serde(rename = "filePath")]
- pub file_path: String,
- pub content: String,
- #[serde(rename = "numLines")]
- pub num_lines: usize,
- #[serde(rename = "startLine")]
- pub start_line: usize,
- #[serde(rename = "totalLines")]
- pub total_lines: usize,
- }
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
- pub struct ReadFileOutput {
- #[serde(rename = "type")]
- pub kind: String,
- pub file: TextFilePayload,
- }
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
- pub struct StructuredPatchHunk {
- #[serde(rename = "oldStart")]
- pub old_start: usize,
- #[serde(rename = "oldLines")]
- pub old_lines: usize,
- #[serde(rename = "newStart")]
- pub new_start: usize,
- #[serde(rename = "newLines")]
- pub new_lines: usize,
- pub lines: Vec<String>,
- }
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
- pub struct WriteFileOutput {
- #[serde(rename = "type")]
- pub kind: String,
- #[serde(rename = "filePath")]
- pub file_path: String,
- pub content: String,
- #[serde(rename = "structuredPatch")]
- pub structured_patch: Vec<StructuredPatchHunk>,
- #[serde(rename = "originalFile")]
- pub original_file: Option<String>,
- #[serde(rename = "gitDiff")]
- pub git_diff: Option<serde_json::Value>,
- }
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
- pub struct EditFileOutput {
- #[serde(rename = "filePath")]
- pub file_path: String,
- #[serde(rename = "oldString")]
- pub old_string: String,
- #[serde(rename = "newString")]
- pub new_string: String,
- #[serde(rename = "originalFile")]
- pub original_file: String,
- #[serde(rename = "structuredPatch")]
- pub structured_patch: Vec<StructuredPatchHunk>,
- #[serde(rename = "userModified")]
- pub user_modified: bool,
- #[serde(rename = "replaceAll")]
- pub replace_all: bool,
- #[serde(rename = "gitDiff")]
- pub git_diff: Option<serde_json::Value>,
- }
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
- pub struct GlobSearchOutput {
- #[serde(rename = "durationMs")]
- pub duration_ms: u128,
- #[serde(rename = "numFiles")]
- pub num_files: usize,
- pub filenames: Vec<String>,
- pub truncated: bool,
- }
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
- pub struct GrepSearchInput {
- pub pattern: String,
- pub path: Option<String>,
- pub glob: Option<String>,
- #[serde(rename = "output_mode")]
- pub output_mode: Option<String>,
- #[serde(rename = "-B")]
- pub before: Option<usize>,
- #[serde(rename = "-A")]
- pub after: Option<usize>,
- #[serde(rename = "-C")]
- pub context_short: Option<usize>,
- pub context: Option<usize>,
- #[serde(rename = "-n")]
- pub line_numbers: Option<bool>,
- #[serde(rename = "-i")]
- pub case_insensitive: Option<bool>,
- #[serde(rename = "type")]
- pub file_type: Option<String>,
- pub head_limit: Option<usize>,
- pub offset: Option<usize>,
- pub multiline: Option<bool>,
- }
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
- pub struct GrepSearchOutput {
- pub mode: Option<String>,
- #[serde(rename = "numFiles")]
- pub num_files: usize,
- pub filenames: Vec<String>,
- pub content: Option<String>,
- #[serde(rename = "numLines")]
- pub num_lines: Option<usize>,
- #[serde(rename = "numMatches")]
- pub num_matches: Option<usize>,
- #[serde(rename = "appliedLimit")]
- pub applied_limit: Option<usize>,
- #[serde(rename = "appliedOffset")]
- pub applied_offset: Option<usize>,
- }
- pub fn read_file(
- path: &str,
- offset: Option<usize>,
- limit: Option<usize>,
- ) -> io::Result<ReadFileOutput> {
- let absolute_path = normalize_path(path)?;
- let content = fs::read_to_string(&absolute_path)?;
- let lines: Vec<&str> = content.lines().collect();
- let start_index = offset.unwrap_or(0).min(lines.len());
- let end_index = limit.map_or(lines.len(), |limit| {
- start_index.saturating_add(limit).min(lines.len())
- });
- let selected = lines[start_index..end_index].join("\n");
- Ok(ReadFileOutput {
- kind: String::from("text"),
- file: TextFilePayload {
- file_path: absolute_path.to_string_lossy().into_owned(),
- content: selected,
- num_lines: end_index.saturating_sub(start_index),
- start_line: start_index.saturating_add(1),
- total_lines: lines.len(),
- },
- })
- }
- pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
- let absolute_path = normalize_path_allow_missing(path)?;
- let original_file = fs::read_to_string(&absolute_path).ok();
- if let Some(parent) = absolute_path.parent() {
- fs::create_dir_all(parent)?;
- }
- fs::write(&absolute_path, content)?;
- Ok(WriteFileOutput {
- kind: if original_file.is_some() {
- String::from("update")
- } else {
- String::from("create")
- },
- file_path: absolute_path.to_string_lossy().into_owned(),
- content: content.to_owned(),
- structured_patch: make_patch(original_file.as_deref().unwrap_or(""), content),
- original_file,
- git_diff: None,
- })
- }
- pub fn edit_file(
- path: &str,
- old_string: &str,
- new_string: &str,
- replace_all: bool,
- ) -> io::Result<EditFileOutput> {
- let absolute_path = normalize_path(path)?;
- let original_file = fs::read_to_string(&absolute_path)?;
- if old_string == new_string {
- return Err(io::Error::new(
- io::ErrorKind::InvalidInput,
- "old_string and new_string must differ",
- ));
- }
- if !original_file.contains(old_string) {
- return Err(io::Error::new(
- io::ErrorKind::NotFound,
- "old_string not found in file",
- ));
- }
- let updated = if replace_all {
- original_file.replace(old_string, new_string)
- } else {
- original_file.replacen(old_string, new_string, 1)
- };
- fs::write(&absolute_path, &updated)?;
- Ok(EditFileOutput {
- file_path: absolute_path.to_string_lossy().into_owned(),
- old_string: old_string.to_owned(),
- new_string: new_string.to_owned(),
- original_file: original_file.clone(),
- structured_patch: make_patch(&original_file, &updated),
- user_modified: false,
- replace_all,
- git_diff: None,
- })
- }
- pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOutput> {
- let started = Instant::now();
- let base_dir = path
- .map(normalize_path)
- .transpose()?
- .unwrap_or(std::env::current_dir()?);
- let search_pattern = if Path::new(pattern).is_absolute() {
- pattern.to_owned()
- } else {
- base_dir.join(pattern).to_string_lossy().into_owned()
- };
- let mut matches = Vec::new();
- let entries = glob::glob(&search_pattern)
- .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
- for entry in entries.flatten() {
- if entry.is_file() {
- matches.push(entry);
- }
- }
- matches.sort_by_key(|path| {
- fs::metadata(path)
- .and_then(|metadata| metadata.modified())
- .ok()
- .map(Reverse)
- });
- let truncated = matches.len() > 100;
- let filenames = matches
- .into_iter()
- .take(100)
- .map(|path| path.to_string_lossy().into_owned())
- .collect::<Vec<_>>();
- Ok(GlobSearchOutput {
- duration_ms: started.elapsed().as_millis(),
- num_files: filenames.len(),
- filenames,
- truncated,
- })
- }
- pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
- let base_path = input
- .path
- .as_deref()
- .map(normalize_path)
- .transpose()?
- .unwrap_or(std::env::current_dir()?);
- let regex = RegexBuilder::new(&input.pattern)
- .case_insensitive(input.case_insensitive.unwrap_or(false))
- .dot_matches_new_line(input.multiline.unwrap_or(false))
- .build()
- .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
- let glob_filter = input
- .glob
- .as_deref()
- .map(Pattern::new)
- .transpose()
- .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
- let file_type = input.file_type.as_deref();
- let output_mode = input
- .output_mode
- .clone()
- .unwrap_or_else(|| String::from("files_with_matches"));
- let context = input.context.or(input.context_short).unwrap_or(0);
- let mut filenames = Vec::new();
- let mut content_lines = Vec::new();
- let mut total_matches = 0usize;
- for file_path in collect_search_files(&base_path)? {
- if !matches_optional_filters(&file_path, glob_filter.as_ref(), file_type) {
- continue;
- }
- let Ok(file_contents) = fs::read_to_string(&file_path) else {
- continue;
- };
- if output_mode == "count" {
- let count = regex.find_iter(&file_contents).count();
- if count > 0 {
- filenames.push(file_path.to_string_lossy().into_owned());
- total_matches += count;
- }
- continue;
- }
- let lines: Vec<&str> = file_contents.lines().collect();
- let mut matched_lines = Vec::new();
- for (index, line) in lines.iter().enumerate() {
- if regex.is_match(line) {
- total_matches += 1;
- matched_lines.push(index);
- }
- }
- if matched_lines.is_empty() {
- continue;
- }
- filenames.push(file_path.to_string_lossy().into_owned());
- if output_mode == "content" {
- for index in matched_lines {
- let start = index.saturating_sub(input.before.unwrap_or(context));
- let end = (index + input.after.unwrap_or(context) + 1).min(lines.len());
- for (current, line) in lines.iter().enumerate().take(end).skip(start) {
- let prefix = if input.line_numbers.unwrap_or(true) {
- format!("{}:{}:", file_path.to_string_lossy(), current + 1)
- } else {
- format!("{}:", file_path.to_string_lossy())
- };
- content_lines.push(format!("{prefix}{line}"));
- }
- }
- }
- }
- let (filenames, applied_limit, applied_offset) =
- apply_limit(filenames, input.head_limit, input.offset);
- let content_output = if output_mode == "content" {
- let (lines, limit, offset) = apply_limit(content_lines, input.head_limit, input.offset);
- return Ok(GrepSearchOutput {
- mode: Some(output_mode),
- num_files: filenames.len(),
- filenames,
- num_lines: Some(lines.len()),
- content: Some(lines.join("\n")),
- num_matches: None,
- applied_limit: limit,
- applied_offset: offset,
- });
- } else {
- None
- };
- Ok(GrepSearchOutput {
- mode: Some(output_mode.clone()),
- num_files: filenames.len(),
- filenames,
- content: content_output,
- num_lines: None,
- num_matches: (output_mode == "count").then_some(total_matches),
- applied_limit,
- applied_offset,
- })
- }
- fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
- if base_path.is_file() {
- return Ok(vec![base_path.to_path_buf()]);
- }
- let mut files = Vec::new();
- for entry in WalkDir::new(base_path) {
- let entry = entry.map_err(|error| io::Error::other(error.to_string()))?;
- if entry.file_type().is_file() {
- files.push(entry.path().to_path_buf());
- }
- }
- Ok(files)
- }
- fn matches_optional_filters(
- path: &Path,
- glob_filter: Option<&Pattern>,
- file_type: Option<&str>,
- ) -> bool {
- if let Some(glob_filter) = glob_filter {
- let path_string = path.to_string_lossy();
- if !glob_filter.matches(&path_string) && !glob_filter.matches_path(path) {
- return false;
- }
- }
- if let Some(file_type) = file_type {
- let extension = path.extension().and_then(|extension| extension.to_str());
- if extension != Some(file_type) {
- return false;
- }
- }
- true
- }
- fn apply_limit<T>(
- items: Vec<T>,
- limit: Option<usize>,
- offset: Option<usize>,
- ) -> (Vec<T>, Option<usize>, Option<usize>) {
- let offset_value = offset.unwrap_or(0);
- let mut items = items.into_iter().skip(offset_value).collect::<Vec<_>>();
- let explicit_limit = limit.unwrap_or(250);
- if explicit_limit == 0 {
- return (items, None, (offset_value > 0).then_some(offset_value));
- }
- let truncated = items.len() > explicit_limit;
- items.truncate(explicit_limit);
- (
- items,
- truncated.then_some(explicit_limit),
- (offset_value > 0).then_some(offset_value),
- )
- }
- fn make_patch(original: &str, updated: &str) -> Vec<StructuredPatchHunk> {
- let mut lines = Vec::new();
- for line in original.lines() {
- lines.push(format!("-{line}"));
- }
- for line in updated.lines() {
- lines.push(format!("+{line}"));
- }
- vec![StructuredPatchHunk {
- old_start: 1,
- old_lines: original.lines().count(),
- new_start: 1,
- new_lines: updated.lines().count(),
- lines,
- }]
- }
- fn normalize_path(path: &str) -> io::Result<PathBuf> {
- let candidate = if Path::new(path).is_absolute() {
- PathBuf::from(path)
- } else {
- std::env::current_dir()?.join(path)
- };
- candidate.canonicalize()
- }
- fn normalize_path_allow_missing(path: &str) -> io::Result<PathBuf> {
- let candidate = if Path::new(path).is_absolute() {
- PathBuf::from(path)
- } else {
- std::env::current_dir()?.join(path)
- };
- if let Ok(canonical) = candidate.canonicalize() {
- return Ok(canonical);
- }
- if let Some(parent) = candidate.parent() {
- let canonical_parent = parent
- .canonicalize()
- .unwrap_or_else(|_| parent.to_path_buf());
- if let Some(name) = candidate.file_name() {
- return Ok(canonical_parent.join(name));
- }
- }
- Ok(candidate)
- }
- #[cfg(test)]
- mod tests {
- use std::time::{SystemTime, UNIX_EPOCH};
- use super::{edit_file, glob_search, grep_search, read_file, write_file, GrepSearchInput};
- fn temp_path(name: &str) -> std::path::PathBuf {
- let unique = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .expect("time should move forward")
- .as_nanos();
- std::env::temp_dir().join(format!("clawd-native-{name}-{unique}"))
- }
- #[test]
- fn reads_and_writes_files() {
- let path = temp_path("read-write.txt");
- let write_output = write_file(path.to_string_lossy().as_ref(), "one\ntwo\nthree")
- .expect("write should succeed");
- assert_eq!(write_output.kind, "create");
- let read_output = read_file(path.to_string_lossy().as_ref(), Some(1), Some(1))
- .expect("read should succeed");
- assert_eq!(read_output.file.content, "two");
- }
- #[test]
- fn edits_file_contents() {
- let path = temp_path("edit.txt");
- write_file(path.to_string_lossy().as_ref(), "alpha beta alpha")
- .expect("initial write should succeed");
- let output = edit_file(path.to_string_lossy().as_ref(), "alpha", "omega", true)
- .expect("edit should succeed");
- assert!(output.replace_all);
- }
- #[test]
- fn globs_and_greps_directory() {
- let dir = temp_path("search-dir");
- std::fs::create_dir_all(&dir).expect("directory should be created");
- let file = dir.join("demo.rs");
- write_file(
- file.to_string_lossy().as_ref(),
- "fn main() {\n println!(\"hello\");\n}\n",
- )
- .expect("file write should succeed");
- let globbed = glob_search("**/*.rs", Some(dir.to_string_lossy().as_ref()))
- .expect("glob should succeed");
- assert_eq!(globbed.num_files, 1);
- let grep_output = grep_search(&GrepSearchInput {
- pattern: String::from("hello"),
- path: Some(dir.to_string_lossy().into_owned()),
- glob: Some(String::from("**/*.rs")),
- output_mode: Some(String::from("content")),
- before: None,
- after: None,
- context_short: None,
- context: None,
- line_numbers: Some(true),
- case_insensitive: Some(false),
- file_type: None,
- head_limit: Some(10),
- offset: Some(0),
- multiline: Some(false),
- })
- .expect("grep should succeed");
- assert!(grep_output.content.unwrap_or_default().contains("hello"));
- }
- }
|