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, } #[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, #[serde(rename = "originalFile")] pub original_file: Option, #[serde(rename = "gitDiff")] pub git_diff: Option, } #[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, #[serde(rename = "userModified")] pub user_modified: bool, #[serde(rename = "replaceAll")] pub replace_all: bool, #[serde(rename = "gitDiff")] pub git_diff: Option, } #[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, pub truncated: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct GrepSearchInput { pub pattern: String, pub path: Option, pub glob: Option, #[serde(rename = "output_mode")] pub output_mode: Option, #[serde(rename = "-B")] pub before: Option, #[serde(rename = "-A")] pub after: Option, #[serde(rename = "-C")] pub context_short: Option, pub context: Option, #[serde(rename = "-n")] pub line_numbers: Option, #[serde(rename = "-i")] pub case_insensitive: Option, #[serde(rename = "type")] pub file_type: Option, pub head_limit: Option, pub offset: Option, pub multiline: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct GrepSearchOutput { pub mode: Option, #[serde(rename = "numFiles")] pub num_files: usize, pub filenames: Vec, pub content: Option, #[serde(rename = "numLines")] pub num_lines: Option, #[serde(rename = "numMatches")] pub num_matches: Option, #[serde(rename = "appliedLimit")] pub applied_limit: Option, #[serde(rename = "appliedOffset")] pub applied_offset: Option, } pub fn read_file( path: &str, offset: Option, limit: Option, ) -> io::Result { 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 { 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 { 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 { 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::>(); Ok(GlobSearchOutput { duration_ms: started.elapsed().as_millis(), num_files: filenames.len(), filenames, truncated, }) } pub fn grep_search(input: &GrepSearchInput) -> io::Result { 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> { 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( items: Vec, limit: Option, offset: Option, ) -> (Vec, Option, Option) { let offset_value = offset.unwrap_or(0); let mut items = items.into_iter().skip(offset_value).collect::>(); 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 { 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 { 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 { 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")); } }