deepseek_rust_cli/tools/file_io/
search.rs1use anyhow::Result;
2use rayon::prelude::*;
3use regex::Regex;
4use tokio::fs;
5use walkdir::WalkDir;
6
7use crate::tools::base::validate_path;
8
9pub async fn list_directory(path: Option<&str>) -> Result<Vec<String>> {
10 let dir_str = path.unwrap_or(".");
11 let p = validate_path(dir_str)?;
12 let mut entries = fs::read_dir(p).await?;
13 let mut names = Vec::new();
14 while let Some(entry) = entries.next_entry().await? {
15 if let Ok(name) = entry.file_name().into_string() {
16 names.push(name);
17 }
18 }
19 Ok(names)
20}
21
22pub async fn search_files(
26 query: &str,
27 path: Option<&str>,
28 glob_pattern: Option<&str>,
29 max_results: usize,
30) -> Result<String> {
31 let search_path_str = path.unwrap_or(".");
32 let validated_path = validate_path(search_path_str)?;
33 let max = max_results.clamp(1, 500);
34
35 let escaped = regex::escape(query);
37 let pattern = format!("(?i){}", escaped);
38 let re = Regex::new(&pattern)
39 .or_else(|_| Regex::new(query))
40 .map_err(|e| anyhow::anyhow!("Invalid search pattern: {}", e))?;
41
42 let mut results: Vec<String> = Vec::new();
44 let walker = WalkDir::new(&validated_path)
45 .follow_links(false)
46 .into_iter()
47 .filter_entry(|e| {
48 if e.depth() == 0 {
50 return true;
51 }
52 let name = e.file_name().to_string_lossy();
54 if name.starts_with('.') {
55 return false;
56 }
57 if e.file_type().is_dir() {
58 let skip = ["target", "node_modules", "__pycache__", ".git"];
59 return !skip.contains(&name.as_ref());
60 }
61 true
62 });
63
64 let files: Vec<_> = walker
66 .filter_map(|e| e.ok())
67 .filter(|e| e.file_type().is_file())
68 .filter(|e| {
69 if let Some(glob) = glob_pattern {
70 let path_str = e.path().to_string_lossy();
71 let filename = e.file_name().to_string_lossy();
72 glob_match(glob, &filename) || glob_match(glob, &path_str)
74 } else {
75 true
76 }
77 })
78 .collect();
79
80 let matches: Vec<String> = files
82 .par_iter()
83 .filter_map(|entry| {
84 let path = entry.path();
85 let content = std::fs::read_to_string(path).ok()?;
86 let mut file_matches = Vec::new();
87
88 for (i, line) in content.lines().enumerate() {
89 if re.is_match(line) {
90 let display = if line.len() > 300 {
92 let truncate_at = line
93 .char_indices()
94 .nth(300)
95 .map(|(i, _)| i)
96 .unwrap_or(line.len());
97 format!("{}...", &line[..truncate_at])
98 } else {
99 line.to_string()
100 };
101 file_matches.push(format!("{}:{}: {}", path.display(), i + 1, display.trim()));
102 }
103 }
104
105 if file_matches.is_empty() {
106 None
107 } else {
108 Some(file_matches.join("\n"))
109 }
110 })
111 .collect();
112
113 for m in &matches {
114 if results.len() >= max {
115 break;
116 }
117 for line in m.lines() {
118 if results.len() >= max {
119 break;
120 }
121 results.push(line.to_string());
122 }
123 }
124
125 if results.is_empty() {
126 Ok(format!("No matches found for '{}'.", query))
127 } else {
128 let total = results.len();
129 let truncated = total >= max;
130 let mut output = results.join("\n");
131 if truncated {
132 output.push_str(&format!(
133 "\n... (truncated to {} results, {} total matches)",
134 max, total
135 ));
136 }
137 Ok(output)
138 }
139}
140
141fn glob_match(pattern: &str, text: &str) -> bool {
143 let parts: Vec<&str> = pattern.split('*').collect();
144 if parts.len() == 1 {
145 return text.contains(pattern);
146 }
147
148 let mut pos = 0usize;
149 for (i, part) in parts.iter().enumerate() {
150 if part.is_empty() {
151 continue;
152 }
153 if i == 0 {
154 if !text.starts_with(part) {
156 return false;
157 }
158 pos = part.len();
159 } else if i == parts.len() - 1 {
160 return text[pos..].ends_with(part);
162 } else {
163 match text[pos..].find(part) {
165 Some(idx) => pos += idx + part.len(),
166 None => return false,
167 }
168 }
169 }
170 true
171}
172
173#[cfg(test)]
174mod tests {
175 use std::fs;
176
177 use tempfile::TempDir;
178
179 use super::*;
180
181 fn tempdir_in_cwd() -> TempDir {
182 TempDir::new_in(".").expect("Failed to create temp dir in CWD")
183 }
184
185 #[tokio::test]
186 async fn test_search_files_basic() {
187 let dir = tempdir_in_cwd();
188 let file_path = dir.path().join("search_test.rs");
189 fs::write(
190 &file_path,
191 "fn main() {\n println!(\"hello world\");\n let x = 42;\n}\n",
192 )
193 .unwrap();
194
195 let dir_str = dir.path().to_str().unwrap();
196 let result = search_files("hello", Some(dir_str), Some("*.rs"), 50)
197 .await
198 .unwrap();
199 assert!(result.contains("hello"));
200 assert!(result.contains("search_test.rs"));
201 }
202
203 #[tokio::test]
204 async fn test_search_files_no_match() {
205 let dir = tempdir_in_cwd();
206 let file_path = dir.path().join("empty.rs");
207 fs::write(&file_path, "just some text\nnothing here\n").unwrap();
208
209 let dir_str = dir.path().to_str().unwrap();
210 let result = search_files("nonexistent", Some(dir_str), None, 50)
211 .await
212 .unwrap();
213
214 assert!(result.contains("No matches found"));
215 }
216}