Skip to main content

normalize_path_resolve/
lib.rs

1//! Path resolution utilities: fuzzy matching, sigil expansion, and unified path parsing.
2
3use ignore::WalkBuilder;
4use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
5use nucleo_matcher::{Config, Matcher};
6use std::path::Path;
7
8/// Whether a resolved path points to a file or a directory.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum PathMatchKind {
11    File,
12    Directory,
13}
14
15/// A resolved path match with fuzzy-match score and kind.
16#[derive(Debug, Clone)]
17pub struct PathMatch {
18    pub path: String,
19    pub kind: PathMatchKind,
20    pub score: u32,
21}
22
23/// A single entry returned by [`PathSource::all_files`] and [`PathSource::find_like`].
24pub struct PathEntry {
25    pub path: String,
26    pub kind: PathMatchKind,
27}
28
29/// Result of expanding a sigil like `@todo` or `@config`.
30#[derive(Debug, Clone)]
31pub struct SigilExpansion {
32    /// Expanded file paths (may be multiple, e.g., ["TODO.md", "TASKS.md"])
33    pub paths: Vec<String>,
34    /// Remaining path after the sigil (e.g., "Section/Item" from "@todo/Section/Item")
35    pub suffix: String,
36}
37
38/// Source of indexed file paths (e.g., from a database index).
39pub trait PathSource {
40    /// Return paths matching `query` using a fast prefix/substring filter.
41    /// Returns `None` if the source cannot answer this query (caller falls back to `all_files`).
42    fn find_like(&self, query: &str) -> Option<Vec<PathEntry>>;
43
44    /// Return all known file/directory entries.
45    /// Returns `None` if the source is unavailable; caller falls back to a filesystem walk.
46    fn all_files(&self) -> Option<Vec<PathEntry>>;
47}
48
49/// Expand an alias query like `@todo` or `@config/section`.
50/// Returns None if the query doesn't start with @ or the alias is unknown.
51///
52/// `alias_lookup` resolves an alias name to its target paths.
53pub fn expand_sigil(
54    query: &str,
55    alias_lookup: &dyn Fn(&str) -> Option<Vec<String>>,
56) -> Option<SigilExpansion> {
57    if !query.starts_with('@') {
58        return None;
59    }
60
61    let rest = &query[1..]; // Strip @
62
63    // Alias name: alphanumeric, underscore, hyphen
64    let alias_end = rest
65        .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
66        .unwrap_or(rest.len());
67
68    let alias_name = &rest[..alias_end];
69    let after_alias = &rest[alias_end..];
70
71    // Strip the separator to get suffix (supports /, :, ::, #)
72    let suffix = after_alias
73        .strip_prefix("::")
74        .or_else(|| after_alias.strip_prefix('/'))
75        .or_else(|| after_alias.strip_prefix(':'))
76        .or_else(|| after_alias.strip_prefix('#'))
77        .unwrap_or(after_alias);
78
79    let targets = alias_lookup(alias_name)?;
80
81    Some(SigilExpansion {
82        paths: targets,
83        suffix: suffix.to_string(),
84    })
85}
86
87/// Result of resolving a unified path like `src/main.py/Foo/bar`
88#[derive(Debug, Clone)]
89pub struct UnifiedPath {
90    /// The file path portion (e.g., "src/main.py")
91    pub file_path: String,
92    /// The symbol path within the file (e.g., "Foo/bar"), empty if pointing to file itself
93    pub symbol_path: Vec<String>,
94    /// Whether the path resolved to a directory (no symbol path possible)
95    pub is_directory: bool,
96}
97
98/// Normalize a unified path query, converting various separator styles to `/`.
99/// Supports: `::` (Rust-style), `#` (URL fragment), `:` (compact)
100fn normalize_separators(query: &str) -> String {
101    query
102        .replace("::", "/")
103        .replace('#', "/")
104        // Only replace single : if it looks like file:symbol (has file extension before it)
105        .split(':')
106        .enumerate()
107        .map(|(i, part)| {
108            if i == 0 {
109                part.to_string()
110            } else {
111                format!("/{}", part)
112            }
113        })
114        .collect::<String>()
115}
116
117/// Resolve a unified path like `src/main.py/Foo/bar` to file + symbol components.
118///
119/// Uses filesystem as source of truth: walks segments left-to-right, checking
120/// at each step whether the path exists as file or directory. Once we hit a file,
121/// remaining segments are the symbol path.
122///
123/// Strategy:
124/// 1. Walk path segments, checking each accumulated path against filesystem
125/// 2. When we hit a file, everything after is symbol path
126/// 3. If exact path doesn't exist, try fuzzy matching for the file portion
127pub fn resolve_unified(
128    query: &str,
129    root: &Path,
130    alias_lookup: &dyn Fn(&str) -> Option<Vec<String>>,
131    path_source: Option<&dyn PathSource>,
132) -> Option<UnifiedPath> {
133    resolve_unified_depth(query, root, alias_lookup, path_source, 0)
134}
135
136fn resolve_unified_depth(
137    query: &str,
138    root: &Path,
139    alias_lookup: &dyn Fn(&str) -> Option<Vec<String>>,
140    path_source: Option<&dyn PathSource>,
141    depth: u8,
142) -> Option<UnifiedPath> {
143    // Handle sigil expansion (@todo, @config, etc.)
144    if query.starts_with('@')
145        && let Some(expansion) = expand_sigil(query, alias_lookup)
146    {
147        // Guard against alias cycles (a→@b→@a→...) by limiting recursion depth.
148        if depth >= 32 {
149            return None;
150        }
151        // Try each target path until one exists
152        for target in &expansion.paths {
153            let full_query = if expansion.suffix.is_empty() {
154                target.clone()
155            } else {
156                format!("{}/{}", target, expansion.suffix)
157            };
158            if let Some(result) =
159                resolve_unified_depth(&full_query, root, alias_lookup, None, depth + 1)
160            {
161                return Some(result);
162            }
163        }
164        return None;
165    }
166    // Unknown sigil - fall through to normal resolution (will likely fail)
167
168    let normalized = normalize_separators(query);
169
170    // Handle absolute paths (start with /) - use filesystem root instead of project root
171    let (segments, base_path): (Vec<&str>, std::path::PathBuf) = if normalized.starts_with('/') {
172        let segs: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
173        (segs, std::path::PathBuf::from("/"))
174    } else {
175        let segs: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
176        (segs, root.to_path_buf())
177    };
178    let is_absolute = normalized.starts_with('/');
179
180    if segments.is_empty() {
181        return None;
182    }
183
184    // Strategy 1: Walk exact path segments
185    let mut current_path = base_path.clone();
186    for (idx, segment) in segments.iter().enumerate() {
187        let test_path = current_path.join(segment);
188
189        if test_path.is_file() {
190            // Found a file - this is the boundary
191            // For absolute paths, keep full path; for relative, strip root prefix
192            let file_path = if is_absolute {
193                test_path.to_string_lossy().to_string()
194            } else {
195                test_path
196                    .strip_prefix(root)
197                    .unwrap_or(&test_path)
198                    .to_string_lossy()
199                    .to_string()
200            };
201            return Some(UnifiedPath {
202                file_path,
203                symbol_path: segments[idx + 1..].iter().map(|s| s.to_string()).collect(),
204                is_directory: false,
205            });
206        } else if test_path.is_dir() {
207            current_path = test_path;
208        } else {
209            // Path doesn't exist - try fuzzy resolution (only for relative paths)
210            break;
211        }
212    }
213
214    // Check if we ended at a directory
215    if current_path != base_path && current_path.is_dir() {
216        let dir_path = if is_absolute {
217            current_path.to_string_lossy().to_string()
218        } else {
219            current_path
220                .strip_prefix(root)
221                .unwrap_or(&current_path)
222                .to_string_lossy()
223                .to_string()
224        };
225        let matched_segments = dir_path.matches('/').count() + 1;
226        if matched_segments >= segments.len() {
227            return Some(UnifiedPath {
228                file_path: dir_path,
229                symbol_path: vec![],
230                is_directory: true,
231            });
232        }
233    }
234
235    // Strategy 2: Try fuzzy matching (only for relative paths within project)
236    if !is_absolute {
237        // Pre-load all paths once to avoid repeated mutable borrows in the loop
238        let all_paths = get_paths_for_query(root, "", path_source);
239        for split_point in (1..=segments.len()).rev() {
240            let file_query = segments[..split_point].join("/");
241            let matches = resolve_from_paths(&file_query, &all_paths);
242
243            if let Some(m) = matches.first() {
244                if m.kind == PathMatchKind::File {
245                    return Some(UnifiedPath {
246                        file_path: m.path.clone(),
247                        symbol_path: segments[split_point..]
248                            .iter()
249                            .map(|s| s.to_string())
250                            .collect(),
251                        is_directory: false,
252                    });
253                } else if m.kind == PathMatchKind::Directory && split_point == segments.len() {
254                    // Only return directory if it's the full query
255                    return Some(UnifiedPath {
256                        file_path: m.path.clone(),
257                        symbol_path: vec![],
258                        is_directory: true,
259                    });
260                }
261            }
262        }
263    }
264
265    None
266}
267
268/// Resolve a query to ALL matching unified paths (for ambiguous queries).
269/// Returns empty vec if no matches, single-element vec if unambiguous,
270/// or multiple elements if query matches multiple files.
271pub fn resolve_unified_all(
272    query: &str,
273    root: &Path,
274    alias_lookup: &dyn Fn(&str) -> Option<Vec<String>>,
275    path_source: Option<&dyn PathSource>,
276) -> Vec<UnifiedPath> {
277    resolve_unified_all_depth(query, root, alias_lookup, path_source, 0)
278}
279
280fn resolve_unified_all_depth(
281    query: &str,
282    root: &Path,
283    alias_lookup: &dyn Fn(&str) -> Option<Vec<String>>,
284    path_source: Option<&dyn PathSource>,
285    depth: u8,
286) -> Vec<UnifiedPath> {
287    // Handle sigil expansion (@todo, @config, etc.)
288    if query.starts_with('@')
289        && let Some(expansion) = expand_sigil(query, alias_lookup)
290    {
291        // Guard against alias cycles by limiting recursion depth.
292        if depth >= 32 {
293            return vec![];
294        }
295        let mut results = Vec::new();
296        for target in &expansion.paths {
297            let full_query = if expansion.suffix.is_empty() {
298                target.clone()
299            } else {
300                format!("{}/{}", target, expansion.suffix)
301            };
302            results.extend(resolve_unified_all_depth(
303                &full_query,
304                root,
305                alias_lookup,
306                None,
307                depth + 1,
308            ));
309        }
310        return results;
311    }
312
313    let normalized = normalize_separators(query);
314
315    // Trailing slash means "directory only" for fuzzy matching
316    let dir_only = normalized.ends_with('/');
317
318    // Absolute paths: single result or none
319    if normalized.starts_with('/') {
320        return resolve_unified_depth(query, root, alias_lookup, None, depth)
321            .into_iter()
322            .collect();
323    }
324
325    let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
326    if segments.is_empty() {
327        return vec![];
328    }
329
330    // Try exact path first
331    let mut current_path = root.to_path_buf();
332    for (idx, segment) in segments.iter().enumerate() {
333        let test_path = current_path.join(segment);
334        if test_path.is_file() {
335            // Exact match - return single result
336            let file_path = test_path
337                .strip_prefix(root)
338                .unwrap_or(&test_path)
339                .to_string_lossy()
340                .to_string();
341            return vec![UnifiedPath {
342                file_path,
343                symbol_path: segments[idx + 1..].iter().map(|s| s.to_string()).collect(),
344                is_directory: false,
345            }];
346        } else if test_path.is_dir() {
347            current_path = test_path;
348        } else {
349            break;
350        }
351    }
352
353    // Check if we ended at a directory (exact match)
354    if current_path != root.to_path_buf() && current_path.is_dir() {
355        let dir_path = current_path
356            .strip_prefix(root)
357            .unwrap_or(&current_path)
358            .to_string_lossy()
359            .to_string();
360        return vec![UnifiedPath {
361            file_path: dir_path,
362            symbol_path: vec![],
363            is_directory: true,
364        }];
365    }
366
367    // Fuzzy matching - return ALL matches
368    // Pre-load all paths once to avoid repeated mutable borrows in the loop
369    let all_paths = get_paths_for_query(root, "", path_source);
370    for split_point in (1..=segments.len()).rev() {
371        let file_query = segments[..split_point].join("/");
372        let matches = resolve_from_paths(&file_query, &all_paths);
373
374        if !matches.is_empty() {
375            // Filter to directories only if query ended with /
376            let filtered: Vec<_> = if dir_only {
377                matches
378                    .into_iter()
379                    .filter(|m| m.kind == PathMatchKind::Directory)
380                    .collect()
381            } else {
382                matches
383            };
384
385            if !filtered.is_empty() {
386                return filtered
387                    .into_iter()
388                    .map(|m| UnifiedPath {
389                        file_path: m.path,
390                        symbol_path: segments[split_point..]
391                            .iter()
392                            .map(|s| s.to_string())
393                            .collect(),
394                        is_directory: m.kind == PathMatchKind::Directory,
395                    })
396                    .collect();
397            }
398        }
399    }
400
401    vec![]
402}
403
404/// Get all files in the repository (uses path source if available, else walks filesystem)
405pub fn all_files(root: &Path, path_source: Option<&dyn PathSource>) -> Vec<PathMatch> {
406    get_paths_for_query(root, "", path_source)
407        .into_iter()
408        .map(|(path, is_dir)| PathMatch {
409            path,
410            kind: if is_dir {
411                PathMatchKind::Directory
412            } else {
413                PathMatchKind::File
414            },
415            score: 0,
416        })
417        .collect()
418}
419
420/// Resolve a fuzzy query to matching paths.
421///
422/// Handles:
423/// - Absolute paths: /tmp/foo.py (if file exists)
424/// - Extension patterns: .rs, .py (returns all matching files)
425/// - Exact paths: src/myapp/dwim.py
426/// - Partial filenames: dwim.py, dwim
427/// - Directory names: myapp, src
428///
429/// **Note:** colon-paths like `src/main.py:MyClass` are silently truncated — only the
430/// file component before `:` is resolved. Symbol resolution is left to the caller
431/// (use [`resolve_unified`] if you need both file and symbol).
432pub fn resolve(query: &str, root: &Path, path_source: Option<&dyn PathSource>) -> Vec<PathMatch> {
433    // Handle absolute paths first - check if file exists directly
434    if query.starts_with('/') {
435        let abs_path = std::path::Path::new(query);
436        if abs_path.is_file() {
437            return vec![PathMatch {
438                path: query.to_string(),
439                kind: PathMatchKind::File,
440                score: u32::MAX,
441            }];
442        } else if abs_path.is_dir() {
443            return vec![PathMatch {
444                path: query.to_string(),
445                kind: PathMatchKind::Directory,
446                score: u32::MAX,
447            }];
448        }
449        // Absolute path doesn't exist - return empty
450        return vec![];
451    }
452
453    // Handle file:symbol syntax (defer symbol resolution to Python for now)
454    if query.contains(':') {
455        // normalize-syntax-allow: rust/unwrap-in-impl - split(':') always yields at least one element
456        let file_part = query.split(':').next().unwrap();
457        return resolve(file_part, root, path_source);
458    }
459
460    // Handle extension patterns (e.g., ".rs", ".py") - return all matches directly
461    if query.starts_with('.') && !query.contains('/') {
462        if let Some(src) = path_source.as_ref()
463            && let Some(files) = src.find_like(query)
464        {
465            return files
466                .into_iter()
467                .map(|e| PathMatch {
468                    path: e.path,
469                    kind: e.kind,
470                    score: u32::MAX,
471                })
472                .collect();
473        }
474        // Fallback: walk filesystem for extension matches
475        let walker = WalkBuilder::new(root)
476            .hidden(false)
477            .git_ignore(true)
478            .git_global(true)
479            .git_exclude(true)
480            .filter_entry(|e| e.file_name() != ".git")
481            .build();
482        return walker
483            .flatten()
484            .filter_map(|entry| {
485                let path = entry.path();
486                if path.is_file() {
487                    let path_str = path.to_string_lossy();
488                    if path_str.ends_with(query)
489                        && let Ok(rel) = path.strip_prefix(root)
490                    {
491                        return Some(PathMatch {
492                            path: rel.to_string_lossy().to_string(),
493                            kind: PathMatchKind::File,
494                            score: u32::MAX,
495                        });
496                    }
497                }
498                None
499            })
500            .collect();
501    }
502
503    // Get candidate paths (uses LIKE for fast filtering when possible)
504    let all_paths = get_paths_for_query(root, query, path_source);
505
506    resolve_from_paths(query, &all_paths)
507}
508
509/// Get paths matching query using PathSource, fallback to all files
510fn get_paths_for_query(
511    root: &Path,
512    query: &str,
513    path_source: Option<&dyn PathSource>,
514) -> Vec<(String, bool)> {
515    if let Some(src) = path_source {
516        // Try LIKE first for faster queries
517        if !query.is_empty()
518            && let Some(files) = src.find_like(query)
519            && !files.is_empty()
520        {
521            return files
522                .into_iter()
523                .map(|e| (e.path, e.kind == PathMatchKind::Directory))
524                .collect();
525        }
526        // Fall back to all files for empty query or no LIKE matches
527        if let Some(files) = src.all_files() {
528            return files
529                .into_iter()
530                .map(|e| (e.path, e.kind == PathMatchKind::Directory))
531                .collect();
532        }
533    }
534    // Fall back to filesystem walk
535    let mut all_paths: Vec<(String, bool)> = Vec::new();
536    let walker = WalkBuilder::new(root)
537        .hidden(false)
538        .git_ignore(true)
539        .git_global(true)
540        .git_exclude(true)
541        .build();
542
543    for entry in walker.flatten() {
544        let path = entry.path();
545        if let Ok(rel) = path.strip_prefix(root) {
546            let rel_str = rel.to_string_lossy().to_string();
547            // Skip empty paths and .git directory
548            if rel_str.is_empty() || rel_str == ".git" || rel_str.starts_with(".git/") {
549                continue;
550            }
551            let is_dir = path.is_dir();
552            all_paths.push((rel_str, is_dir));
553        }
554    }
555
556    all_paths
557}
558
559/// Normalize a char for comparison
560#[inline]
561fn normalize_char(c: char) -> char {
562    match c {
563        '-' | '.' | '_' => ' ',
564        c => c.to_ascii_lowercase(),
565    }
566}
567
568/// Compare two strings with normalization (no allocation)
569fn eq_normalized(a: &str, b: &str) -> bool {
570    let mut a_chars = a.chars().map(normalize_char);
571    let mut b_chars = b.chars().map(normalize_char);
572    loop {
573        match (a_chars.next(), b_chars.next()) {
574            (Some(ac), Some(bc)) if ac == bc => continue,
575            (None, None) => return true,
576            _ => return false,
577        }
578    }
579}
580
581/// Normalize string for comparison (used for filename matching)
582fn normalize_for_match(s: &str) -> String {
583    s.chars().map(normalize_char).collect()
584}
585
586/// Resolve from a pre-loaded list of paths
587fn resolve_from_paths(query: &str, all_paths: &[(String, bool)]) -> Vec<PathMatch> {
588    // Handle glob patterns (* and **)
589    if query.contains('*') {
590        let pattern = glob::Pattern::new(query).ok();
591        if let Some(ref pat) = pattern {
592            let mut glob_matches: Vec<PathMatch> = Vec::new();
593            for (path, is_dir) in all_paths {
594                if pat.matches(path) || pat.matches(&path.replace('\\', "/")) {
595                    glob_matches.push(PathMatch {
596                        path: path.clone(),
597                        kind: if *is_dir {
598                            PathMatchKind::Directory
599                        } else {
600                            PathMatchKind::File
601                        },
602                        score: u32::MAX,
603                    });
604                }
605            }
606            if !glob_matches.is_empty() {
607                return glob_matches;
608            }
609        }
610    }
611
612    let query_lower = query.to_lowercase();
613    let query_normalized = normalize_for_match(query);
614
615    // Try normalized path match (handles exact match too, no allocation)
616    for (path, is_dir) in all_paths {
617        if eq_normalized(path, query) {
618            return vec![PathMatch {
619                path: path.clone(),
620                kind: if *is_dir {
621                    PathMatchKind::Directory
622                } else {
623                    PathMatchKind::File
624                },
625                score: u32::MAX,
626            }];
627        }
628    }
629
630    // Try exact filename/dirname match (case-insensitive, _ and - equivalent)
631    let mut exact_matches: Vec<PathMatch> = Vec::new();
632    for (path, is_dir) in all_paths {
633        let name = Path::new(path)
634            .file_name()
635            .map(|n| n.to_string_lossy().to_lowercase())
636            .unwrap_or_default();
637        let stem = Path::new(path)
638            .file_stem()
639            .map(|n| n.to_string_lossy().to_lowercase())
640            .unwrap_or_default();
641        let name_normalized = normalize_for_match(&name);
642        let stem_normalized = normalize_for_match(&stem);
643
644        if name == query_lower
645            || stem == query_lower
646            || name_normalized == query_normalized
647            || stem_normalized == query_normalized
648        {
649            exact_matches.push(PathMatch {
650                path: path.clone(),
651                kind: if *is_dir {
652                    PathMatchKind::Directory
653                } else {
654                    PathMatchKind::File
655                },
656                score: u32::MAX - 1,
657            });
658        }
659    }
660
661    if !exact_matches.is_empty() {
662        return exact_matches;
663    }
664
665    // Try suffix match (query is end of path)
666    // e.g., "analyze/report.rs" matches "crates/myapp/src/commands/analyze/report.rs"
667    if query.contains('/') || query.contains('\\') {
668        let query_suffix = query.replace('\\', "/");
669        let mut suffix_matches: Vec<PathMatch> = Vec::new();
670        for (path, is_dir) in all_paths {
671            let path_normalized = path.replace('\\', "/");
672            if path_normalized.ends_with(&query_suffix)
673                || path_normalized.ends_with(&format!("/{}", query_suffix))
674            {
675                suffix_matches.push(PathMatch {
676                    path: path.clone(),
677                    kind: if *is_dir {
678                        PathMatchKind::Directory
679                    } else {
680                        PathMatchKind::File
681                    },
682                    score: u32::MAX - 2,
683                });
684            }
685        }
686        if !suffix_matches.is_empty() {
687            return suffix_matches;
688        }
689    }
690
691    // Fuzzy match using nucleo
692    let mut matcher = Matcher::new(Config::DEFAULT);
693    let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
694
695    let mut fuzzy_matches: Vec<PathMatch> = Vec::new();
696
697    for (path, is_dir) in all_paths {
698        let mut buf = Vec::new();
699        if let Some(score) =
700            pattern.score(nucleo_matcher::Utf32Str::new(path, &mut buf), &mut matcher)
701        {
702            fuzzy_matches.push(PathMatch {
703                path: path.clone(),
704                kind: if *is_dir {
705                    PathMatchKind::Directory
706                } else {
707                    PathMatchKind::File
708                },
709                score,
710            });
711        }
712    }
713
714    // Sort by score descending, take top 10
715    fuzzy_matches.sort_by(|a, b| b.score.cmp(&a.score));
716    fuzzy_matches.truncate(10);
717
718    fuzzy_matches
719}
720
721/// Check if a pattern contains glob characters (* ? [)
722pub fn is_glob_pattern(pattern: &str) -> bool {
723    pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729    use std::fs;
730    use tempfile::tempdir;
731
732    fn no_aliases(_name: &str) -> Option<Vec<String>> {
733        None
734    }
735
736    #[test]
737    fn test_exact_match() {
738        let dir = tempdir().unwrap();
739        fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
740        fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
741
742        let matches = resolve("src/myapp/cli.py", dir.path(), None);
743        assert_eq!(matches.len(), 1);
744        assert_eq!(matches[0].path, "src/myapp/cli.py");
745    }
746
747    #[test]
748    fn test_filename_match() {
749        let dir = tempdir().unwrap();
750        fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
751        fs::write(dir.path().join("src/myapp/dwim.py"), "").unwrap();
752
753        let matches = resolve("dwim.py", dir.path(), None);
754        assert_eq!(matches.len(), 1);
755        assert_eq!(matches[0].path, "src/myapp/dwim.py");
756    }
757
758    #[test]
759    fn test_stem_match() {
760        let dir = tempdir().unwrap();
761        fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
762        fs::write(dir.path().join("src/myapp/dwim.py"), "").unwrap();
763
764        let matches = resolve("dwim", dir.path(), None);
765        assert_eq!(matches.len(), 1);
766        assert_eq!(matches[0].path, "src/myapp/dwim.py");
767    }
768
769    #[test]
770    fn test_underscore_hyphen_equivalence() {
771        let dir = tempdir().unwrap();
772        fs::create_dir_all(dir.path().join("docs")).unwrap();
773        fs::write(dir.path().join("docs/prior-art.md"), "").unwrap();
774
775        // underscore query should match hyphen filename
776        let matches = resolve("prior_art", dir.path(), None);
777        assert_eq!(matches.len(), 1);
778        assert_eq!(matches[0].path, "docs/prior-art.md");
779
780        // hyphen query should also work
781        let matches = resolve("prior-art", dir.path(), None);
782        assert_eq!(matches.len(), 1);
783        assert_eq!(matches[0].path, "docs/prior-art.md");
784
785        // full path with underscores should match hyphenated path
786        let matches = resolve("docs/prior_art.md", dir.path(), None);
787        assert_eq!(matches.len(), 1);
788        assert_eq!(matches[0].path, "docs/prior-art.md");
789    }
790
791    #[test]
792    fn test_unified_path_file_only() {
793        let dir = tempdir().unwrap();
794        fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
795        fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
796
797        let result = resolve_unified("src/myapp/cli.py", dir.path(), &no_aliases, None);
798        assert!(result.is_some());
799        let u = result.unwrap();
800        assert_eq!(u.file_path, "src/myapp/cli.py");
801        assert!(u.symbol_path.is_empty());
802        assert!(!u.is_directory);
803    }
804
805    #[test]
806    fn test_unified_path_with_symbol() {
807        let dir = tempdir().unwrap();
808        fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
809        fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
810
811        // File with symbol path
812        let result = resolve_unified("src/myapp/cli.py/Foo/bar", dir.path(), &no_aliases, None);
813        assert!(result.is_some());
814        let u = result.unwrap();
815        assert_eq!(u.file_path, "src/myapp/cli.py");
816        assert_eq!(u.symbol_path, vec!["Foo", "bar"]);
817        assert!(!u.is_directory);
818    }
819
820    #[test]
821    fn test_unified_path_directory() {
822        let dir = tempdir().unwrap();
823        fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
824        fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
825
826        let result = resolve_unified("src/myapp", dir.path(), &no_aliases, None);
827        assert!(result.is_some());
828        let u = result.unwrap();
829        assert_eq!(u.file_path, "src/myapp");
830        assert!(u.symbol_path.is_empty());
831        assert!(u.is_directory);
832    }
833
834    #[test]
835    fn test_unified_path_rust_style_separator() {
836        let dir = tempdir().unwrap();
837        fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
838        fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
839
840        // Rust-style :: separator
841        let result = resolve_unified("src/myapp/cli.py::Foo::bar", dir.path(), &no_aliases, None);
842        assert!(result.is_some());
843        let u = result.unwrap();
844        assert_eq!(u.file_path, "src/myapp/cli.py");
845        assert_eq!(u.symbol_path, vec!["Foo", "bar"]);
846    }
847
848    #[test]
849    fn test_unified_path_hash_separator() {
850        let dir = tempdir().unwrap();
851        fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
852        fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
853
854        // URL fragment-style # separator
855        let result = resolve_unified("src/myapp/cli.py#Foo", dir.path(), &no_aliases, None);
856        assert!(result.is_some());
857        let u = result.unwrap();
858        assert_eq!(u.file_path, "src/myapp/cli.py");
859        assert_eq!(u.symbol_path, vec!["Foo"]);
860    }
861
862    #[test]
863    fn test_unified_path_colon_separator() {
864        let dir = tempdir().unwrap();
865        fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
866        fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
867
868        // Compact : separator
869        let result = resolve_unified("src/myapp/cli.py:Foo:bar", dir.path(), &no_aliases, None);
870        assert!(result.is_some());
871        let u = result.unwrap();
872        assert_eq!(u.file_path, "src/myapp/cli.py");
873        assert_eq!(u.symbol_path, vec!["Foo", "bar"]);
874    }
875
876    #[test]
877    fn test_unified_path_fuzzy_file() {
878        let dir = tempdir().unwrap();
879        fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
880        fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
881
882        // Fuzzy file match with symbol
883        let result = resolve_unified("cli.py/Foo", dir.path(), &no_aliases, None);
884        assert!(result.is_some());
885        let u = result.unwrap();
886        assert_eq!(u.file_path, "src/myapp/cli.py");
887        assert_eq!(u.symbol_path, vec!["Foo"]);
888    }
889
890    #[test]
891    fn test_unified_path_absolute() {
892        let dir = tempdir().unwrap();
893        let abs_path = dir.path().join("test.py");
894        fs::write(&abs_path, "def foo(): pass").unwrap();
895
896        // Absolute path should resolve directly
897        let abs_str = abs_path.to_string_lossy().to_string();
898        let result = resolve_unified(&abs_str, Path::new("/some/other/root"), &no_aliases, None);
899        assert!(result.is_some());
900        let u = result.unwrap();
901        assert_eq!(u.file_path, abs_str);
902        assert!(u.symbol_path.is_empty());
903        assert!(!u.is_directory);
904    }
905
906    #[test]
907    fn test_unified_path_absolute_with_symbol() {
908        let dir = tempdir().unwrap();
909        let abs_path = dir.path().join("test.py");
910        fs::write(&abs_path, "def foo(): pass").unwrap();
911
912        // Absolute path with symbol
913        let query = format!("{}/foo", abs_path.to_string_lossy());
914        let result = resolve_unified(&query, Path::new("/some/other/root"), &no_aliases, None);
915        assert!(result.is_some());
916        let u = result.unwrap();
917        assert_eq!(u.file_path, abs_path.to_string_lossy().to_string());
918        assert_eq!(u.symbol_path, vec!["foo"]);
919    }
920
921    #[test]
922    fn test_unified_path_unicode() {
923        let dir = tempdir().unwrap();
924        let unicode_dir = dir.path().join("日本語");
925        fs::create_dir_all(&unicode_dir).unwrap();
926        let unicode_file = unicode_dir.join("テスト.py");
927        fs::write(&unicode_file, "def hello(): pass").unwrap();
928
929        // Absolute unicode path
930        let abs_str = unicode_file.to_string_lossy().to_string();
931        let result = resolve_unified(&abs_str, Path::new("/some/other/root"), &no_aliases, None);
932        assert!(result.is_some());
933        let u = result.unwrap();
934        assert_eq!(u.file_path, abs_str);
935        assert!(!u.is_directory);
936    }
937}