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 an existing index all the way up. A bare `.code-kb`
464        // directory is not a marker: `~/.code-kb` holds telemetry and plugin downloads.
465        let mut probe = curr.clone();
466        loop {
467            if probe.join(".code-kb").join("artifact.db").exists() || probe.join(".git").exists() {
468                let canon = dunce::canonicalize(&probe).unwrap_or(probe);
469                return Ok(normalize_path(&canon));
470            }
471            if let Some(name) = probe.file_name().and_then(|n| n.to_str())
472                && is_hard_excluded(name)
473            {
474                break;
475            }
476            if let Some(parent) = probe.parent() {
477                if parent == probe {
478                    break;
479                }
480                probe = parent.to_path_buf();
481            } else {
482                break;
483            }
484        }
485
486        // Pass 2: Look for language project markers
487        let mut curr_marker = curr.clone();
488        loop {
489            if curr_marker.join("Cargo.toml").exists()
490                || curr_marker.join("package.json").exists()
491                || curr_marker.join("go.mod").exists()
492                || curr_marker.join("pyproject.toml").exists()
493            {
494                let canon = dunce::canonicalize(&curr_marker).unwrap_or(curr_marker);
495                return Ok(normalize_path(&canon));
496            }
497            if let Some(name) = curr_marker.file_name().and_then(|n| n.to_str())
498                && is_hard_excluded(name)
499            {
500                break;
501            }
502
503            if let Some(parent) = curr_marker.parent() {
504                if parent == curr_marker {
505                    break;
506                }
507                curr_marker = parent.to_path_buf();
508            } else {
509                break;
510            }
511        }
512
513        // Default to start directory if no markers found
514        let start_dir = if parsed.is_file() {
515            parsed.parent().unwrap_or(&parsed).to_path_buf()
516        } else {
517            parsed
518        };
519        let canon = dunce::canonicalize(&start_dir).unwrap_or(start_dir);
520        Ok(normalize_path(&canon))
521    }
522
523    /// Resolves an input path (relative, absolute, or file:// URI) to a canonical absolute path and relative path.
524    pub fn resolve_path(&self, input: &Path) -> Result<(PathBuf, String), WorkspaceError> {
525        let raw_str = input.to_string_lossy();
526        let path = if raw_str.starts_with("file://") {
527            parse_file_uri(&raw_str).unwrap_or_else(|| input.to_path_buf())
528        } else {
529            input.to_path_buf()
530        };
531        let path = normalize_path(&path);
532
533        let is_abs = path.is_absolute()
534            || (cfg!(windows) && (path.to_string_lossy().chars().nth(1) == Some(':')));
535
536        let joined = if is_abs {
537            path
538        } else {
539            let rel_str = if cfg!(not(windows)) && path.to_string_lossy().contains('\\') {
540                path.to_string_lossy().replace('\\', "/")
541            } else {
542                path.to_string_lossy().to_string()
543            };
544            self.canonical_root.join(Path::new(&rel_str))
545        };
546
547        // Lexically clean the path to collapse `.` and `..` components
548        let cleaned = clean_path(&joined);
549        let abs_path = normalize_path(&cleaned);
550
551        // If file exists, canonicalize to resolve any symlinks
552        let effective_abs = if abs_path.exists() {
553            dunce::canonicalize(&abs_path)
554                .map(|p| normalize_path(&p))
555                .unwrap_or_else(|_| abs_path.clone())
556        } else {
557            abs_path.clone()
558        };
559
560        let norm_root = dunce::canonicalize(&self.canonical_root)
561            .map(|p| normalize_path(&p))
562            .unwrap_or_else(|_| self.canonical_root.clone());
563
564        // Check if within canonical root (trying multiple normalization variants with case-insensitivity on Windows)
565        let rel = match strip_prefix_lossy(&effective_abs, &norm_root)
566            .or_else(|| strip_prefix_lossy(&effective_abs, &self.canonical_root))
567            .or_else(|| {
568                // Only fall back to uncanonicalized abs_path if the file does not exist yet (e.g. filters or uncreated files)
569                if !abs_path.exists() {
570                    strip_prefix_lossy(&abs_path, &norm_root)
571                        .or_else(|| strip_prefix_lossy(&abs_path, &self.canonical_root))
572                } else {
573                    None
574                }
575            }) {
576            Some(r) => {
577                let forward = to_forward_slash(r);
578                if forward.starts_with("../") || forward == ".." {
579                    return Err(WorkspaceError::PathOutsideWorkspace(
580                        abs_path,
581                        self.canonical_root.clone(),
582                    ));
583                }
584                forward
585            }
586            None => {
587                return Err(WorkspaceError::PathOutsideWorkspace(
588                    abs_path,
589                    self.canonical_root.clone(),
590                ));
591            }
592        };
593
594        Ok((effective_abs, rel))
595    }
596
597    /// Relativizes a path filter string (which may be absolute, file:// URI, or relative)
598    /// against this workspace root into a forward-slash relative path suitable for SQLite queries.
599    pub fn relativize_filter(&self, filter: &str) -> String {
600        let trimmed = filter.trim();
601        if trimmed.is_empty() {
602            return String::new();
603        }
604
605        // Handle file:// URI
606        let path_str = if trimmed.starts_with("file://") {
607            parse_file_uri(trimmed)
608                .map(|p| p.to_string_lossy().to_string())
609                .unwrap_or_else(|| trimmed.to_string())
610        } else {
611            trimmed.to_string()
612        };
613
614        let raw_path = Path::new(&path_str);
615        let simplified = dunce::simplified(raw_path);
616
617        if simplified.is_absolute() {
618            if let Ok((_, rel)) = self.resolve_path(simplified) {
619                return rel;
620            }
621            // If resolve_path failed (e.g. non-existent path), try prefix stripping on normalized strings
622            let norm_simplified = normalize_path(simplified);
623            let norm_root = normalize_path(&self.canonical_root);
624            if let Some(rel) = strip_prefix_lossy(&norm_simplified, &norm_root)
625                .or_else(|| strip_prefix_lossy(&norm_simplified, &self.root))
626            {
627                let forward = to_forward_slash(rel);
628                if !forward.starts_with("../") && forward != ".." {
629                    return forward.trim_matches('/').to_string();
630                }
631            }
632        }
633
634        // Relative path: pass through clean_path to collapse `.` and `..`, normalize slashes, and trim leading ./ or /
635        let cleaned = clean_path(Path::new(&path_str));
636        let forward = to_forward_slash(&cleaned);
637        let trimmed = forward.trim_start_matches("./").trim_matches('/');
638        if trimmed == "." {
639            String::new()
640        } else {
641            trimmed.to_string()
642        }
643    }
644
645    /// Resolve candidate database paths for this workspace:
646    /// 1. Explicit override path (if provided)
647    /// 2. In-tree `.code-kb/artifact.db` or `.code-kb/store.db`
648    pub fn candidate_db_paths(&self, explicit_db: Option<&Path>) -> Vec<PathBuf> {
649        let mut candidates = Vec::new();
650
651        if let Some(p) = explicit_db {
652            candidates.push(normalize_path(p));
653        }
654
655        // In-tree options
656        candidates.push(normalize_path(
657            &self.canonical_root.join(".code-kb").join("artifact.db"),
658        ));
659        candidates.push(normalize_path(
660            &self.canonical_root.join(".code-kb").join("store.db"),
661        ));
662        candidates.push(normalize_path(&self.canonical_root.join("artifact.db")));
663
664        candidates
665    }
666
667    /// Finds the first existing database file, or returns the default target location.
668    pub fn locate_db(&self, explicit_db: Option<&Path>) -> Result<PathBuf, WorkspaceError> {
669        if let Some(p) = explicit_db {
670            return Ok(normalize_path(p));
671        }
672
673        let candidates = self.candidate_db_paths(None);
674        for candidate in &candidates {
675            if candidate.exists() && candidate.is_file() {
676                return Ok(normalize_path(candidate));
677            }
678        }
679
680        Ok(normalize_path(
681            &self.canonical_root.join(".code-kb").join("artifact.db"),
682        ))
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689
690    #[test]
691    #[cfg(windows)]
692    fn test_normalize_path() {
693        let p = PathBuf::from(r"\\?\C:\source\code-kb\src\main.rs");
694        let norm = normalize_path(&p);
695        assert!(!norm.to_string_lossy().starts_with(r"\\?\"));
696    }
697
698    #[test]
699    fn test_find_workspace_root_ignores_ancestor_code_kb_without_index() {
700        let temp = crate::safe_tempdir();
701        let home = temp.path();
702        std::fs::create_dir_all(home.join(".code-kb")).unwrap();
703        std::fs::write(home.join(".code-kb").join("telemetry.db"), b"").unwrap();
704        let project = home.join("project");
705        std::fs::create_dir_all(&project).unwrap();
706        std::fs::write(project.join("Cargo.toml"), "[package]\n").unwrap();
707
708        let root = Workspace::find_workspace_root(&project).unwrap();
709
710        assert!(paths_equal(&root, &project), "{}", root.display());
711    }
712
713    #[test]
714    fn test_find_workspace_root_uses_ancestor_index() {
715        let temp = crate::safe_tempdir();
716        let repo = temp.path().join("repo");
717        std::fs::create_dir_all(repo.join(".code-kb")).unwrap();
718        std::fs::write(repo.join(".code-kb").join("artifact.db"), b"").unwrap();
719        let nested = repo.join("src").join("deep");
720        std::fs::create_dir_all(&nested).unwrap();
721
722        let root = Workspace::find_workspace_root(&nested).unwrap();
723
724        assert!(paths_equal(&root, &repo), "{}", root.display());
725    }
726
727    #[test]
728    fn test_to_forward_slash() {
729        let p = PathBuf::from(r"src\models\mod.rs");
730        assert_eq!(to_forward_slash(&p), "src/models/mod.rs");
731    }
732
733    #[test]
734    fn test_workspace_resolve_path() {
735        let ws = Workspace::new(PathBuf::from("C:/source/test-project"));
736        let (abs, rel) = ws.resolve_path(Path::new("src/lib.rs")).unwrap();
737        assert_eq!(rel, "src/lib.rs");
738        assert!(abs.to_string_lossy().contains("test-project"));
739
740        #[cfg(windows)]
741        {
742            // Lowercase drive letter
743            let (_abs2, rel2) = ws
744                .resolve_path(Path::new("c:/source/test-project/src/lib.rs"))
745                .unwrap();
746            assert_eq!(rel2, "src/lib.rs");
747
748            // Case-insensitive directory on Windows
749            let (_abs3, rel3) = ws
750                .resolve_path(Path::new("C:/SOURCE/test-project/src/lib.rs"))
751                .unwrap();
752            assert_eq!(rel3, "src/lib.rs");
753
754            // file:// URI
755            let (_abs4, rel4) = ws
756                .resolve_path(Path::new("file:///C:/source/test-project/src/lib.rs"))
757                .unwrap();
758            assert_eq!(rel4, "src/lib.rs");
759
760            // file:// URI with lowercase drive letter
761            let (_abs5, rel5) = ws
762                .resolve_path(Path::new("file:///c:/source/test-project/src/lib.rs"))
763                .unwrap();
764            assert_eq!(rel5, "src/lib.rs");
765        }
766    }
767
768    #[test]
769    fn test_workspace_resolve_path_traversal_escape() {
770        let temp = crate::safe_tempdir();
771        let ws = Workspace::new(temp.path().to_path_buf());
772        let res = ws.resolve_path(Path::new("sub/../../outside.rs"));
773        assert!(
774            matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
775            "Expected PathOutsideWorkspace error, but got: {:?}",
776            res
777        );
778    }
779
780    #[test]
781    fn test_parse_file_uri() {
782        #[cfg(windows)]
783        let (uri, expected) = ("file:///C:/my%20folder/project", "C:/my folder/project");
784        #[cfg(not(windows))]
785        let (uri, expected) = ("file:///tmp/my%20folder/project", "/tmp/my folder/project");
786
787        let p1 = parse_file_uri(uri).unwrap();
788        assert_eq!(p1, normalize_path(Path::new(expected)));
789
790        // Plain path fallback
791        let p2 = parse_file_uri("C:/direct/path").unwrap();
792        assert_eq!(p2, normalize_path(Path::new("C:/direct/path")));
793    }
794
795    #[test]
796    fn test_relativize_filter() {
797        let temp = crate::safe_tempdir();
798        let ws = Workspace::new(temp.path().to_path_buf());
799
800        // Relative path
801        assert_eq!(ws.relativize_filter("."), "");
802        assert_eq!(ws.relativize_filter("./"), "");
803        assert_eq!(ws.relativize_filter("src/models"), "src/models");
804        assert_eq!(ws.relativize_filter("./src/models/"), "src/models");
805        assert_eq!(
806            ws.relativize_filter(r"src\models\mod.rs"),
807            "src/models/mod.rs"
808        );
809
810        // Relative path with ..
811        assert_eq!(
812            ws.relativize_filter("src/../src/models/mod.rs"),
813            "src/models/mod.rs"
814        );
815
816        // Absolute path inside workspace
817        let abs_file = temp.path().join("src").join("lib.rs");
818        std::fs::create_dir_all(abs_file.parent().unwrap()).unwrap();
819        std::fs::write(&abs_file, "").unwrap();
820
821        assert_eq!(
822            ws.relativize_filter(&abs_file.to_string_lossy()),
823            "src/lib.rs"
824        );
825
826        // File URI
827        let uri = format!("file://{}", abs_file.to_string_lossy().replace('\\', "/"));
828        assert_eq!(ws.relativize_filter(&uri), "src/lib.rs");
829
830        #[cfg(windows)]
831        {
832            // Case-insensitive absolute path for existing file resolves to canonical disk casing
833            let upper_abs = abs_file.to_string_lossy().to_uppercase();
834            assert_eq!(ws.relativize_filter(&upper_abs), "src/lib.rs");
835
836            // File URI with alternate case
837            let uri_cased = format!(
838                "file:///{}",
839                abs_file.to_string_lossy().replace('\\', "/").to_lowercase()
840            );
841            assert_eq!(ws.relativize_filter(&uri_cased), "src/lib.rs");
842        }
843    }
844
845    #[test]
846    fn test_paths_equal() {
847        assert!(paths_equal(
848            Path::new("src/lib.rs"),
849            Path::new("src/lib.rs")
850        ));
851        assert!(!paths_equal(
852            Path::new("src/lib.rs"),
853            Path::new("src/main.rs")
854        ));
855
856        #[cfg(windows)]
857        {
858            // Case-insensitive drive letters and paths
859            assert!(paths_equal(
860                Path::new(r"C:\source\code-kb\src\lib.rs"),
861                Path::new(r"c:\source\code-kb\src\lib.rs")
862            ));
863            assert!(paths_equal(
864                Path::new(r"C:\source\code-kb\src\lib.rs"),
865                Path::new(r"c:\SOURCE\CODE-KB\SRC\LIB.RS")
866            ));
867            // Verbatim prefixes
868            assert!(paths_equal(
869                Path::new(r"\\?\C:\source\code-kb\src\lib.rs"),
870                Path::new(r"C:\source\code-kb\src\lib.rs")
871            ));
872            assert!(paths_equal(
873                Path::new(r"\\?\c:\source\code-kb\src\lib.rs"),
874                Path::new(r"C:\source\code-kb\src\lib.rs")
875            ));
876            // UNC paths
877            assert!(paths_equal(
878                Path::new(r"\\server\share\file"),
879                Path::new(r"\\SERVER\SHARE\file")
880            ));
881            assert!(paths_equal(
882                Path::new(r"\\server\share\file"),
883                Path::new(r"\\server\share\file")
884            ));
885            assert!(!paths_equal(
886                Path::new(r"\\server\share1\file"),
887                Path::new(r"\\server\share2\file")
888            ));
889        }
890    }
891
892    #[test]
893    fn test_strip_prefix_lossy() {
894        let base = Path::new("src");
895        assert_eq!(
896            strip_prefix_lossy(Path::new("src/lib.rs"), base),
897            Some(Path::new("lib.rs"))
898        );
899        assert_eq!(strip_prefix_lossy(Path::new("tests/foo.rs"), base), None);
900
901        #[cfg(windows)]
902        {
903            let base_win = Path::new(r"C:\source\code-kb");
904            // Standard path
905            assert_eq!(
906                strip_prefix_lossy(Path::new(r"C:\source\code-kb\src\lib.rs"), base_win),
907                Some(Path::new(r"src\lib.rs"))
908            );
909            // Disk prefix casing
910            assert_eq!(
911                strip_prefix_lossy(Path::new(r"c:\source\code-kb\src\lib.rs"), base_win),
912                Some(Path::new(r"src\lib.rs"))
913            );
914            assert_eq!(
915                strip_prefix_lossy(Path::new(r"c:\SOURCE\CODE-KB\src\lib.rs"), base_win),
916                Some(Path::new(r"src\lib.rs"))
917            );
918            // Verbatim prefixes
919            assert_eq!(
920                strip_prefix_lossy(Path::new(r"\\?\C:\source\code-kb\src\lib.rs"), base_win),
921                Some(Path::new(r"src\lib.rs"))
922            );
923            assert_eq!(
924                strip_prefix_lossy(Path::new(r"\\?\c:\source\code-kb\src\lib.rs"), base_win),
925                Some(Path::new(r"src\lib.rs"))
926            );
927            // Negative non-matching paths
928            assert_eq!(
929                strip_prefix_lossy(Path::new(r"C:\other\code-kb\src\lib.rs"), base_win),
930                None
931            );
932            assert_eq!(
933                strip_prefix_lossy(Path::new(r"D:\source\code-kb\src\lib.rs"), base_win),
934                None
935            );
936        }
937    }
938
939    #[test]
940    fn test_parse_file_uri_two_slash_and_percent() {
941        #[cfg(windows)]
942        {
943            let p1 = parse_file_uri("file://C:/my%20folder/lib.rs").unwrap();
944            assert_eq!(p1, normalize_path(Path::new("C:/my folder/lib.rs")));
945
946            let p2 = parse_file_uri("file://c:/my%20folder/lib.rs").unwrap();
947            assert_eq!(p2, normalize_path(Path::new("c:/my folder/lib.rs")));
948
949            let p3 = parse_file_uri("file:///C:/my%20folder/lib.rs").unwrap();
950            assert_eq!(p3, normalize_path(Path::new("C:/my folder/lib.rs")));
951        }
952        #[cfg(not(windows))]
953        {
954            let p1 = parse_file_uri("file:///my%20folder/lib.rs").unwrap();
955            assert_eq!(p1, normalize_path(Path::new("/my folder/lib.rs")));
956        }
957    }
958
959    #[test]
960    fn test_workspace_verbatim_root_and_db_cleanup() {
961        let temp = crate::safe_tempdir();
962        let verbatim_path = format!(r"\\?\{}", temp.path().display());
963        let ws = Workspace::new(PathBuf::from(&verbatim_path));
964        assert!(!ws.root.to_string_lossy().starts_with(r"\\?\"));
965        assert!(!ws.canonical_root.to_string_lossy().starts_with(r"\\?\"));
966
967        let explicit = PathBuf::from(format!(r"\\?\{}\test.db", temp.path().display()));
968        let located = ws.locate_db(Some(&explicit)).unwrap();
969        assert!(!located.to_string_lossy().starts_with(r"\\?\"));
970    }
971
972    #[test]
973    fn test_trim_trailing_slash_edge_cases() {
974        assert_eq!(trim_trailing_slash(Path::new("/")), PathBuf::from("/"));
975        assert_eq!(trim_trailing_slash(Path::new("///")), PathBuf::from("/"));
976        assert_eq!(
977            trim_trailing_slash(Path::new("/a/b/")),
978            PathBuf::from("/a/b")
979        );
980        assert_eq!(
981            trim_trailing_slash(Path::new("foo/bar/")),
982            PathBuf::from("foo/bar")
983        );
984
985        #[cfg(windows)]
986        {
987            assert_eq!(
988                trim_trailing_slash(Path::new("C:\\")),
989                PathBuf::from("C:\\")
990            );
991            assert_eq!(trim_trailing_slash(Path::new("C:/")), PathBuf::from("C:\\"));
992            assert_eq!(
993                trim_trailing_slash(Path::new("C://")),
994                PathBuf::from("C:\\")
995            );
996            assert_eq!(
997                trim_trailing_slash(Path::new("C:\\\\")),
998                PathBuf::from("C:\\")
999            );
1000            assert_eq!(
1001                trim_trailing_slash(Path::new("C:/foo/")),
1002                PathBuf::from("C:/foo")
1003            );
1004        }
1005    }
1006
1007    #[test]
1008    fn test_unicode_and_emoji_uri_safety() {
1009        // Must not panic on non-ASCII character boundaries
1010        let p1 = parse_file_uri("file:///a๐Ÿ˜€/x");
1011        assert!(p1.is_some());
1012
1013        let p2 = parse_file_uri("file:///c๐Ÿ˜€/x");
1014        assert!(p2.is_some());
1015
1016        let p3 = parse_file_uri("file:///localhost๐Ÿ˜€/x");
1017        assert!(p3.is_some());
1018
1019        let p4 = parse_file_uri("file://C:/๐Ÿ˜€๐Ÿ˜€/main.rs");
1020        assert!(p4.is_some());
1021    }
1022
1023    #[test]
1024    #[cfg(unix)]
1025    fn test_escaping_symlink_rejected() {
1026        let ws_dir = crate::safe_tempdir();
1027        let ext_dir = crate::safe_tempdir();
1028
1029        let ext_file = ext_dir.path().join("secret.txt");
1030        std::fs::write(&ext_file, "secret").unwrap();
1031
1032        let symlink_path = ws_dir.path().join("link.txt");
1033        std::os::unix::fs::symlink(&ext_file, &symlink_path).unwrap();
1034        let ws = Workspace::new(ws_dir.path().to_path_buf());
1035        let res = ws.resolve_path(&symlink_path);
1036        assert!(
1037            matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
1038            "Expected PathOutsideWorkspace, got: {res:?}"
1039        );
1040    }
1041}