Skip to main content

deepseek_rust_cli/tools/file_io/
search.rs

1use 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
22/// Search files for a text pattern using native Rust (no shell process).
23/// Uses literal search by default, with regex support.
24/// Returns matching lines with file path and line number.
25pub 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    // Compile pattern: prefer case-insensitive literal, fall back to raw regex
36    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    // Collect files matching glob
43    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            // Always include the root directory (depth 0)
49            if e.depth() == 0 {
50                return true;
51            }
52            // Skip hidden directories and common ignore dirs
53            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    // Collect candidate files
65    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                // Simple glob matching: support * and ** patterns
73                glob_match(glob, &filename) || glob_match(glob, &path_str)
74            } else {
75                true
76            }
77        })
78        .collect();
79
80    // Search in parallel using rayon
81    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                    // Truncate long lines
91                    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
141/// Simple glob matching: supports * wildcard
142fn 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            // Must match at start
155            if !text.starts_with(part) {
156                return false;
157            }
158            pos = part.len();
159        } else if i == parts.len() - 1 {
160            // Must match at end
161            return text[pos..].ends_with(part);
162        } else {
163            // Match in middle
164            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}