1use glob::Pattern;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use thiserror::Error;
13
14pub type ExploreResult<T> = Result<T, ExploreError>;
16
17#[derive(Debug, Error)]
19pub enum ExploreError {
20 #[error("Invalid path: {0}")]
22 InvalidPath(String),
23
24 #[error("File not found: {0}")]
26 FileNotFound(String),
27
28 #[error("Invalid pattern: {0}")]
30 PatternError(String),
31
32 #[error("IO error: {0}")]
34 Io(#[from] std::io::Error),
35
36 #[error("Search error: {0}")]
38 SearchError(String),
39
40 #[error("Analysis error: {0}")]
42 AnalysisError(String),
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
48#[serde(rename_all = "camelCase")]
49pub enum ThoroughnessLevel {
50 Quick,
52
53 #[default]
55 Medium,
56
57 VeryThorough,
59}
60
61impl ThoroughnessLevel {
62 pub fn max_depth(&self) -> usize {
64 match self {
65 ThoroughnessLevel::Quick => 2,
66 ThoroughnessLevel::Medium => 5,
67 ThoroughnessLevel::VeryThorough => 10,
68 }
69 }
70
71 pub fn max_files(&self) -> usize {
73 match self {
74 ThoroughnessLevel::Quick => 50,
75 ThoroughnessLevel::Medium => 200,
76 ThoroughnessLevel::VeryThorough => 1000,
77 }
78 }
79
80 pub fn context_lines(&self) -> usize {
82 match self {
83 ThoroughnessLevel::Quick => 1,
84 ThoroughnessLevel::Medium => 3,
85 ThoroughnessLevel::VeryThorough => 5,
86 }
87 }
88
89 pub fn max_content_size(&self) -> usize {
91 match self {
92 ThoroughnessLevel::Quick => 10_000,
93 ThoroughnessLevel::Medium => 50_000,
94 ThoroughnessLevel::VeryThorough => 200_000,
95 }
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct ExploreOptions {
103 pub thoroughness: ThoroughnessLevel,
105
106 pub query: String,
108
109 pub target_path: Option<PathBuf>,
111
112 pub patterns: Option<Vec<String>>,
114
115 pub max_results: Option<usize>,
117
118 pub include_hidden: bool,
120}
121
122impl Default for ExploreOptions {
123 fn default() -> Self {
124 Self {
125 thoroughness: ThoroughnessLevel::Medium,
126 query: String::new(),
127 target_path: None,
128 patterns: None,
129 max_results: None,
130 include_hidden: false,
131 }
132 }
133}
134
135impl ExploreOptions {
136 pub fn new(query: impl Into<String>) -> Self {
138 Self {
139 query: query.into(),
140 ..Default::default()
141 }
142 }
143
144 pub fn with_thoroughness(mut self, level: ThoroughnessLevel) -> Self {
146 self.thoroughness = level;
147 self
148 }
149
150 pub fn with_target_path(mut self, path: impl Into<PathBuf>) -> Self {
152 self.target_path = Some(path.into());
153 self
154 }
155
156 pub fn with_patterns(mut self, patterns: Vec<String>) -> Self {
158 self.patterns = Some(patterns);
159 self
160 }
161
162 pub fn with_max_results(mut self, max: usize) -> Self {
164 self.max_results = Some(max);
165 self
166 }
167
168 pub fn with_hidden(mut self, include: bool) -> Self {
170 self.include_hidden = include;
171 self
172 }
173
174 pub fn effective_max_results(&self) -> usize {
176 self.max_results
177 .unwrap_or_else(|| self.thoroughness.max_files())
178 }
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
183#[serde(rename_all = "camelCase")]
184pub struct CodeSnippet {
185 pub file_path: PathBuf,
187
188 pub line_number: usize,
190
191 pub content: String,
193
194 pub context_before: Vec<String>,
196
197 pub context_after: Vec<String>,
199
200 pub matched_term: String,
202}
203
204impl CodeSnippet {
205 pub fn new(
207 file_path: impl Into<PathBuf>,
208 line_number: usize,
209 content: impl Into<String>,
210 matched_term: impl Into<String>,
211 ) -> Self {
212 Self {
213 file_path: file_path.into(),
214 line_number,
215 content: content.into(),
216 context_before: Vec::new(),
217 context_after: Vec::new(),
218 matched_term: matched_term.into(),
219 }
220 }
221
222 pub fn with_context(mut self, before: Vec<String>, after: Vec<String>) -> Self {
224 self.context_before = before;
225 self.context_after = after;
226 self
227 }
228}
229
230#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
232#[serde(rename_all = "camelCase")]
233pub struct ExploreStats {
234 pub files_scanned: usize,
236
237 pub directories_traversed: usize,
239
240 pub matches_found: usize,
242
243 pub bytes_read: usize,
245
246 pub duration_ms: u64,
248
249 pub files_by_extension: HashMap<String, usize>,
251}
252
253impl ExploreStats {
254 pub fn new() -> Self {
256 Self::default()
257 }
258
259 pub fn record_file(&mut self, extension: Option<&str>, bytes: usize) {
261 self.files_scanned += 1;
262 self.bytes_read += bytes;
263 if let Some(ext) = extension {
264 *self.files_by_extension.entry(ext.to_string()).or_insert(0) += 1;
265 }
266 }
267
268 pub fn record_directory(&mut self) {
270 self.directories_traversed += 1;
271 }
272
273 pub fn record_matches(&mut self, count: usize) {
275 self.matches_found += count;
276 }
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
281#[serde(rename_all = "camelCase")]
282pub struct ExploreResultData {
283 pub files: Vec<PathBuf>,
285
286 pub code_snippets: Vec<CodeSnippet>,
288
289 pub summary: String,
291
292 pub suggestions: Vec<String>,
294
295 pub stats: ExploreStats,
297}
298
299impl Default for ExploreResultData {
300 fn default() -> Self {
301 Self {
302 files: Vec::new(),
303 code_snippets: Vec::new(),
304 summary: String::new(),
305 suggestions: Vec::new(),
306 stats: ExploreStats::new(),
307 }
308 }
309}
310
311impl ExploreResultData {
312 pub fn new() -> Self {
314 Self::default()
315 }
316
317 pub fn with_files(mut self, files: Vec<PathBuf>) -> Self {
319 self.files = files;
320 self
321 }
322
323 pub fn with_snippets(mut self, snippets: Vec<CodeSnippet>) -> Self {
325 self.code_snippets = snippets;
326 self
327 }
328
329 pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
331 self.summary = summary.into();
332 self
333 }
334
335 pub fn with_suggestions(mut self, suggestions: Vec<String>) -> Self {
337 self.suggestions = suggestions;
338 self
339 }
340
341 pub fn with_stats(mut self, stats: ExploreStats) -> Self {
343 self.stats = stats;
344 self
345 }
346}
347
348#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
350#[serde(rename_all = "camelCase")]
351pub struct StructureAnalysis {
352 pub file_path: PathBuf,
354
355 pub language: Option<String>,
357
358 pub exports: Vec<String>,
360
361 pub imports: Vec<String>,
363
364 pub classes: Vec<String>,
366
367 pub functions: Vec<String>,
369
370 pub interfaces: Vec<String>,
372
373 pub types: Vec<String>,
375
376 pub constants: Vec<String>,
378}
379
380impl StructureAnalysis {
381 pub fn new(file_path: impl Into<PathBuf>) -> Self {
383 Self {
384 file_path: file_path.into(),
385 ..Default::default()
386 }
387 }
388
389 pub fn with_language(mut self, language: impl Into<String>) -> Self {
391 self.language = Some(language.into());
392 self
393 }
394
395 pub fn has_structure(&self) -> bool {
397 !self.exports.is_empty()
398 || !self.imports.is_empty()
399 || !self.classes.is_empty()
400 || !self.functions.is_empty()
401 || !self.interfaces.is_empty()
402 || !self.types.is_empty()
403 || !self.constants.is_empty()
404 }
405
406 pub fn total_items(&self) -> usize {
408 self.exports.len()
409 + self.imports.len()
410 + self.classes.len()
411 + self.functions.len()
412 + self.interfaces.len()
413 + self.types.len()
414 + self.constants.len()
415 }
416}
417
418pub struct ExploreAgent {
426 options: ExploreOptions,
427}
428
429impl ExploreAgent {
430 pub fn new(options: ExploreOptions) -> Self {
432 Self { options }
433 }
434
435 pub fn options(&self) -> &ExploreOptions {
437 &self.options
438 }
439
440 fn target_path(&self) -> PathBuf {
442 self.options
443 .target_path
444 .clone()
445 .unwrap_or_else(|| PathBuf::from("."))
446 }
447
448 fn should_include_path(&self, path: &Path) -> bool {
450 if self.options.include_hidden {
451 return true;
452 }
453
454 if let Some(name) = path.file_name() {
457 if let Some(name_str) = name.to_str() {
458 if name_str.starts_with('.') {
459 return false;
460 }
461 }
462 }
463 true
464 }
465
466 fn matches_patterns(&self, path: &Path) -> bool {
468 let patterns = match &self.options.patterns {
469 Some(p) if !p.is_empty() => p,
470 _ => return true, };
472
473 let path_str = path.to_string_lossy();
474 for pattern in patterns {
475 if let Ok(glob) = Pattern::new(pattern) {
477 if glob.matches(&path_str) {
478 return true;
479 }
480 }
481 if let Some(filename) = path.file_name() {
483 let filename_str = filename.to_string_lossy();
484 if let Ok(glob) = Pattern::new(pattern) {
485 if glob.matches(&filename_str) {
486 return true;
487 }
488 }
489 if pattern.starts_with("*.") {
491 let ext = pattern.get(2..).unwrap_or("");
492 if let Some(file_ext) = path.extension() {
493 if file_ext.to_string_lossy() == ext {
494 return true;
495 }
496 }
497 }
498 }
499 }
500 false
501 }
502
503 pub async fn explore(&self) -> ExploreResult<ExploreResultData> {
505 let start = std::time::Instant::now();
506 let mut stats = ExploreStats::new();
507 let mut files = Vec::new();
508 let mut code_snippets = Vec::new();
509
510 let target = self.target_path();
511 if !target.exists() {
512 return Err(ExploreError::InvalidPath(format!(
513 "Target path does not exist: {}",
514 target.display()
515 )));
516 }
517
518 let found_files = self.find_files_internal(&target, &mut stats)?;
520 let max_results = self.options.effective_max_results();
521
522 for file_path in found_files.into_iter().take(max_results) {
523 files.push(file_path.clone());
524
525 if !self.options.query.is_empty() {
527 if let Ok(snippets) = self.search_in_file(&file_path, &self.options.query) {
528 stats.record_matches(snippets.len());
529 code_snippets.extend(snippets);
530 }
531 }
532 }
533
534 stats.duration_ms = start.elapsed().as_millis() as u64;
535
536 let summary = self.generate_summary(&files, &code_snippets, &stats);
538 let suggestions = self.generate_suggestions(&files, &code_snippets);
539
540 Ok(ExploreResultData::new()
541 .with_files(files)
542 .with_snippets(code_snippets)
543 .with_summary(summary)
544 .with_suggestions(suggestions)
545 .with_stats(stats))
546 }
547
548 pub async fn find_files(&self, pattern: &str) -> ExploreResult<Vec<PathBuf>> {
550 let mut stats = ExploreStats::new();
551 let target = self.target_path();
552
553 if !target.exists() {
554 return Err(ExploreError::InvalidPath(format!(
555 "Target path does not exist: {}",
556 target.display()
557 )));
558 }
559
560 let temp_options = ExploreOptions {
562 patterns: Some(vec![pattern.to_string()]),
563 ..self.options.clone()
564 };
565
566 let temp_agent = ExploreAgent::new(temp_options);
567 let files = temp_agent.find_files_internal(&target, &mut stats)?;
568
569 let max_results = self.options.effective_max_results();
570 Ok(files.into_iter().take(max_results).collect())
571 }
572
573 fn find_files_internal(
575 &self,
576 path: &Path,
577 stats: &mut ExploreStats,
578 ) -> ExploreResult<Vec<PathBuf>> {
579 let mut files = Vec::new();
580 let max_depth = self.options.thoroughness.max_depth();
581 let max_files = self.options.effective_max_results();
582
583 self.find_files_recursive(path, 0, max_depth, max_files, &mut files, stats, true)?;
585 Ok(files)
586 }
587
588 #[allow(clippy::too_many_arguments)]
589 fn find_files_recursive(
590 &self,
591 path: &Path,
592 current_depth: usize,
593 max_depth: usize,
594 max_files: usize,
595 files: &mut Vec<PathBuf>,
596 stats: &mut ExploreStats,
597 is_root: bool,
598 ) -> ExploreResult<()> {
599 if current_depth > max_depth || files.len() >= max_files {
600 return Ok(());
601 }
602
603 if path.is_file() {
604 if self.should_include_path(path) && self.matches_patterns(path) {
605 let ext = path.extension().and_then(|e| e.to_str());
606 let size = path.metadata().map(|m| m.len() as usize).unwrap_or(0);
607 stats.record_file(ext, size);
608 files.push(path.to_path_buf());
609 }
610 return Ok(());
611 }
612
613 if path.is_dir() {
614 if !is_root && !self.should_include_path(path) {
616 return Ok(());
617 }
618
619 stats.record_directory();
620
621 let entries = std::fs::read_dir(path)?;
622 for entry in entries.flatten() {
623 if files.len() >= max_files {
624 break;
625 }
626 self.find_files_recursive(
627 &entry.path(),
628 current_depth + 1,
629 max_depth,
630 max_files,
631 files,
632 stats,
633 false, )?;
635 }
636 }
637
638 Ok(())
639 }
640
641 pub async fn search_code(&self, keyword: &str) -> ExploreResult<Vec<CodeSnippet>> {
643 let mut stats = ExploreStats::new();
644 let target = self.target_path();
645
646 if !target.exists() {
647 return Err(ExploreError::InvalidPath(format!(
648 "Target path does not exist: {}",
649 target.display()
650 )));
651 }
652
653 let files = self.find_files_internal(&target, &mut stats)?;
654 let mut snippets = Vec::new();
655 let max_results = self.options.effective_max_results();
656
657 for file_path in files {
658 if snippets.len() >= max_results {
659 break;
660 }
661
662 if let Ok(file_snippets) = self.search_in_file(&file_path, keyword) {
663 for snippet in file_snippets {
664 if snippets.len() >= max_results {
665 break;
666 }
667 snippets.push(snippet);
668 }
669 }
670 }
671
672 Ok(snippets)
673 }
674
675 fn search_in_file(&self, path: &Path, keyword: &str) -> ExploreResult<Vec<CodeSnippet>> {
677 let max_size = self.options.thoroughness.max_content_size();
678 let context_lines = self.options.thoroughness.context_lines();
679
680 let content = std::fs::read_to_string(path).map_err(|e| {
681 ExploreError::Io(std::io::Error::new(
682 e.kind(),
683 format!("{}: {}", path.display(), e),
684 ))
685 })?;
686
687 if content.len() > max_size {
689 return Ok(Vec::new());
690 }
691
692 let lines: Vec<&str> = content.lines().collect();
693 let keyword_lower = keyword.to_lowercase();
694 let mut snippets = Vec::new();
695
696 for (idx, line) in lines.iter().enumerate() {
697 if line.to_lowercase().contains(&keyword_lower) {
698 let line_number = idx + 1;
699
700 let start = idx.saturating_sub(context_lines);
702 let end = (idx + context_lines + 1).min(lines.len());
703
704 let context_before: Vec<String> =
705 lines[start..idx].iter().map(|s| s.to_string()).collect();
706 let context_after: Vec<String> = lines[(idx + 1)..end]
707 .iter()
708 .map(|s| s.to_string())
709 .collect();
710
711 let snippet = CodeSnippet::new(path, line_number, *line, keyword)
712 .with_context(context_before, context_after);
713
714 snippets.push(snippet);
715 }
716 }
717
718 Ok(snippets)
719 }
720
721 pub fn analyze_structure(&self, file_path: &Path) -> ExploreResult<StructureAnalysis> {
723 if !file_path.exists() {
724 return Err(ExploreError::FileNotFound(file_path.display().to_string()));
725 }
726
727 if !file_path.is_file() {
728 return Err(ExploreError::InvalidPath(format!(
729 "Not a file: {}",
730 file_path.display()
731 )));
732 }
733
734 let content = std::fs::read_to_string(file_path)?;
735 let language = self.detect_language(file_path);
736
737 let mut analysis = StructureAnalysis::new(file_path);
738 if let Some(lang) = &language {
739 analysis = analysis.with_language(lang);
740 }
741
742 match language.as_deref() {
744 Some("rust") => self.analyze_rust(&content, &mut analysis),
745 Some("python") => self.analyze_python(&content, &mut analysis),
746 Some("javascript") | Some("typescript") => self.analyze_js_ts(&content, &mut analysis),
747 Some("go") => self.analyze_go(&content, &mut analysis),
748 _ => self.analyze_generic(&content, &mut analysis),
749 }
750
751 Ok(analysis)
752 }
753
754 fn detect_language(&self, path: &Path) -> Option<String> {
756 let ext = path.extension()?.to_str()?;
757 match ext.to_lowercase().as_str() {
758 "rs" => Some("rust".to_string()),
759 "py" => Some("python".to_string()),
760 "js" | "mjs" | "cjs" => Some("javascript".to_string()),
761 "ts" | "tsx" => Some("typescript".to_string()),
762 "go" => Some("go".to_string()),
763 "java" => Some("java".to_string()),
764 "c" | "h" => Some("c".to_string()),
765 "cpp" | "cc" | "cxx" | "hpp" => Some("cpp".to_string()),
766 "rb" => Some("ruby".to_string()),
767 "php" => Some("php".to_string()),
768 "swift" => Some("swift".to_string()),
769 "kt" | "kts" => Some("kotlin".to_string()),
770 "scala" => Some("scala".to_string()),
771 "cs" => Some("csharp".to_string()),
772 _ => None,
773 }
774 }
775
776 fn analyze_rust(&self, content: &str, analysis: &mut StructureAnalysis) {
778 for line in content.lines() {
779 let trimmed = line.trim();
780
781 if trimmed.starts_with("use ") {
783 if let Some(import) = trimmed
784 .strip_prefix("use ")
785 .and_then(|s| s.strip_suffix(';'))
786 {
787 analysis.imports.push(import.to_string());
788 }
789 }
790
791 if trimmed.starts_with("pub ") {
793 if let Some(rest) = trimmed.strip_prefix("pub ") {
794 if rest.starts_with("fn ") {
795 if let Some(name) = self.extract_fn_name(rest) {
796 analysis.exports.push(name.clone());
797 analysis.functions.push(name);
798 }
799 } else if rest.starts_with("struct ") {
800 if let Some(name) = self.extract_type_name(rest, "struct ") {
801 analysis.exports.push(name.clone());
802 analysis.types.push(name);
803 }
804 } else if rest.starts_with("enum ") {
805 if let Some(name) = self.extract_type_name(rest, "enum ") {
806 analysis.exports.push(name.clone());
807 analysis.types.push(name);
808 }
809 } else if rest.starts_with("trait ") {
810 if let Some(name) = self.extract_type_name(rest, "trait ") {
811 analysis.exports.push(name.clone());
812 analysis.interfaces.push(name);
813 }
814 } else if rest.starts_with("const ") {
815 if let Some(name) = self.extract_const_name(rest) {
816 analysis.exports.push(name.clone());
817 analysis.constants.push(name);
818 }
819 }
820 }
821 }
822
823 if trimmed.starts_with("fn ") && !trimmed.starts_with("fn main") {
825 if let Some(name) = self.extract_fn_name(trimmed) {
826 if !analysis.functions.contains(&name) {
827 analysis.functions.push(name);
828 }
829 }
830 }
831
832 if trimmed.starts_with("struct ") {
833 if let Some(name) = self.extract_type_name(trimmed, "struct ") {
834 if !analysis.types.contains(&name) {
835 analysis.types.push(name);
836 }
837 }
838 }
839
840 if trimmed.starts_with("impl ") {
841 if let Some(name) = self.extract_impl_name(trimmed) {
842 if !analysis.classes.contains(&name) {
843 analysis.classes.push(name);
844 }
845 }
846 }
847 }
848 }
849
850 fn analyze_python(&self, content: &str, analysis: &mut StructureAnalysis) {
852 for line in content.lines() {
853 let trimmed = line.trim();
854
855 if trimmed.starts_with("import ") || trimmed.starts_with("from ") {
857 analysis.imports.push(trimmed.to_string());
858 }
859
860 if trimmed.starts_with("class ") {
862 if let Some(name) = self.extract_python_class_name(trimmed) {
863 analysis.classes.push(name.clone());
864 if !name.starts_with('_') {
866 analysis.exports.push(name);
867 }
868 }
869 }
870
871 if line.starts_with("def ") {
873 if let Some(name) = self.extract_python_fn_name(trimmed) {
874 analysis.functions.push(name.clone());
875 if !name.starts_with('_') {
876 analysis.exports.push(name);
877 }
878 }
879 }
880
881 if !line.starts_with(' ') && !line.starts_with('\t') {
883 if let Some((name, _)) = trimmed.split_once('=') {
884 let name = name.trim();
885 if name.chars().all(|c| c.is_uppercase() || c == '_') && !name.is_empty() {
886 analysis.constants.push(name.to_string());
887 }
888 }
889 }
890 }
891 }
892
893 fn analyze_js_ts(&self, content: &str, analysis: &mut StructureAnalysis) {
895 for line in content.lines() {
896 let trimmed = line.trim();
897
898 if trimmed.starts_with("import ") {
900 analysis.imports.push(trimmed.to_string());
901 }
902
903 if trimmed.starts_with("export ") {
905 let rest = trimmed.strip_prefix("export ").unwrap_or("");
906
907 if rest.starts_with("default ") {
908 analysis.exports.push("default".to_string());
909 } else if rest.starts_with("function ") {
910 if let Some(name) = self.extract_js_fn_name(rest) {
911 analysis.exports.push(name.clone());
912 analysis.functions.push(name);
913 }
914 } else if rest.starts_with("class ") {
915 if let Some(name) = self.extract_js_class_name(rest) {
916 analysis.exports.push(name.clone());
917 analysis.classes.push(name);
918 }
919 } else if rest.starts_with("interface ") {
920 if let Some(name) = self.extract_type_name(rest, "interface ") {
921 analysis.exports.push(name.clone());
922 analysis.interfaces.push(name);
923 }
924 } else if rest.starts_with("type ") {
925 if let Some(name) = self.extract_type_name(rest, "type ") {
926 analysis.exports.push(name.clone());
927 analysis.types.push(name);
928 }
929 } else if rest.starts_with("const ") {
930 if let Some(name) = self.extract_js_const_name(rest) {
931 analysis.exports.push(name.clone());
932 analysis.constants.push(name);
933 }
934 }
935 }
936
937 if trimmed.starts_with("function ") {
939 if let Some(name) = self.extract_js_fn_name(trimmed) {
940 if !analysis.functions.contains(&name) {
941 analysis.functions.push(name);
942 }
943 }
944 }
945
946 if trimmed.starts_with("class ") {
947 if let Some(name) = self.extract_js_class_name(trimmed) {
948 if !analysis.classes.contains(&name) {
949 analysis.classes.push(name);
950 }
951 }
952 }
953
954 if trimmed.starts_with("interface ") {
955 if let Some(name) = self.extract_type_name(trimmed, "interface ") {
956 if !analysis.interfaces.contains(&name) {
957 analysis.interfaces.push(name);
958 }
959 }
960 }
961 }
962 }
963
964 fn analyze_go(&self, content: &str, analysis: &mut StructureAnalysis) {
966 for line in content.lines() {
967 let trimmed = line.trim();
968
969 if trimmed.starts_with("import ") || trimmed.starts_with("import (") {
971 analysis.imports.push(trimmed.to_string());
972 }
973
974 if trimmed.starts_with("func ") {
976 if let Some(name) = self.extract_go_fn_name(trimmed) {
977 analysis.functions.push(name.clone());
978 if name
980 .chars()
981 .next()
982 .map(|c| c.is_uppercase())
983 .unwrap_or(false)
984 {
985 analysis.exports.push(name);
986 }
987 }
988 }
989
990 if trimmed.starts_with("type ") {
992 if let Some(name) = self.extract_go_type_name(trimmed) {
993 if trimmed.contains(" struct ") {
994 analysis.types.push(name.clone());
995 } else if trimmed.contains(" interface ") {
996 analysis.interfaces.push(name.clone());
997 } else {
998 analysis.types.push(name.clone());
999 }
1000 if name
1002 .chars()
1003 .next()
1004 .map(|c| c.is_uppercase())
1005 .unwrap_or(false)
1006 {
1007 analysis.exports.push(name);
1008 }
1009 }
1010 }
1011
1012 if trimmed.starts_with("const ") {
1014 if let Some(name) = self.extract_go_const_name(trimmed) {
1015 analysis.constants.push(name.clone());
1016 if name
1017 .chars()
1018 .next()
1019 .map(|c| c.is_uppercase())
1020 .unwrap_or(false)
1021 {
1022 analysis.exports.push(name);
1023 }
1024 }
1025 }
1026 }
1027 }
1028
1029 fn analyze_generic(&self, content: &str, analysis: &mut StructureAnalysis) {
1031 for line in content.lines() {
1032 let trimmed = line.trim();
1033
1034 if trimmed.contains("import ") || trimmed.contains("require(") {
1036 analysis.imports.push(trimmed.to_string());
1037 }
1038
1039 if trimmed.contains("function ") || trimmed.contains("def ") || trimmed.contains("fn ")
1040 {
1041 analysis.functions.push(trimmed.to_string());
1042 }
1043
1044 if trimmed.contains("class ") {
1045 analysis.classes.push(trimmed.to_string());
1046 }
1047 }
1048 }
1049
1050 fn extract_fn_name(&self, line: &str) -> Option<String> {
1053 let rest = line.strip_prefix("fn ")?.trim();
1054 let name_end = rest.find(|c: char| c == '(' || c == '<' || c.is_whitespace())?;
1055 Some(rest.get(..name_end)?.to_string())
1056 }
1057
1058 fn extract_type_name(&self, line: &str, prefix: &str) -> Option<String> {
1059 let rest = line.strip_prefix(prefix)?.trim();
1060 let name_end =
1061 rest.find(|c: char| c == '{' || c == '<' || c == '(' || c.is_whitespace())?;
1062 Some(rest.get(..name_end)?.to_string())
1063 }
1064
1065 fn extract_const_name(&self, line: &str) -> Option<String> {
1066 let rest = line.strip_prefix("const ")?.trim();
1067 let name_end = rest.find(|c: char| c == ':' || c == '=' || c.is_whitespace())?;
1068 Some(rest.get(..name_end)?.to_string())
1069 }
1070
1071 fn extract_impl_name(&self, line: &str) -> Option<String> {
1072 let rest = line.strip_prefix("impl")?.trim();
1073 let rest = if rest.starts_with('<') {
1075 let end = rest.find('>')?;
1076 rest.get(end + 1..)?.trim()
1077 } else {
1078 rest
1079 };
1080 let name_end = rest.find(|c: char| c == '{' || c == '<' || c.is_whitespace())?;
1081 let name = rest.get(..name_end)?.trim();
1082 if name.is_empty() {
1083 None
1084 } else {
1085 Some(name.to_string())
1086 }
1087 }
1088
1089 fn extract_python_class_name(&self, line: &str) -> Option<String> {
1090 let rest = line.strip_prefix("class ")?.trim();
1091 let name_end = rest.find(|c: char| c == '(' || c == ':' || c.is_whitespace())?;
1092 Some(rest.get(..name_end)?.to_string())
1093 }
1094
1095 fn extract_python_fn_name(&self, line: &str) -> Option<String> {
1096 let rest = line.strip_prefix("def ")?.trim();
1097 let name_end = rest.find('(')?;
1098 Some(rest.get(..name_end)?.to_string())
1099 }
1100
1101 fn extract_js_fn_name(&self, line: &str) -> Option<String> {
1102 let rest = line.strip_prefix("function ")?.trim();
1103 let name_end = rest.find(|c: char| c == '(' || c == '<' || c.is_whitespace())?;
1104 let name = rest.get(..name_end)?.trim();
1105 if name.is_empty() {
1106 None
1107 } else {
1108 Some(name.to_string())
1109 }
1110 }
1111
1112 fn extract_js_class_name(&self, line: &str) -> Option<String> {
1113 let rest = line.strip_prefix("class ")?.trim();
1114 let name_end = rest.find(|c: char| c == '{' || c == '<' || c.is_whitespace())?;
1115 Some(rest.get(..name_end)?.to_string())
1116 }
1117
1118 fn extract_js_const_name(&self, line: &str) -> Option<String> {
1119 let rest = line.strip_prefix("const ")?.trim();
1120 let name_end = rest.find(|c: char| c == '=' || c == ':' || c.is_whitespace())?;
1121 Some(rest.get(..name_end)?.to_string())
1122 }
1123
1124 fn extract_go_fn_name(&self, line: &str) -> Option<String> {
1125 let rest = line.strip_prefix("func ")?.trim();
1126 let rest = if rest.starts_with('(') {
1128 let end = rest.find(')')?;
1129 rest.get(end + 1..)?.trim()
1130 } else {
1131 rest
1132 };
1133 let name_end = rest.find(|c: char| c == '(' || c == '<' || c.is_whitespace())?;
1134 let name = rest.get(..name_end)?.trim();
1135 if name.is_empty() {
1136 None
1137 } else {
1138 Some(name.to_string())
1139 }
1140 }
1141
1142 fn extract_go_type_name(&self, line: &str) -> Option<String> {
1143 let rest = line.strip_prefix("type ")?.trim();
1144 let name_end = rest.find(|c: char| c.is_whitespace())?;
1145 Some(rest.get(..name_end)?.to_string())
1146 }
1147
1148 fn extract_go_const_name(&self, line: &str) -> Option<String> {
1149 let rest = line.strip_prefix("const ")?.trim();
1150 let name_end = rest.find(|c: char| c == '=' || c.is_whitespace())?;
1151 Some(rest.get(..name_end)?.to_string())
1152 }
1153
1154 fn generate_summary(
1156 &self,
1157 files: &[PathBuf],
1158 snippets: &[CodeSnippet],
1159 stats: &ExploreStats,
1160 ) -> String {
1161 let mut summary = String::new();
1162
1163 summary.push_str(&format!(
1164 "Exploration completed in {}ms\n",
1165 stats.duration_ms
1166 ));
1167 summary.push_str(&format!(
1168 "Scanned {} files across {} directories\n",
1169 stats.files_scanned, stats.directories_traversed
1170 ));
1171
1172 if !files.is_empty() {
1173 summary.push_str(&format!("Found {} matching files\n", files.len()));
1174 }
1175
1176 if !snippets.is_empty() {
1177 summary.push_str(&format!(
1178 "Found {} code matches for '{}'\n",
1179 snippets.len(),
1180 self.options.query
1181 ));
1182 }
1183
1184 if !stats.files_by_extension.is_empty() {
1186 summary.push_str("\nFile types:\n");
1187 let mut extensions: Vec<_> = stats.files_by_extension.iter().collect();
1188 extensions.sort_by(|a, b| b.1.cmp(a.1));
1189 for (ext, count) in extensions.iter().take(5) {
1190 summary.push_str(&format!(" .{}: {} files\n", ext, count));
1191 }
1192 }
1193
1194 summary
1195 }
1196
1197 fn generate_suggestions(&self, files: &[PathBuf], snippets: &[CodeSnippet]) -> Vec<String> {
1199 let mut suggestions = Vec::new();
1200
1201 if files.is_empty() && snippets.is_empty() {
1202 suggestions.push("No results found. Try broadening your search patterns.".to_string());
1203 suggestions.push("Consider using wildcards like *.rs or **/*.py".to_string());
1204 }
1205
1206 if files.len() >= self.options.effective_max_results() {
1207 suggestions.push(format!(
1208 "Results limited to {}. Use more specific patterns to narrow down.",
1209 self.options.effective_max_results()
1210 ));
1211 }
1212
1213 if !self.options.query.is_empty() && snippets.is_empty() && !files.is_empty() {
1214 suggestions.push(format!(
1215 "No code matches for '{}'. The term might not exist in the matched files.",
1216 self.options.query
1217 ));
1218 }
1219
1220 if self.options.thoroughness == ThoroughnessLevel::Quick && files.len() > 40 {
1221 suggestions.push(
1222 "Consider using 'medium' or 'very_thorough' for more comprehensive results."
1223 .to_string(),
1224 );
1225 }
1226
1227 suggestions
1228 }
1229}
1230
1231#[cfg(test)]
1232mod tests {
1233 use super::*;
1234 use std::fs;
1235 use tempfile::TempDir;
1236
1237 fn create_test_files(dir: &Path) -> std::io::Result<()> {
1238 fs::write(
1240 dir.join("main.rs"),
1241 r#"use std::io;
1242
1243pub fn hello() {
1244 println!("Hello");
1245}
1246
1247pub struct MyStruct {
1248 field: i32,
1249}
1250
1251impl MyStruct {
1252 pub fn new() -> Self {
1253 Self { field: 0 }
1254 }
1255}
1256
1257pub const MAX_SIZE: usize = 100;
1258"#,
1259 )?;
1260
1261 fs::write(
1263 dir.join("script.py"),
1264 r#"import os
1265from pathlib import Path
1266
1267MAX_COUNT = 10
1268
1269class MyClass:
1270 def __init__(self):
1271 pass
1272
1273def main():
1274 print("Hello")
1275"#,
1276 )?;
1277
1278 fs::write(
1280 dir.join("app.ts"),
1281 r#"import { Component } from 'react';
1282
1283export interface User {
1284 name: string;
1285}
1286
1287export class App {
1288 constructor() {}
1289}
1290
1291export function render() {
1292 return null;
1293}
1294
1295export const VERSION = "1.0.0";
1296"#,
1297 )?;
1298
1299 let subdir = dir.join("src");
1301 fs::create_dir_all(&subdir)?;
1302 fs::write(subdir.join("lib.rs"), "pub mod utils;\n")?;
1303
1304 Ok(())
1305 }
1306
1307 #[test]
1308 fn test_thoroughness_level_defaults() {
1309 assert_eq!(ThoroughnessLevel::Quick.max_depth(), 2);
1310 assert_eq!(ThoroughnessLevel::Medium.max_depth(), 5);
1311 assert_eq!(ThoroughnessLevel::VeryThorough.max_depth(), 10);
1312
1313 assert_eq!(ThoroughnessLevel::Quick.max_files(), 50);
1314 assert_eq!(ThoroughnessLevel::Medium.max_files(), 200);
1315 assert_eq!(ThoroughnessLevel::VeryThorough.max_files(), 1000);
1316 }
1317
1318 #[test]
1319 fn test_explore_options_builder() {
1320 let options = ExploreOptions::new("test query")
1321 .with_thoroughness(ThoroughnessLevel::VeryThorough)
1322 .with_max_results(10)
1323 .with_hidden(true);
1324
1325 assert_eq!(options.query, "test query");
1326 assert_eq!(options.thoroughness, ThoroughnessLevel::VeryThorough);
1327 assert_eq!(options.max_results, Some(10));
1328 assert!(options.include_hidden);
1329 }
1330
1331 #[test]
1332 fn test_code_snippet_creation() {
1333 let snippet = CodeSnippet::new("/path/file.rs", 10, "let x = 1;", "let").with_context(
1334 vec!["// comment".to_string()],
1335 vec!["let y = 2;".to_string()],
1336 );
1337
1338 assert_eq!(snippet.line_number, 10);
1339 assert_eq!(snippet.content, "let x = 1;");
1340 assert_eq!(snippet.matched_term, "let");
1341 assert_eq!(snippet.context_before.len(), 1);
1342 assert_eq!(snippet.context_after.len(), 1);
1343 }
1344
1345 #[test]
1346 fn test_explore_stats() {
1347 let mut stats = ExploreStats::new();
1348 stats.record_file(Some("rs"), 1000);
1349 stats.record_file(Some("rs"), 500);
1350 stats.record_file(Some("py"), 200);
1351 stats.record_directory();
1352 stats.record_matches(5);
1353
1354 assert_eq!(stats.files_scanned, 3);
1355 assert_eq!(stats.bytes_read, 1700);
1356 assert_eq!(stats.directories_traversed, 1);
1357 assert_eq!(stats.matches_found, 5);
1358 assert_eq!(stats.files_by_extension.get("rs"), Some(&2));
1359 assert_eq!(stats.files_by_extension.get("py"), Some(&1));
1360 }
1361
1362 #[test]
1363 fn test_structure_analysis() {
1364 let mut analysis = StructureAnalysis::new("/path/file.rs").with_language("rust");
1365
1366 assert!(!analysis.has_structure());
1367 assert_eq!(analysis.total_items(), 0);
1368
1369 analysis.functions.push("test_fn".to_string());
1370 analysis.classes.push("TestClass".to_string());
1371
1372 assert!(analysis.has_structure());
1373 assert_eq!(analysis.total_items(), 2);
1374 }
1375
1376 #[tokio::test]
1377 async fn test_find_files_with_pattern() {
1378 let temp_dir = TempDir::new().unwrap();
1379 create_test_files(temp_dir.path()).unwrap();
1380
1381 let options = ExploreOptions::new("")
1382 .with_target_path(temp_dir.path())
1383 .with_patterns(vec!["*.rs".to_string()]);
1384
1385 let agent = ExploreAgent::new(options);
1386 let result = agent.explore().await.unwrap();
1387
1388 assert!(!result.files.is_empty(), "Should find .rs files");
1389 assert!(result
1390 .files
1391 .iter()
1392 .all(|f| f.extension().map(|e| e == "rs").unwrap_or(false)));
1393 }
1394
1395 #[tokio::test]
1396 async fn test_explore_with_query() {
1397 let temp_dir = TempDir::new().unwrap();
1398 create_test_files(temp_dir.path()).unwrap();
1399
1400 let options = ExploreOptions::new("Hello").with_target_path(temp_dir.path());
1401
1402 let agent = ExploreAgent::new(options);
1403 let result = agent.explore().await.unwrap();
1404
1405 assert!(!result.files.is_empty(), "Should find files");
1406 assert!(
1407 !result.code_snippets.is_empty(),
1408 "Should find code snippets containing 'Hello'"
1409 );
1410 assert!(!result.summary.is_empty());
1411 }
1412
1413 #[tokio::test]
1414 async fn test_search_code() {
1415 let temp_dir = TempDir::new().unwrap();
1416 create_test_files(temp_dir.path()).unwrap();
1417
1418 let options = ExploreOptions::new("").with_target_path(temp_dir.path());
1419
1420 let agent = ExploreAgent::new(options);
1421 let snippets = agent.search_code("pub fn").await.unwrap();
1422
1423 assert!(!snippets.is_empty(), "Should find 'pub fn' in Rust files");
1424 assert!(snippets.iter().all(|s| s.content.contains("pub fn")));
1425 }
1426
1427 #[test]
1428 fn test_analyze_structure_rust() {
1429 let temp_dir = TempDir::new().unwrap();
1430 create_test_files(temp_dir.path()).unwrap();
1431
1432 let options = ExploreOptions::new("").with_target_path(temp_dir.path());
1433
1434 let agent = ExploreAgent::new(options);
1435 let analysis = agent
1436 .analyze_structure(&temp_dir.path().join("main.rs"))
1437 .unwrap();
1438
1439 assert_eq!(analysis.language, Some("rust".to_string()));
1440 assert!(analysis.imports.iter().any(|i| i.contains("std::io")));
1441 assert!(analysis.functions.iter().any(|f| f == "hello"));
1442 assert!(analysis.types.iter().any(|t| t == "MyStruct"));
1443 assert!(analysis.classes.iter().any(|c| c == "MyStruct"));
1444 assert!(analysis.constants.iter().any(|c| c == "MAX_SIZE"));
1445 }
1446
1447 #[test]
1448 fn test_analyze_structure_python() {
1449 let temp_dir = TempDir::new().unwrap();
1450 create_test_files(temp_dir.path()).unwrap();
1451
1452 let options = ExploreOptions::new("").with_target_path(temp_dir.path());
1453
1454 let agent = ExploreAgent::new(options);
1455 let analysis = agent
1456 .analyze_structure(&temp_dir.path().join("script.py"))
1457 .unwrap();
1458
1459 assert_eq!(analysis.language, Some("python".to_string()));
1460 assert!(!analysis.imports.is_empty());
1461 assert!(analysis.classes.iter().any(|c| c == "MyClass"));
1462 assert!(analysis.functions.iter().any(|f| f == "main"));
1463 }
1464
1465 #[test]
1466 fn test_analyze_structure_typescript() {
1467 let temp_dir = TempDir::new().unwrap();
1468 create_test_files(temp_dir.path()).unwrap();
1469
1470 let options = ExploreOptions::new("").with_target_path(temp_dir.path());
1471
1472 let agent = ExploreAgent::new(options);
1473 let analysis = agent
1474 .analyze_structure(&temp_dir.path().join("app.ts"))
1475 .unwrap();
1476
1477 assert_eq!(analysis.language, Some("typescript".to_string()));
1478 assert!(analysis.interfaces.iter().any(|i| i == "User"));
1479 assert!(analysis.classes.iter().any(|c| c == "App"));
1480 assert!(analysis.functions.iter().any(|f| f == "render"));
1481 assert!(analysis.constants.iter().any(|c| c == "VERSION"));
1482 }
1483
1484 #[test]
1485 fn test_hidden_file_filtering() {
1486 let temp_dir = TempDir::new().unwrap();
1487 let hidden_dir = temp_dir.path().join(".hidden");
1488 fs::create_dir_all(&hidden_dir).unwrap();
1489 fs::write(hidden_dir.join("secret.rs"), "// secret").unwrap();
1490 fs::write(temp_dir.path().join("visible.rs"), "// visible").unwrap();
1491
1492 let options = ExploreOptions::new("")
1494 .with_target_path(temp_dir.path())
1495 .with_hidden(false);
1496
1497 let agent = ExploreAgent::new(options);
1498 let mut stats = ExploreStats::new();
1499 let files = agent
1500 .find_files_internal(temp_dir.path(), &mut stats)
1501 .unwrap();
1502
1503 assert!(files
1504 .iter()
1505 .all(|f| !f.to_string_lossy().contains(".hidden")));
1506
1507 let options = ExploreOptions::new("")
1509 .with_target_path(temp_dir.path())
1510 .with_hidden(true);
1511
1512 let agent = ExploreAgent::new(options);
1513 let mut stats = ExploreStats::new();
1514 let files = agent
1515 .find_files_internal(temp_dir.path(), &mut stats)
1516 .unwrap();
1517
1518 assert!(files
1519 .iter()
1520 .any(|f| f.to_string_lossy().contains(".hidden")));
1521 }
1522
1523 #[tokio::test]
1524 async fn test_max_results_limit() {
1525 let temp_dir = TempDir::new().unwrap();
1526
1527 for i in 0..20 {
1529 fs::write(temp_dir.path().join(format!("file{}.rs", i)), "// content").unwrap();
1530 }
1531
1532 let options = ExploreOptions::new("")
1533 .with_target_path(temp_dir.path())
1534 .with_max_results(5);
1535
1536 let agent = ExploreAgent::new(options);
1537 let result = agent.explore().await.unwrap();
1538
1539 assert!(result.files.len() <= 5);
1540 }
1541
1542 #[test]
1543 fn test_explore_nonexistent_path() {
1544 let options =
1545 ExploreOptions::new("").with_target_path("/nonexistent/path/that/does/not/exist");
1546
1547 let agent = ExploreAgent::new(options);
1548 let rt = tokio::runtime::Runtime::new().unwrap();
1549 let result = rt.block_on(agent.explore());
1550
1551 assert!(result.is_err());
1552 assert!(matches!(result.unwrap_err(), ExploreError::InvalidPath(_)));
1553 }
1554
1555 #[test]
1556 fn test_analyze_nonexistent_file() {
1557 let options = ExploreOptions::new("");
1558 let agent = ExploreAgent::new(options);
1559
1560 let result = agent.analyze_structure(Path::new("/nonexistent/file.rs"));
1561 assert!(result.is_err());
1562 assert!(matches!(result.unwrap_err(), ExploreError::FileNotFound(_)));
1563 }
1564}