Skip to main content

code_kb_core/
workspace.rs

1use std::path::{Path, PathBuf};
2use thiserror::Error;
3
4#[derive(Debug, Error)]
5pub enum WorkspaceError {
6    #[error("Failed to canonicalize path {path}: {source}")]
7    CanonicalizationFailed {
8        path: PathBuf,
9        #[source]
10        source: std::io::Error,
11    },
12    #[error("Path '{0}' is outside workspace root '{1}'")]
13    PathOutsideWorkspace(PathBuf, PathBuf),
14    #[error("Could not discover workspace root from '{0}'")]
15    DiscoveryFailed(PathBuf),
16    #[error("Database artifact not found at '{0}'")]
17    ArtifactNotFound(PathBuf),
18}
19
20/// Lexically clean a path by collapsing `.` and `..` components.
21pub fn clean_path(path: &Path) -> PathBuf {
22    use std::path::Component;
23    let s = path.to_string_lossy();
24    let norm = if cfg!(not(windows)) && s.contains('\\') {
25        std::borrow::Cow::Owned(PathBuf::from(s.replace('\\', "/")))
26    } else {
27        std::borrow::Cow::Borrowed(path)
28    };
29    let mut stack = Vec::new();
30    for comp in norm.components() {
31        match comp {
32            Component::CurDir => {}
33            Component::ParentDir => {
34                if let Some(Component::Normal(_)) = stack.last() {
35                    stack.pop();
36                } else {
37                    stack.push(comp);
38                }
39            }
40            _ => stack.push(comp),
41        }
42    }
43    stack.into_iter().collect()
44}
45
46fn percent_decode(input: &str) -> String {
47    let mut bytes = Vec::with_capacity(input.len());
48    let input_bytes = input.as_bytes();
49    let mut i = 0;
50    while i < input_bytes.len() {
51        if input_bytes[i] == b'%'
52            && i + 2 < input_bytes.len()
53            && let Ok(hex) = std::str::from_utf8(&input_bytes[i + 1..i + 3])
54            && let Ok(byte) = u8::from_str_radix(hex, 16)
55        {
56            bytes.push(byte);
57            i += 3;
58            continue;
59        }
60        bytes.push(input_bytes[i]);
61        i += 1;
62    }
63    String::from_utf8_lossy(&bytes).into_owned()
64}
65
66/// Extract drive letter and remainder if the string begins with a drive specification
67/// delimited by ':', '|', or percent-encoded "%7C" / "%3A".
68fn extract_drive_letter_and_remainder(s: &str) -> Option<(char, &str)> {
69    let bytes = s.as_bytes();
70    if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() {
71        return None;
72    }
73    let drive = bytes[0] as char;
74
75    // Single-byte delimiters: ':' or '|'
76    if bytes.len() >= 2
77        && (bytes[1] == b':' || bytes[1] == b'|')
78        && (bytes.len() == 2
79            || bytes[2] == b'/'
80            || bytes[2] == b'\\'
81            || bytes[2] == b'?'
82            || bytes[2] == b'#')
83    {
84        return Some((drive, &s[2..]));
85    }
86
87    // Three-byte percent-encoded delimiters: "%7C", "%7c", "%3A", "%3a"
88    if bytes.len() >= 4 {
89        let delim = &bytes[1..4];
90        if (delim.eq_ignore_ascii_case(b"%7c") || delim.eq_ignore_ascii_case(b"%3a"))
91            && (bytes.len() == 4
92                || bytes[4] == b'/'
93                || bytes[4] == b'\\'
94                || bytes[4] == b'?'
95                || bytes[4] == b'#')
96        {
97            return Some((drive, &s[4..]));
98        }
99    }
100
101    None
102}
103
104/// Strip an optional "localhost/" or "localhost\" prefix (with or without a leading slash).
105fn strip_localhost_prefix(s: &str) -> &str {
106    let without_slash = s.strip_prefix('/').unwrap_or(s);
107    let bytes = without_slash.as_bytes();
108    if bytes.len() >= 10
109        && bytes[..9].eq_ignore_ascii_case(b"localhost")
110        && (bytes[9] == b'/' || bytes[9] == b'\\')
111    {
112        &without_slash[10..]
113    } else {
114        s
115    }
116}
117
118/// Convert a path string starting with a pipe drive specification (e.g. "C|/..." or "/C|/...")
119/// to use a standard colon ':' delimiter (e.g. "C:/...").
120fn normalize_drive_pipe_str(s: &str) -> String {
121    let clean = strip_localhost_prefix(s);
122    let target = clean.strip_prefix('/').unwrap_or(clean);
123    let target = strip_localhost_prefix(target);
124    if let Some((drive, remainder)) = extract_drive_letter_and_remainder(target) {
125        if remainder.is_empty() || remainder.starts_with('?') || remainder.starts_with('#') {
126            format!("{}:/{}", drive, remainder)
127        } else {
128            format!("{}:{}", drive, remainder)
129        }
130    } else {
131        s.to_string()
132    }
133}
134
135/// Parse an MCP file URI or plain path into a normalized PathBuf.
136/// Handles standard file URIs (`file:///path`), two-slash drive letter URIs (`file://C:/...`),
137/// pipe drive delimiters (`file:///C|/...`, `file://C|/...`), percent-encoding (`%20`, `%7C`), and plain paths.
138pub fn parse_file_uri(cand: &str) -> Option<PathBuf> {
139    if let Some(rest) = cand.strip_prefix("file://") {
140        let path_part = rest.strip_prefix('/').unwrap_or(rest);
141        let path_part = strip_localhost_prefix(path_part);
142        let normalized_cand = if let Some((drive, remainder)) =
143            extract_drive_letter_and_remainder(path_part)
144        {
145            if remainder.is_empty() || remainder.starts_with('?') || remainder.starts_with('#') {
146                format!("file:///{}:/{}", drive, remainder)
147            } else if remainder.starts_with('/') || remainder.starts_with('\\') {
148                format!("file:///{}:{}", drive, remainder)
149            } else {
150                format!("file:///{}:/{}", drive, remainder)
151            }
152        } else {
153            cand.to_string()
154        };
155
156        let file_path = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
157            url::Url::parse(&normalized_cand)
158                .ok()
159                .and_then(|url| url.to_file_path().ok())
160        }))
161        .ok()
162        .flatten();
163
164        if let Some(path) = file_path {
165            return Some(normalize_path(&path));
166        }
167        // Fallback for non-standard file:// patterns with percent decoding
168        if let Some(s) = cand.strip_prefix("file:///") {
169            let decoded = percent_decode(s);
170            let normalized = normalize_drive_pipe_str(&decoded);
171            if cfg!(windows) {
172                Some(normalize_path(Path::new(&normalized)))
173            } else {
174                Some(normalize_path(&PathBuf::from(format!("/{}", normalized))))
175            }
176        } else {
177            let s = cand.strip_prefix("file://").unwrap_or(cand);
178            let decoded = percent_decode(s);
179            let normalized = normalize_drive_pipe_str(&decoded);
180            Some(normalize_path(Path::new(&normalized)))
181        }
182    } else {
183        let normalized = normalize_drive_pipe_str(cand);
184        Some(normalize_path(Path::new(&normalized)))
185    }
186}
187
188/// Strip Windows verbatim prefix (\\?\, \\?\UNC\) using dunce.
189pub fn normalize_path(path: &Path) -> PathBuf {
190    let s = path.to_string_lossy();
191    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
192        let unc = format!(r"\\{rest}");
193        return dunce::simplified(Path::new(&unc)).to_path_buf();
194    }
195    if let Some(rest) = s.strip_prefix(r"\\?\") {
196        return dunce::simplified(Path::new(rest)).to_path_buf();
197    }
198    dunce::simplified(path).to_path_buf()
199}
200
201/// Convert a path to forward-slash string representation for stable relative paths.
202pub fn to_forward_slash(path: &Path) -> String {
203    let s = path.to_string_lossy();
204    s.replace('\\', "/")
205}
206
207/// Compare two path components for equality.
208/// On Windows, compares `Component::Normal` case-insensitively and drive letters in `Component::Prefix` case-insensitively.
209#[cfg(windows)]
210fn components_equal(c1: &std::path::Component, c2: &std::path::Component) -> bool {
211    if c1 == c2 {
212        return true;
213    }
214    {
215        use std::path::Component;
216        match (c1, c2) {
217            (Component::Normal(s1), Component::Normal(s2)) => s1
218                .to_string_lossy()
219                .eq_ignore_ascii_case(&s2.to_string_lossy()),
220            (Component::Prefix(p1), Component::Prefix(p2)) => {
221                use std::path::Prefix;
222                match (p1.kind(), p2.kind()) {
223                    (Prefix::Disk(d1), Prefix::Disk(d2))
224                    | (Prefix::VerbatimDisk(d1), Prefix::VerbatimDisk(d2))
225                    | (Prefix::Disk(d1), Prefix::VerbatimDisk(d2))
226                    | (Prefix::VerbatimDisk(d1), Prefix::Disk(d2)) => d1.eq_ignore_ascii_case(&d2),
227                    (Prefix::UNC(s1, sh1), Prefix::UNC(s2, sh2))
228                    | (Prefix::VerbatimUNC(s1, sh1), Prefix::VerbatimUNC(s2, sh2))
229                    | (Prefix::UNC(s1, sh1), Prefix::VerbatimUNC(s2, sh2))
230                    | (Prefix::VerbatimUNC(s1, sh1), Prefix::UNC(s2, sh2)) => {
231                        s1.to_string_lossy()
232                            .eq_ignore_ascii_case(&s2.to_string_lossy())
233                            && sh1
234                                .to_string_lossy()
235                                .eq_ignore_ascii_case(&sh2.to_string_lossy())
236                    }
237                    (Prefix::DeviceNS(d1), Prefix::DeviceNS(d2))
238                    | (Prefix::Verbatim(d1), Prefix::Verbatim(d2)) => d1
239                        .to_string_lossy()
240                        .eq_ignore_ascii_case(&d2.to_string_lossy()),
241                    _ => false,
242                }
243            }
244            _ => false,
245        }
246    }
247}
248
249/// Strips `base` from `path`. On Windows, if standard `strip_prefix` fails,
250/// performs case-insensitive component comparison to support Windows case-preserving filesystems.
251pub fn strip_prefix_lossy<'a>(path: &'a Path, base: &Path) -> Option<&'a Path> {
252    if let Ok(rel) = path.strip_prefix(base) {
253        return Some(rel);
254    }
255
256    #[cfg(windows)]
257    {
258        let mut path_comps = path.components();
259        for base_comp in base.components() {
260            let path_comp = path_comps.next()?;
261            if !components_equal(&base_comp, &path_comp) {
262                return None;
263            }
264        }
265        Some(path_comps.as_path())
266    }
267    #[cfg(not(windows))]
268    {
269        None
270    }
271}
272
273/// Compare two paths for logical identity.
274/// On Windows, normalizes verbatim prefixes via dunce and compares disk prefixes and components case-insensitively.
275/// On non-Windows, compares paths directly.
276pub fn paths_equal(p1: &Path, p2: &Path) -> bool {
277    let p1_norm = normalize_path(p1);
278    let p2_norm = normalize_path(p2);
279    if p1_norm == p2_norm {
280        return true;
281    }
282    if to_forward_slash(&p1_norm) == to_forward_slash(&p2_norm) {
283        return true;
284    }
285    if let (Ok(c1), Ok(c2)) = (dunce::canonicalize(p1), dunce::canonicalize(p2)) {
286        let c1_norm = normalize_path(&c1);
287        let c2_norm = normalize_path(&c2);
288        if c1_norm == c2_norm || to_forward_slash(&c1_norm) == to_forward_slash(&c2_norm) {
289            return true;
290        }
291    }
292    #[cfg(windows)]
293    {
294        let mut c1 = p1_norm.components();
295        let mut c2 = p2_norm.components();
296        loop {
297            match (c1.next(), c2.next()) {
298                (None, None) => return true,
299                (Some(comp1), Some(comp2)) => {
300                    if !components_equal(&comp1, &comp2) {
301                        return false;
302                    }
303                }
304                _ => return false,
305            }
306        }
307    }
308    #[cfg(not(windows))]
309    {
310        false
311    }
312}
313
314/// Check if a relative path contains directories or file patterns that must never be indexed or watched.
315pub fn is_hard_excluded(rel_path: &str) -> bool {
316    let p = rel_path.replace('\\', "/");
317    let has_excluded_dir = p.split('/').any(|component| {
318        matches!(
319            component,
320            ".git"
321                | ".hg"
322                | ".svn"
323                | ".julie"
324                | ".code-kb"
325                | ".memories"
326                | ".agents"
327                | ".razorback"
328                | ".worktrees"
329                | "worktrees"
330                | ".claude"
331                | ".venv"
332                | "venv"
333                | ".env"
334                | ".tox"
335                | ".vs"
336                | "node_modules"
337                | "vendor"
338                | "target"
339                | "dist"
340                | "build"
341                | ".cache"
342                | "obj"
343                | "TestResults"
344                | ".idea"
345                | ".vscode"
346        )
347    });
348
349    if has_excluded_dir {
350        return true;
351    }
352
353    const EXCLUDED_SUFFIXES: &[&str] = &[
354        ".min.js",
355        ".bundle.js",
356        ".generated.js",
357        ".generated.jsx",
358        ".generated.ts",
359        ".generated.tsx",
360        ".generated.d.ts",
361        ".tmp",
362        ".swp",
363        "~",
364    ];
365
366    EXCLUDED_SUFFIXES.iter().any(|suffix| p.ends_with(suffix))
367}
368
369/// Represents a bound workspace session.
370#[derive(Debug, Clone)]
371pub struct Workspace {
372    pub root: PathBuf,
373    pub canonical_root: PathBuf,
374    pub repo_name: String,
375}
376
377fn trim_trailing_slash(p: &Path) -> PathBuf {
378    let s = p.to_string_lossy();
379    if s.len() > 1 && (s.ends_with('/') || s.ends_with('\\')) {
380        let trimmed = s.trim_end_matches(['/', '\\']);
381        if trimmed.is_empty() {
382            return PathBuf::from(if cfg!(windows) && s.starts_with('\\') {
383                "\\"
384            } else {
385                "/"
386            });
387        }
388        if cfg!(windows)
389            && trimmed.len() == 2
390            && trimmed.as_bytes()[0].is_ascii_alphabetic()
391            && trimmed.as_bytes()[1] == b':'
392        {
393            return PathBuf::from(format!("{}\\", trimmed));
394        }
395        return PathBuf::from(trimmed);
396    }
397    p.to_path_buf()
398}
399
400/// True when `root` carries a repository or language project marker.
401pub fn is_project_root(root: &Path) -> bool {
402    [
403        ".git",
404        "Cargo.toml",
405        "package.json",
406        "go.mod",
407        "pyproject.toml",
408    ]
409    .iter()
410    .any(|marker| root.join(marker).exists())
411}
412
413impl Workspace {
414    /// Discover and bind a workspace from an optional path, falling back to CWD and upward traversal.
415    pub fn discover(start_path: Option<&Path>) -> Result<Self, WorkspaceError> {
416        let current = match start_path {
417            Some(p) => p.to_path_buf(),
418            None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
419        };
420
421        let root = Self::find_workspace_root(&current)?;
422        Ok(Self::new(root))
423    }
424
425    /// Create workspace binding directly for a known root directory.
426    pub fn new(root: PathBuf) -> Self {
427        let root_str = root.to_string_lossy();
428        let root = if root_str.starts_with("file://") {
429            parse_file_uri(&root_str).unwrap_or_else(|| normalize_path(&root))
430        } else {
431            normalize_path(&root)
432        };
433        let root = trim_trailing_slash(&root);
434        let canonical_root =
435            normalize_path(&dunce::canonicalize(&root).unwrap_or_else(|_| root.clone()));
436        let repo_name = canonical_root
437            .file_name()
438            .map(|n| n.to_string_lossy().to_string())
439            .unwrap_or_else(|| "repo".to_string());
440
441        Self {
442            root,
443            canonical_root,
444            repo_name,
445        }
446    }
447
448    /// Find root by searching upwards for .git, .code-kb, or workspace markers.
449    pub fn find_workspace_root(start: &Path) -> Result<PathBuf, WorkspaceError> {
450        let raw = start.to_string_lossy();
451        let parsed = if raw.starts_with("file://") {
452            parse_file_uri(&raw).unwrap_or_else(|| start.to_path_buf())
453        } else {
454            start.to_path_buf()
455        };
456        let parsed = trim_trailing_slash(&parsed);
457        let curr = if parsed.is_file() {
458            parsed.parent().unwrap_or(&parsed).to_path_buf()
459        } else {
460            parsed.clone()
461        };
462
463        // Pass 1: Look for .git or .code-kb all the way up
464        let mut probe = curr.clone();
465        loop {
466            if probe.join(".code-kb").exists() || probe.join(".git").exists() {
467                let canon = dunce::canonicalize(&probe).unwrap_or(probe);
468                return Ok(normalize_path(&canon));
469            }
470            if let Some(name) = probe.file_name().and_then(|n| n.to_str())
471                && is_hard_excluded(name)
472            {
473                break;
474            }
475            if let Some(parent) = probe.parent() {
476                if parent == probe {
477                    break;
478                }
479                probe = parent.to_path_buf();
480            } else {
481                break;
482            }
483        }
484
485        // Pass 2: Look for language project markers
486        let mut curr_marker = curr.clone();
487        loop {
488            if curr_marker.join("Cargo.toml").exists()
489                || curr_marker.join("package.json").exists()
490                || curr_marker.join("go.mod").exists()
491                || curr_marker.join("pyproject.toml").exists()
492            {
493                let canon = dunce::canonicalize(&curr_marker).unwrap_or(curr_marker);
494                return Ok(normalize_path(&canon));
495            }
496            if let Some(name) = curr_marker.file_name().and_then(|n| n.to_str())
497                && is_hard_excluded(name)
498            {
499                break;
500            }
501
502            if let Some(parent) = curr_marker.parent() {
503                if parent == curr_marker {
504                    break;
505                }
506                curr_marker = parent.to_path_buf();
507            } else {
508                break;
509            }
510        }
511
512        // Default to start directory if no markers found
513        let start_dir = if parsed.is_file() {
514            parsed.parent().unwrap_or(&parsed).to_path_buf()
515        } else {
516            parsed
517        };
518        let canon = dunce::canonicalize(&start_dir).unwrap_or(start_dir);
519        Ok(normalize_path(&canon))
520    }
521
522    /// Resolves an input path (relative, absolute, or file:// URI) to a canonical absolute path and relative path.
523    pub fn resolve_path(&self, input: &Path) -> Result<(PathBuf, String), WorkspaceError> {
524        let raw_str = input.to_string_lossy();
525        let path = if raw_str.starts_with("file://") {
526            parse_file_uri(&raw_str).unwrap_or_else(|| input.to_path_buf())
527        } else {
528            input.to_path_buf()
529        };
530        let path = normalize_path(&path);
531
532        let is_abs = path.is_absolute()
533            || (cfg!(windows) && (path.to_string_lossy().chars().nth(1) == Some(':')));
534
535        let joined = if is_abs {
536            path
537        } else {
538            let rel_str = if cfg!(not(windows)) && path.to_string_lossy().contains('\\') {
539                path.to_string_lossy().replace('\\', "/")
540            } else {
541                path.to_string_lossy().to_string()
542            };
543            self.canonical_root.join(Path::new(&rel_str))
544        };
545
546        // Lexically clean the path to collapse `.` and `..` components
547        let cleaned = clean_path(&joined);
548        let abs_path = normalize_path(&cleaned);
549
550        // If file exists, canonicalize to resolve any symlinks
551        let effective_abs = if abs_path.exists() {
552            dunce::canonicalize(&abs_path)
553                .map(|p| normalize_path(&p))
554                .unwrap_or_else(|_| abs_path.clone())
555        } else {
556            abs_path.clone()
557        };
558
559        let norm_root = dunce::canonicalize(&self.canonical_root)
560            .map(|p| normalize_path(&p))
561            .unwrap_or_else(|_| self.canonical_root.clone());
562
563        // Check if within canonical root (trying multiple normalization variants with case-insensitivity on Windows)
564        let rel = match strip_prefix_lossy(&effective_abs, &norm_root)
565            .or_else(|| strip_prefix_lossy(&effective_abs, &self.canonical_root))
566            .or_else(|| {
567                // Only fall back to uncanonicalized abs_path if the file does not exist yet (e.g. filters or uncreated files)
568                if !abs_path.exists() {
569                    strip_prefix_lossy(&abs_path, &norm_root)
570                        .or_else(|| strip_prefix_lossy(&abs_path, &self.canonical_root))
571                } else {
572                    None
573                }
574            }) {
575            Some(r) => {
576                let forward = to_forward_slash(r);
577                if forward.starts_with("../") || forward == ".." {
578                    return Err(WorkspaceError::PathOutsideWorkspace(
579                        abs_path,
580                        self.canonical_root.clone(),
581                    ));
582                }
583                forward
584            }
585            None => {
586                return Err(WorkspaceError::PathOutsideWorkspace(
587                    abs_path,
588                    self.canonical_root.clone(),
589                ));
590            }
591        };
592
593        Ok((effective_abs, rel))
594    }
595
596    /// Relativizes a path filter string (which may be absolute, file:// URI, or relative)
597    /// against this workspace root into a forward-slash relative path suitable for SQLite queries.
598    pub fn relativize_filter(&self, filter: &str) -> String {
599        let trimmed = filter.trim();
600        if trimmed.is_empty() {
601            return String::new();
602        }
603
604        // Handle file:// URI
605        let path_str = if trimmed.starts_with("file://") {
606            parse_file_uri(trimmed)
607                .map(|p| p.to_string_lossy().to_string())
608                .unwrap_or_else(|| trimmed.to_string())
609        } else {
610            trimmed.to_string()
611        };
612
613        let raw_path = Path::new(&path_str);
614        let simplified = dunce::simplified(raw_path);
615
616        if simplified.is_absolute() {
617            if let Ok((_, rel)) = self.resolve_path(simplified) {
618                return rel;
619            }
620            // If resolve_path failed (e.g. non-existent path), try prefix stripping on normalized strings
621            let norm_simplified = normalize_path(simplified);
622            let norm_root = normalize_path(&self.canonical_root);
623            if let Some(rel) = strip_prefix_lossy(&norm_simplified, &norm_root)
624                .or_else(|| strip_prefix_lossy(&norm_simplified, &self.root))
625            {
626                let forward = to_forward_slash(rel);
627                if !forward.starts_with("../") && forward != ".." {
628                    return forward.trim_matches('/').to_string();
629                }
630            }
631        }
632
633        // Relative path: pass through clean_path to collapse `.` and `..`, normalize slashes, and trim leading ./ or /
634        let cleaned = clean_path(Path::new(&path_str));
635        let forward = to_forward_slash(&cleaned);
636        let trimmed = forward.trim_start_matches("./").trim_matches('/');
637        if trimmed == "." {
638            String::new()
639        } else {
640            trimmed.to_string()
641        }
642    }
643
644    /// Resolve candidate database paths for this workspace:
645    /// 1. Explicit override path (if provided)
646    /// 2. In-tree `.code-kb/artifact.db` or `.code-kb/store.db`
647    pub fn candidate_db_paths(&self, explicit_db: Option<&Path>) -> Vec<PathBuf> {
648        let mut candidates = Vec::new();
649
650        if let Some(p) = explicit_db {
651            candidates.push(normalize_path(p));
652        }
653
654        // In-tree options
655        candidates.push(normalize_path(
656            &self.canonical_root.join(".code-kb").join("artifact.db"),
657        ));
658        candidates.push(normalize_path(
659            &self.canonical_root.join(".code-kb").join("store.db"),
660        ));
661        candidates.push(normalize_path(&self.canonical_root.join("artifact.db")));
662
663        candidates
664    }
665
666    /// Finds the first existing database file, or returns the default target location.
667    pub fn locate_db(&self, explicit_db: Option<&Path>) -> Result<PathBuf, WorkspaceError> {
668        if let Some(p) = explicit_db {
669            return Ok(normalize_path(p));
670        }
671
672        let candidates = self.candidate_db_paths(None);
673        for candidate in &candidates {
674            if candidate.exists() && candidate.is_file() {
675                return Ok(normalize_path(candidate));
676            }
677        }
678
679        Ok(normalize_path(
680            &self.canonical_root.join(".code-kb").join("artifact.db"),
681        ))
682    }
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    #[test]
690    #[cfg(windows)]
691    fn test_normalize_path() {
692        let p = PathBuf::from(r"\\?\C:\source\code-kb\src\main.rs");
693        let norm = normalize_path(&p);
694        assert!(!norm.to_string_lossy().starts_with(r"\\?\"));
695    }
696
697    #[test]
698    fn test_to_forward_slash() {
699        let p = PathBuf::from(r"src\models\mod.rs");
700        assert_eq!(to_forward_slash(&p), "src/models/mod.rs");
701    }
702
703    #[test]
704    fn test_workspace_resolve_path() {
705        let ws = Workspace::new(PathBuf::from("C:/source/test-project"));
706        let (abs, rel) = ws.resolve_path(Path::new("src/lib.rs")).unwrap();
707        assert_eq!(rel, "src/lib.rs");
708        assert!(abs.to_string_lossy().contains("test-project"));
709
710        #[cfg(windows)]
711        {
712            // Lowercase drive letter
713            let (_abs2, rel2) = ws
714                .resolve_path(Path::new("c:/source/test-project/src/lib.rs"))
715                .unwrap();
716            assert_eq!(rel2, "src/lib.rs");
717
718            // Case-insensitive directory on Windows
719            let (_abs3, rel3) = ws
720                .resolve_path(Path::new("C:/SOURCE/test-project/src/lib.rs"))
721                .unwrap();
722            assert_eq!(rel3, "src/lib.rs");
723
724            // file:// URI
725            let (_abs4, rel4) = ws
726                .resolve_path(Path::new("file:///C:/source/test-project/src/lib.rs"))
727                .unwrap();
728            assert_eq!(rel4, "src/lib.rs");
729
730            // file:// URI with lowercase drive letter
731            let (_abs5, rel5) = ws
732                .resolve_path(Path::new("file:///c:/source/test-project/src/lib.rs"))
733                .unwrap();
734            assert_eq!(rel5, "src/lib.rs");
735        }
736    }
737
738    #[test]
739    fn test_workspace_resolve_path_traversal_escape() {
740        let temp = crate::safe_tempdir();
741        let ws = Workspace::new(temp.path().to_path_buf());
742        let res = ws.resolve_path(Path::new("sub/../../outside.rs"));
743        assert!(
744            matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
745            "Expected PathOutsideWorkspace error, but got: {:?}",
746            res
747        );
748    }
749
750    #[test]
751    fn test_parse_file_uri() {
752        #[cfg(windows)]
753        let (uri, expected) = ("file:///C:/my%20folder/project", "C:/my folder/project");
754        #[cfg(not(windows))]
755        let (uri, expected) = ("file:///tmp/my%20folder/project", "/tmp/my folder/project");
756
757        let p1 = parse_file_uri(uri).unwrap();
758        assert_eq!(p1, normalize_path(Path::new(expected)));
759
760        // Plain path fallback
761        let p2 = parse_file_uri("C:/direct/path").unwrap();
762        assert_eq!(p2, normalize_path(Path::new("C:/direct/path")));
763    }
764
765    #[test]
766    fn test_relativize_filter() {
767        let temp = crate::safe_tempdir();
768        let ws = Workspace::new(temp.path().to_path_buf());
769
770        // Relative path
771        assert_eq!(ws.relativize_filter("."), "");
772        assert_eq!(ws.relativize_filter("./"), "");
773        assert_eq!(ws.relativize_filter("src/models"), "src/models");
774        assert_eq!(ws.relativize_filter("./src/models/"), "src/models");
775        assert_eq!(
776            ws.relativize_filter(r"src\models\mod.rs"),
777            "src/models/mod.rs"
778        );
779
780        // Relative path with ..
781        assert_eq!(
782            ws.relativize_filter("src/../src/models/mod.rs"),
783            "src/models/mod.rs"
784        );
785
786        // Absolute path inside workspace
787        let abs_file = temp.path().join("src").join("lib.rs");
788        std::fs::create_dir_all(abs_file.parent().unwrap()).unwrap();
789        std::fs::write(&abs_file, "").unwrap();
790
791        assert_eq!(
792            ws.relativize_filter(&abs_file.to_string_lossy()),
793            "src/lib.rs"
794        );
795
796        // File URI
797        let uri = format!("file://{}", abs_file.to_string_lossy().replace('\\', "/"));
798        assert_eq!(ws.relativize_filter(&uri), "src/lib.rs");
799
800        #[cfg(windows)]
801        {
802            // Case-insensitive absolute path for existing file resolves to canonical disk casing
803            let upper_abs = abs_file.to_string_lossy().to_uppercase();
804            assert_eq!(ws.relativize_filter(&upper_abs), "src/lib.rs");
805
806            // File URI with alternate case
807            let uri_cased = format!(
808                "file:///{}",
809                abs_file.to_string_lossy().replace('\\', "/").to_lowercase()
810            );
811            assert_eq!(ws.relativize_filter(&uri_cased), "src/lib.rs");
812        }
813    }
814
815    #[test]
816    fn test_paths_equal() {
817        assert!(paths_equal(
818            Path::new("src/lib.rs"),
819            Path::new("src/lib.rs")
820        ));
821        assert!(!paths_equal(
822            Path::new("src/lib.rs"),
823            Path::new("src/main.rs")
824        ));
825
826        #[cfg(windows)]
827        {
828            // Case-insensitive drive letters and paths
829            assert!(paths_equal(
830                Path::new(r"C:\source\code-kb\src\lib.rs"),
831                Path::new(r"c:\source\code-kb\src\lib.rs")
832            ));
833            assert!(paths_equal(
834                Path::new(r"C:\source\code-kb\src\lib.rs"),
835                Path::new(r"c:\SOURCE\CODE-KB\SRC\LIB.RS")
836            ));
837            // Verbatim prefixes
838            assert!(paths_equal(
839                Path::new(r"\\?\C:\source\code-kb\src\lib.rs"),
840                Path::new(r"C:\source\code-kb\src\lib.rs")
841            ));
842            assert!(paths_equal(
843                Path::new(r"\\?\c:\source\code-kb\src\lib.rs"),
844                Path::new(r"C:\source\code-kb\src\lib.rs")
845            ));
846            // UNC paths
847            assert!(paths_equal(
848                Path::new(r"\\server\share\file"),
849                Path::new(r"\\SERVER\SHARE\file")
850            ));
851            assert!(paths_equal(
852                Path::new(r"\\server\share\file"),
853                Path::new(r"\\server\share\file")
854            ));
855            assert!(!paths_equal(
856                Path::new(r"\\server\share1\file"),
857                Path::new(r"\\server\share2\file")
858            ));
859        }
860    }
861
862    #[test]
863    fn test_strip_prefix_lossy() {
864        let base = Path::new("src");
865        assert_eq!(
866            strip_prefix_lossy(Path::new("src/lib.rs"), base),
867            Some(Path::new("lib.rs"))
868        );
869        assert_eq!(strip_prefix_lossy(Path::new("tests/foo.rs"), base), None);
870
871        #[cfg(windows)]
872        {
873            let base_win = Path::new(r"C:\source\code-kb");
874            // Standard path
875            assert_eq!(
876                strip_prefix_lossy(Path::new(r"C:\source\code-kb\src\lib.rs"), base_win),
877                Some(Path::new(r"src\lib.rs"))
878            );
879            // Disk prefix casing
880            assert_eq!(
881                strip_prefix_lossy(Path::new(r"c:\source\code-kb\src\lib.rs"), base_win),
882                Some(Path::new(r"src\lib.rs"))
883            );
884            assert_eq!(
885                strip_prefix_lossy(Path::new(r"c:\SOURCE\CODE-KB\src\lib.rs"), base_win),
886                Some(Path::new(r"src\lib.rs"))
887            );
888            // Verbatim prefixes
889            assert_eq!(
890                strip_prefix_lossy(Path::new(r"\\?\C:\source\code-kb\src\lib.rs"), base_win),
891                Some(Path::new(r"src\lib.rs"))
892            );
893            assert_eq!(
894                strip_prefix_lossy(Path::new(r"\\?\c:\source\code-kb\src\lib.rs"), base_win),
895                Some(Path::new(r"src\lib.rs"))
896            );
897            // Negative non-matching paths
898            assert_eq!(
899                strip_prefix_lossy(Path::new(r"C:\other\code-kb\src\lib.rs"), base_win),
900                None
901            );
902            assert_eq!(
903                strip_prefix_lossy(Path::new(r"D:\source\code-kb\src\lib.rs"), base_win),
904                None
905            );
906        }
907    }
908
909    #[test]
910    fn test_parse_file_uri_two_slash_and_percent() {
911        #[cfg(windows)]
912        {
913            let p1 = parse_file_uri("file://C:/my%20folder/lib.rs").unwrap();
914            assert_eq!(p1, normalize_path(Path::new("C:/my folder/lib.rs")));
915
916            let p2 = parse_file_uri("file://c:/my%20folder/lib.rs").unwrap();
917            assert_eq!(p2, normalize_path(Path::new("c:/my folder/lib.rs")));
918
919            let p3 = parse_file_uri("file:///C:/my%20folder/lib.rs").unwrap();
920            assert_eq!(p3, normalize_path(Path::new("C:/my folder/lib.rs")));
921        }
922        #[cfg(not(windows))]
923        {
924            let p1 = parse_file_uri("file:///my%20folder/lib.rs").unwrap();
925            assert_eq!(p1, normalize_path(Path::new("/my folder/lib.rs")));
926        }
927    }
928
929    #[test]
930    fn test_workspace_verbatim_root_and_db_cleanup() {
931        let temp = crate::safe_tempdir();
932        let verbatim_path = format!(r"\\?\{}", temp.path().display());
933        let ws = Workspace::new(PathBuf::from(&verbatim_path));
934        assert!(!ws.root.to_string_lossy().starts_with(r"\\?\"));
935        assert!(!ws.canonical_root.to_string_lossy().starts_with(r"\\?\"));
936
937        let explicit = PathBuf::from(format!(r"\\?\{}\test.db", temp.path().display()));
938        let located = ws.locate_db(Some(&explicit)).unwrap();
939        assert!(!located.to_string_lossy().starts_with(r"\\?\"));
940    }
941
942    #[test]
943    fn test_trim_trailing_slash_edge_cases() {
944        assert_eq!(trim_trailing_slash(Path::new("/")), PathBuf::from("/"));
945        assert_eq!(trim_trailing_slash(Path::new("///")), PathBuf::from("/"));
946        assert_eq!(
947            trim_trailing_slash(Path::new("/a/b/")),
948            PathBuf::from("/a/b")
949        );
950        assert_eq!(
951            trim_trailing_slash(Path::new("foo/bar/")),
952            PathBuf::from("foo/bar")
953        );
954
955        #[cfg(windows)]
956        {
957            assert_eq!(
958                trim_trailing_slash(Path::new("C:\\")),
959                PathBuf::from("C:\\")
960            );
961            assert_eq!(trim_trailing_slash(Path::new("C:/")), PathBuf::from("C:\\"));
962            assert_eq!(
963                trim_trailing_slash(Path::new("C://")),
964                PathBuf::from("C:\\")
965            );
966            assert_eq!(
967                trim_trailing_slash(Path::new("C:\\\\")),
968                PathBuf::from("C:\\")
969            );
970            assert_eq!(
971                trim_trailing_slash(Path::new("C:/foo/")),
972                PathBuf::from("C:/foo")
973            );
974        }
975    }
976
977    #[test]
978    fn test_unicode_and_emoji_uri_safety() {
979        // Must not panic on non-ASCII character boundaries
980        let p1 = parse_file_uri("file:///a๐Ÿ˜€/x");
981        assert!(p1.is_some());
982
983        let p2 = parse_file_uri("file:///c๐Ÿ˜€/x");
984        assert!(p2.is_some());
985
986        let p3 = parse_file_uri("file:///localhost๐Ÿ˜€/x");
987        assert!(p3.is_some());
988
989        let p4 = parse_file_uri("file://C:/๐Ÿ˜€๐Ÿ˜€/main.rs");
990        assert!(p4.is_some());
991    }
992
993    #[test]
994    #[cfg(unix)]
995    fn test_escaping_symlink_rejected() {
996        let ws_dir = crate::safe_tempdir();
997        let ext_dir = crate::safe_tempdir();
998
999        let ext_file = ext_dir.path().join("secret.txt");
1000        std::fs::write(&ext_file, "secret").unwrap();
1001
1002        let symlink_path = ws_dir.path().join("link.txt");
1003        std::os::unix::fs::symlink(&ext_file, &symlink_path).unwrap();
1004        let ws = Workspace::new(ws_dir.path().to_path_buf());
1005        let res = ws.resolve_path(&symlink_path);
1006        assert!(
1007            matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
1008            "Expected PathOutsideWorkspace, got: {res:?}"
1009        );
1010    }
1011}