Skip to main content

aster/agents/specialized/
explore.rs

1//! Explore Agent
2//!
3//! Specialized agent for codebase exploration with
4//! file search, code search, and structure analysis.
5//!
6//! This module implements Requirements 13.1-13.7 from the design document.
7
8use glob::Pattern;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use thiserror::Error;
13
14/// Result type alias for explore operations
15pub type ExploreResult<T> = Result<T, ExploreError>;
16
17/// Error types for explore operations
18#[derive(Debug, Error)]
19pub enum ExploreError {
20    /// Invalid path
21    #[error("Invalid path: {0}")]
22    InvalidPath(String),
23
24    /// File not found
25    #[error("File not found: {0}")]
26    FileNotFound(String),
27
28    /// Pattern error
29    #[error("Invalid pattern: {0}")]
30    PatternError(String),
31
32    /// I/O error
33    #[error("IO error: {0}")]
34    Io(#[from] std::io::Error),
35
36    /// Search error
37    #[error("Search error: {0}")]
38    SearchError(String),
39
40    /// Analysis error
41    #[error("Analysis error: {0}")]
42    AnalysisError(String),
43}
44
45/// Thoroughness level for exploration
46/// Determines how deep and comprehensive the exploration will be
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
48#[serde(rename_all = "camelCase")]
49pub enum ThoroughnessLevel {
50    /// Quick exploration - minimal depth, fast results
51    Quick,
52
53    /// Medium exploration - balanced depth and speed
54    #[default]
55    Medium,
56
57    /// Very thorough exploration - maximum depth, comprehensive results
58    VeryThorough,
59}
60
61impl ThoroughnessLevel {
62    /// Get the maximum depth for directory traversal
63    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    /// Get the maximum number of files to process
72    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    /// Get the number of context lines for code search
81    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    /// Get the maximum content size to read per file (in bytes)
90    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/// Options for explore operations
100#[derive(Debug, Clone, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct ExploreOptions {
103    /// Thoroughness level for exploration
104    pub thoroughness: ThoroughnessLevel,
105
106    /// Search query or description
107    pub query: String,
108
109    /// Target path to explore (defaults to current directory)
110    pub target_path: Option<PathBuf>,
111
112    /// File patterns to match (glob patterns)
113    pub patterns: Option<Vec<String>>,
114
115    /// Maximum number of results to return
116    pub max_results: Option<usize>,
117
118    /// Whether to include hidden files
119    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    /// Create new explore options with a query
137    pub fn new(query: impl Into<String>) -> Self {
138        Self {
139            query: query.into(),
140            ..Default::default()
141        }
142    }
143
144    /// Set the thoroughness level
145    pub fn with_thoroughness(mut self, level: ThoroughnessLevel) -> Self {
146        self.thoroughness = level;
147        self
148    }
149
150    /// Set the target path
151    pub fn with_target_path(mut self, path: impl Into<PathBuf>) -> Self {
152        self.target_path = Some(path.into());
153        self
154    }
155
156    /// Set file patterns
157    pub fn with_patterns(mut self, patterns: Vec<String>) -> Self {
158        self.patterns = Some(patterns);
159        self
160    }
161
162    /// Set maximum results
163    pub fn with_max_results(mut self, max: usize) -> Self {
164        self.max_results = Some(max);
165        self
166    }
167
168    /// Include hidden files
169    pub fn with_hidden(mut self, include: bool) -> Self {
170        self.include_hidden = include;
171        self
172    }
173
174    /// Get effective max results based on thoroughness
175    pub fn effective_max_results(&self) -> usize {
176        self.max_results
177            .unwrap_or_else(|| self.thoroughness.max_files())
178    }
179}
180
181/// A code snippet found during search
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
183#[serde(rename_all = "camelCase")]
184pub struct CodeSnippet {
185    /// File path containing the snippet
186    pub file_path: PathBuf,
187
188    /// Line number where the match starts
189    pub line_number: usize,
190
191    /// The matched line content
192    pub content: String,
193
194    /// Context lines before the match
195    pub context_before: Vec<String>,
196
197    /// Context lines after the match
198    pub context_after: Vec<String>,
199
200    /// The search term that matched
201    pub matched_term: String,
202}
203
204impl CodeSnippet {
205    /// Create a new code snippet
206    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    /// Add context lines
223    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/// Statistics from exploration
231#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
232#[serde(rename_all = "camelCase")]
233pub struct ExploreStats {
234    /// Total files scanned
235    pub files_scanned: usize,
236
237    /// Total directories traversed
238    pub directories_traversed: usize,
239
240    /// Total matches found
241    pub matches_found: usize,
242
243    /// Total bytes read
244    pub bytes_read: usize,
245
246    /// Duration in milliseconds
247    pub duration_ms: u64,
248
249    /// Files by extension
250    pub files_by_extension: HashMap<String, usize>,
251}
252
253impl ExploreStats {
254    /// Create new stats
255    pub fn new() -> Self {
256        Self::default()
257    }
258
259    /// Record a file scan
260    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    /// Record a directory
269    pub fn record_directory(&mut self) {
270        self.directories_traversed += 1;
271    }
272
273    /// Record matches
274    pub fn record_matches(&mut self, count: usize) {
275        self.matches_found += count;
276    }
277}
278
279/// Result of an exploration operation
280#[derive(Debug, Clone, Serialize, Deserialize)]
281#[serde(rename_all = "camelCase")]
282pub struct ExploreResultData {
283    /// Files found during exploration
284    pub files: Vec<PathBuf>,
285
286    /// Code snippets found during search
287    pub code_snippets: Vec<CodeSnippet>,
288
289    /// Summary of the exploration
290    pub summary: String,
291
292    /// Suggestions for further exploration
293    pub suggestions: Vec<String>,
294
295    /// Statistics from the exploration
296    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    /// Create a new explore result
313    pub fn new() -> Self {
314        Self::default()
315    }
316
317    /// Add files to the result
318    pub fn with_files(mut self, files: Vec<PathBuf>) -> Self {
319        self.files = files;
320        self
321    }
322
323    /// Add code snippets
324    pub fn with_snippets(mut self, snippets: Vec<CodeSnippet>) -> Self {
325        self.code_snippets = snippets;
326        self
327    }
328
329    /// Set the summary
330    pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
331        self.summary = summary.into();
332        self
333    }
334
335    /// Add suggestions
336    pub fn with_suggestions(mut self, suggestions: Vec<String>) -> Self {
337        self.suggestions = suggestions;
338        self
339    }
340
341    /// Set statistics
342    pub fn with_stats(mut self, stats: ExploreStats) -> Self {
343        self.stats = stats;
344        self
345    }
346}
347
348/// Structure analysis result for a file
349#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
350#[serde(rename_all = "camelCase")]
351pub struct StructureAnalysis {
352    /// File path
353    pub file_path: PathBuf,
354
355    /// Detected language
356    pub language: Option<String>,
357
358    /// Exported items (functions, classes, etc.)
359    pub exports: Vec<String>,
360
361    /// Imported modules/packages
362    pub imports: Vec<String>,
363
364    /// Class definitions
365    pub classes: Vec<String>,
366
367    /// Function definitions
368    pub functions: Vec<String>,
369
370    /// Interface/trait definitions
371    pub interfaces: Vec<String>,
372
373    /// Type definitions
374    pub types: Vec<String>,
375
376    /// Constants
377    pub constants: Vec<String>,
378}
379
380impl StructureAnalysis {
381    /// Create a new structure analysis
382    pub fn new(file_path: impl Into<PathBuf>) -> Self {
383        Self {
384            file_path: file_path.into(),
385            ..Default::default()
386        }
387    }
388
389    /// Set the language
390    pub fn with_language(mut self, language: impl Into<String>) -> Self {
391        self.language = Some(language.into());
392        self
393    }
394
395    /// Check if the analysis found any structure
396    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    /// Get total number of items found
407    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
418/// Explore Agent for codebase exploration
419///
420/// Provides functionality for:
421/// - File pattern search
422/// - Code content search
423/// - Structure analysis
424/// - Summary generation
425pub struct ExploreAgent {
426    options: ExploreOptions,
427}
428
429impl ExploreAgent {
430    /// Create a new explore agent with options
431    pub fn new(options: ExploreOptions) -> Self {
432        Self { options }
433    }
434
435    /// Get the options
436    pub fn options(&self) -> &ExploreOptions {
437        &self.options
438    }
439
440    /// Get the effective target path
441    fn target_path(&self) -> PathBuf {
442        self.options
443            .target_path
444            .clone()
445            .unwrap_or_else(|| PathBuf::from("."))
446    }
447
448    /// Check if a path should be included based on hidden file settings
449    fn should_include_path(&self, path: &Path) -> bool {
450        if self.options.include_hidden {
451            return true;
452        }
453
454        // Only check the file/directory name itself, not the full path
455        // This allows temp directories like /var/folders/.../T/... to work
456        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    /// Check if a file matches the configured patterns
467    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, // No patterns means match all
471        };
472
473        let path_str = path.to_string_lossy();
474        for pattern in patterns {
475            // Try matching the full path
476            if let Ok(glob) = Pattern::new(pattern) {
477                if glob.matches(&path_str) {
478                    return true;
479                }
480            }
481            // Also try matching just the filename
482            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                // Handle simple extension patterns like "*.rs"
490                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    /// Perform exploration based on configured options
504    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        // Find files matching patterns
519        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 there's a query, search for it in the file
526            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        // Generate summary and suggestions
537        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    /// Find files matching the configured patterns
549    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        // Create a temporary options with the pattern
561        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    /// Internal file finding with stats tracking
574    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        // Start with is_root=true to not filter the target path itself
584        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            // Don't filter the root target path, only subdirectories
615            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, // Children are not root
634                )?;
635            }
636        }
637
638        Ok(())
639    }
640
641    /// Search for code content in files
642    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    /// Search for a keyword in a single file
676    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        // Skip files that are too large
688        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                // Get context lines
701                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    /// Analyze the structure of a file
722    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        // Parse based on language
743        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    /// Detect the programming language from file extension
755    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    /// Analyze Rust source code
777    fn analyze_rust(&self, content: &str, analysis: &mut StructureAnalysis) {
778        for line in content.lines() {
779            let trimmed = line.trim();
780
781            // Imports (use statements)
782            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            // Public exports
792            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            // Non-public items
824            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    /// Analyze Python source code
851    fn analyze_python(&self, content: &str, analysis: &mut StructureAnalysis) {
852        for line in content.lines() {
853            let trimmed = line.trim();
854
855            // Imports
856            if trimmed.starts_with("import ") || trimmed.starts_with("from ") {
857                analysis.imports.push(trimmed.to_string());
858            }
859
860            // Classes
861            if trimmed.starts_with("class ") {
862                if let Some(name) = self.extract_python_class_name(trimmed) {
863                    analysis.classes.push(name.clone());
864                    // Python classes are typically exported
865                    if !name.starts_with('_') {
866                        analysis.exports.push(name);
867                    }
868                }
869            }
870
871            // Functions (top-level, not indented)
872            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            // Constants (uppercase at module level)
882            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    /// Analyze JavaScript/TypeScript source code
894    fn analyze_js_ts(&self, content: &str, analysis: &mut StructureAnalysis) {
895        for line in content.lines() {
896            let trimmed = line.trim();
897
898            // Imports
899            if trimmed.starts_with("import ") {
900                analysis.imports.push(trimmed.to_string());
901            }
902
903            // Exports
904            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            // Non-exported items
938            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    /// Analyze Go source code
965    fn analyze_go(&self, content: &str, analysis: &mut StructureAnalysis) {
966        for line in content.lines() {
967            let trimmed = line.trim();
968
969            // Imports
970            if trimmed.starts_with("import ") || trimmed.starts_with("import (") {
971                analysis.imports.push(trimmed.to_string());
972            }
973
974            // Functions
975            if trimmed.starts_with("func ") {
976                if let Some(name) = self.extract_go_fn_name(trimmed) {
977                    analysis.functions.push(name.clone());
978                    // Exported if starts with uppercase
979                    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            // Types
991            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                    // Exported if starts with uppercase
1001                    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            // Constants
1013            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    /// Generic analysis for unknown languages
1030    fn analyze_generic(&self, content: &str, analysis: &mut StructureAnalysis) {
1031        for line in content.lines() {
1032            let trimmed = line.trim();
1033
1034            // Look for common patterns
1035            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    // Helper methods for name extraction
1051
1052    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        // Skip generic parameters
1074        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        // Handle method receivers: func (r *Receiver) Name()
1127        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    /// Generate a summary of the exploration results
1155    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        // File type breakdown
1185        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    /// Generate suggestions based on exploration results
1198    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        // Create Rust file
1239        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        // Create Python file
1262        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        // Create TypeScript file
1279        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        // Create subdirectory with files
1300        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        // Without hidden files
1493        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        // With hidden files
1508        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        // Create many files
1528        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}