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/// Directory that holds the workspace's rolling `code-kb.log.*` files.
189pub fn log_dir(workspace_root: &Path) -> PathBuf {
190    workspace_root.join(".code-kb").join("logs")
191}
192
193/// The workspace's `code-kb.log.*` files, newest first by modification time. Other files in
194/// the log directory are ignored so a stray file never enters a bug report.
195pub fn log_files_newest_first(workspace_root: &Path) -> Vec<PathBuf> {
196    let mut files: Vec<(std::time::SystemTime, PathBuf)> =
197        std::fs::read_dir(log_dir(workspace_root))
198            .into_iter()
199            .flatten()
200            .flatten()
201            .filter(|entry| {
202                entry
203                    .file_name()
204                    .to_string_lossy()
205                    .starts_with("code-kb.log")
206            })
207            .filter(|entry| entry.path().is_file())
208            .filter_map(|entry| {
209                let modified = entry.metadata().ok()?.modified().ok()?;
210                Some((modified, entry.path()))
211            })
212            .collect();
213    files.sort_by(|a, b| b.cmp(a));
214    files.into_iter().map(|(_, path)| path).collect()
215}
216
217/// Most recently modified `code-kb.log.*` file, if any.
218pub fn latest_log_file(workspace_root: &Path) -> Option<PathBuf> {
219    log_files_newest_first(workspace_root).into_iter().next()
220}
221
222/// Strip Windows verbatim prefix (\\?\, \\?\UNC\) using dunce.
223pub fn normalize_path(path: &Path) -> PathBuf {
224    let s = path.to_string_lossy();
225    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
226        let unc = format!(r"\\{rest}");
227        return dunce::simplified(Path::new(&unc)).to_path_buf();
228    }
229    if let Some(rest) = s.strip_prefix(r"\\?\") {
230        return dunce::simplified(Path::new(rest)).to_path_buf();
231    }
232    dunce::simplified(path).to_path_buf()
233}
234
235/// Convert a path to forward-slash string representation for stable relative paths.
236pub fn to_forward_slash(path: &Path) -> String {
237    let s = path.to_string_lossy();
238    s.replace('\\', "/")
239}
240
241/// Compare two path components for equality.
242/// On Windows, compares `Component::Normal` case-insensitively and drive letters in `Component::Prefix` case-insensitively.
243#[cfg(windows)]
244fn components_equal(c1: &std::path::Component, c2: &std::path::Component) -> bool {
245    if c1 == c2 {
246        return true;
247    }
248    {
249        use std::path::Component;
250        match (c1, c2) {
251            (Component::Normal(s1), Component::Normal(s2)) => s1
252                .to_string_lossy()
253                .eq_ignore_ascii_case(&s2.to_string_lossy()),
254            (Component::Prefix(p1), Component::Prefix(p2)) => {
255                use std::path::Prefix;
256                match (p1.kind(), p2.kind()) {
257                    (Prefix::Disk(d1), Prefix::Disk(d2))
258                    | (Prefix::VerbatimDisk(d1), Prefix::VerbatimDisk(d2))
259                    | (Prefix::Disk(d1), Prefix::VerbatimDisk(d2))
260                    | (Prefix::VerbatimDisk(d1), Prefix::Disk(d2)) => d1.eq_ignore_ascii_case(&d2),
261                    (Prefix::UNC(s1, sh1), Prefix::UNC(s2, sh2))
262                    | (Prefix::VerbatimUNC(s1, sh1), Prefix::VerbatimUNC(s2, sh2))
263                    | (Prefix::UNC(s1, sh1), Prefix::VerbatimUNC(s2, sh2))
264                    | (Prefix::VerbatimUNC(s1, sh1), Prefix::UNC(s2, sh2)) => {
265                        s1.to_string_lossy()
266                            .eq_ignore_ascii_case(&s2.to_string_lossy())
267                            && sh1
268                                .to_string_lossy()
269                                .eq_ignore_ascii_case(&sh2.to_string_lossy())
270                    }
271                    (Prefix::DeviceNS(d1), Prefix::DeviceNS(d2))
272                    | (Prefix::Verbatim(d1), Prefix::Verbatim(d2)) => d1
273                        .to_string_lossy()
274                        .eq_ignore_ascii_case(&d2.to_string_lossy()),
275                    _ => false,
276                }
277            }
278            _ => false,
279        }
280    }
281}
282
283/// Strips `base` from `path`. On Windows, if standard `strip_prefix` fails,
284/// performs case-insensitive component comparison to support Windows case-preserving filesystems.
285pub fn strip_prefix_lossy<'a>(path: &'a Path, base: &Path) -> Option<&'a Path> {
286    if let Ok(rel) = path.strip_prefix(base) {
287        return Some(rel);
288    }
289
290    #[cfg(windows)]
291    {
292        let mut path_comps = path.components();
293        for base_comp in base.components() {
294            let path_comp = path_comps.next()?;
295            if !components_equal(&base_comp, &path_comp) {
296                return None;
297            }
298        }
299        Some(path_comps.as_path())
300    }
301    #[cfg(not(windows))]
302    {
303        None
304    }
305}
306
307/// Compare two paths for logical identity.
308/// On Windows, normalizes verbatim prefixes via dunce and compares disk prefixes and components case-insensitively.
309/// On non-Windows, compares paths directly.
310pub fn paths_equal(p1: &Path, p2: &Path) -> bool {
311    let p1_norm = normalize_path(p1);
312    let p2_norm = normalize_path(p2);
313    if p1_norm == p2_norm {
314        return true;
315    }
316    if to_forward_slash(&p1_norm) == to_forward_slash(&p2_norm) {
317        return true;
318    }
319    if let (Ok(c1), Ok(c2)) = (dunce::canonicalize(p1), dunce::canonicalize(p2)) {
320        let c1_norm = normalize_path(&c1);
321        let c2_norm = normalize_path(&c2);
322        if c1_norm == c2_norm || to_forward_slash(&c1_norm) == to_forward_slash(&c2_norm) {
323            return true;
324        }
325    }
326    #[cfg(windows)]
327    {
328        let mut c1 = p1_norm.components();
329        let mut c2 = p2_norm.components();
330        loop {
331            match (c1.next(), c2.next()) {
332                (None, None) => return true,
333                (Some(comp1), Some(comp2)) => {
334                    if !components_equal(&comp1, &comp2) {
335                        return false;
336                    }
337                }
338                _ => return false,
339            }
340        }
341    }
342    #[cfg(not(windows))]
343    {
344        false
345    }
346}
347
348/// Check if a relative path contains directories or file patterns that must never be indexed or watched.
349pub fn is_hard_excluded(rel_path: &str) -> bool {
350    let p = rel_path.replace('\\', "/");
351    let has_excluded_dir = p.split('/').any(|component| {
352        matches!(
353            component,
354            ".git"
355                | ".hg"
356                | ".svn"
357                | ".julie"
358                | ".code-kb"
359                | ".memories"
360                | ".agents"
361                | ".razorback"
362                | ".worktrees"
363                | "worktrees"
364                | ".claude"
365                | ".venv"
366                | "venv"
367                | ".env"
368                | ".tox"
369                | ".vs"
370                | "node_modules"
371                | "vendor"
372                | "target"
373                | "dist"
374                | "build"
375                | ".cache"
376                | "obj"
377                | "TestResults"
378                | ".idea"
379                | ".vscode"
380        )
381    });
382
383    if has_excluded_dir {
384        return true;
385    }
386
387    const EXCLUDED_SUFFIXES: &[&str] = &[
388        ".min.js",
389        ".bundle.js",
390        ".generated.js",
391        ".generated.jsx",
392        ".generated.ts",
393        ".generated.tsx",
394        ".generated.d.ts",
395        ".tmp",
396        ".swp",
397        "~",
398    ];
399
400    EXCLUDED_SUFFIXES.iter().any(|suffix| p.ends_with(suffix))
401}
402
403/// Represents a bound workspace session.
404#[derive(Debug, Clone)]
405pub struct Workspace {
406    pub root: PathBuf,
407    pub canonical_root: PathBuf,
408    pub repo_name: String,
409}
410
411fn trim_trailing_slash(p: &Path) -> PathBuf {
412    let s = p.to_string_lossy();
413    if s.len() > 1 && (s.ends_with('/') || s.ends_with('\\')) {
414        let trimmed = s.trim_end_matches(['/', '\\']);
415        if trimmed.is_empty() {
416            return PathBuf::from(if cfg!(windows) && s.starts_with('\\') {
417                "\\"
418            } else {
419                "/"
420            });
421        }
422        if cfg!(windows)
423            && trimmed.len() == 2
424            && trimmed.as_bytes()[0].is_ascii_alphabetic()
425            && trimmed.as_bytes()[1] == b':'
426        {
427            return PathBuf::from(format!("{}\\", trimmed));
428        }
429        return PathBuf::from(trimmed);
430    }
431    p.to_path_buf()
432}
433
434/// True when `root` carries a repository or language project marker.
435pub fn is_project_root(root: &Path) -> bool {
436    [
437        ".git",
438        "Cargo.toml",
439        "package.json",
440        "go.mod",
441        "pyproject.toml",
442    ]
443    .iter()
444    .any(|marker| root.join(marker).exists())
445}
446
447impl Workspace {
448    /// Discover and bind a workspace from an optional path, falling back to CWD and upward traversal.
449    pub fn discover(start_path: Option<&Path>) -> Result<Self, WorkspaceError> {
450        let current = match start_path {
451            Some(p) => p.to_path_buf(),
452            None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
453        };
454
455        let root = Self::find_workspace_root(&current)?;
456        Ok(Self::new(root))
457    }
458
459    /// Create workspace binding directly for a known root directory.
460    pub fn new(root: PathBuf) -> Self {
461        let root_str = root.to_string_lossy();
462        let root = if root_str.starts_with("file://") {
463            parse_file_uri(&root_str).unwrap_or_else(|| normalize_path(&root))
464        } else {
465            normalize_path(&root)
466        };
467        let root = trim_trailing_slash(&root);
468        let canonical_root =
469            normalize_path(&dunce::canonicalize(&root).unwrap_or_else(|_| root.clone()));
470        let repo_name = canonical_root
471            .file_name()
472            .map(|n| n.to_string_lossy().to_string())
473            .unwrap_or_else(|| "repo".to_string());
474
475        Self {
476            root,
477            canonical_root,
478            repo_name,
479        }
480    }
481
482    /// Find root by searching upwards for .git, .code-kb, or workspace markers.
483    pub fn find_workspace_root(start: &Path) -> Result<PathBuf, WorkspaceError> {
484        let raw = start.to_string_lossy();
485        let parsed = if raw.starts_with("file://") {
486            parse_file_uri(&raw).unwrap_or_else(|| start.to_path_buf())
487        } else {
488            start.to_path_buf()
489        };
490        let parsed = trim_trailing_slash(&parsed);
491        let curr = if parsed.is_file() {
492            parsed.parent().unwrap_or(&parsed).to_path_buf()
493        } else {
494            parsed.clone()
495        };
496
497        // Pass 1: Look for .git or an existing index all the way up. A bare `.code-kb`
498        // directory is not a marker: `~/.code-kb` holds telemetry and plugin downloads.
499        let mut probe = curr.clone();
500        loop {
501            if probe.join(".code-kb").join("artifact.db").exists() || probe.join(".git").exists() {
502                let canon = dunce::canonicalize(&probe).unwrap_or(probe);
503                return Ok(normalize_path(&canon));
504            }
505            if let Some(name) = probe.file_name().and_then(|n| n.to_str())
506                && is_hard_excluded(name)
507            {
508                break;
509            }
510            if let Some(parent) = probe.parent() {
511                if parent == probe {
512                    break;
513                }
514                probe = parent.to_path_buf();
515            } else {
516                break;
517            }
518        }
519
520        // Pass 2: Look for language project markers
521        let mut curr_marker = curr.clone();
522        loop {
523            if curr_marker.join("Cargo.toml").exists()
524                || curr_marker.join("package.json").exists()
525                || curr_marker.join("go.mod").exists()
526                || curr_marker.join("pyproject.toml").exists()
527            {
528                let canon = dunce::canonicalize(&curr_marker).unwrap_or(curr_marker);
529                return Ok(normalize_path(&canon));
530            }
531            if let Some(name) = curr_marker.file_name().and_then(|n| n.to_str())
532                && is_hard_excluded(name)
533            {
534                break;
535            }
536
537            if let Some(parent) = curr_marker.parent() {
538                if parent == curr_marker {
539                    break;
540                }
541                curr_marker = parent.to_path_buf();
542            } else {
543                break;
544            }
545        }
546
547        // Default to start directory if no markers found
548        let start_dir = if parsed.is_file() {
549            parsed.parent().unwrap_or(&parsed).to_path_buf()
550        } else {
551            parsed
552        };
553        let canon = dunce::canonicalize(&start_dir).unwrap_or(start_dir);
554        Ok(normalize_path(&canon))
555    }
556
557    /// Resolves an input path (relative, absolute, or file:// URI) to a canonical absolute path and relative path.
558    pub fn resolve_path(&self, input: &Path) -> Result<(PathBuf, String), WorkspaceError> {
559        let raw_str = input.to_string_lossy();
560        let path = if raw_str.starts_with("file://") {
561            parse_file_uri(&raw_str).unwrap_or_else(|| input.to_path_buf())
562        } else {
563            input.to_path_buf()
564        };
565        let path = normalize_path(&path);
566
567        let is_abs = path.is_absolute()
568            || (cfg!(windows) && (path.to_string_lossy().chars().nth(1) == Some(':')));
569
570        let joined = if is_abs {
571            path
572        } else {
573            let rel_str = if cfg!(not(windows)) && path.to_string_lossy().contains('\\') {
574                path.to_string_lossy().replace('\\', "/")
575            } else {
576                path.to_string_lossy().to_string()
577            };
578            self.canonical_root.join(Path::new(&rel_str))
579        };
580
581        // Lexically clean the path to collapse `.` and `..` components
582        let cleaned = clean_path(&joined);
583        let abs_path = normalize_path(&cleaned);
584
585        // If file exists, canonicalize to resolve any symlinks
586        let effective_abs = if abs_path.exists() {
587            dunce::canonicalize(&abs_path)
588                .map(|p| normalize_path(&p))
589                .unwrap_or_else(|_| abs_path.clone())
590        } else {
591            abs_path.clone()
592        };
593
594        let norm_root = dunce::canonicalize(&self.canonical_root)
595            .map(|p| normalize_path(&p))
596            .unwrap_or_else(|_| self.canonical_root.clone());
597
598        // Check if within canonical root (trying multiple normalization variants with case-insensitivity on Windows)
599        let rel = match strip_prefix_lossy(&effective_abs, &norm_root)
600            .or_else(|| strip_prefix_lossy(&effective_abs, &self.canonical_root))
601            .or_else(|| {
602                // Only fall back to uncanonicalized abs_path if the file does not exist yet (e.g. filters or uncreated files)
603                if !abs_path.exists() {
604                    strip_prefix_lossy(&abs_path, &norm_root)
605                        .or_else(|| strip_prefix_lossy(&abs_path, &self.canonical_root))
606                } else {
607                    None
608                }
609            }) {
610            Some(r) => {
611                let forward = to_forward_slash(r);
612                if forward.starts_with("../") || forward == ".." {
613                    return Err(WorkspaceError::PathOutsideWorkspace(
614                        abs_path,
615                        self.canonical_root.clone(),
616                    ));
617                }
618                forward
619            }
620            None => {
621                return Err(WorkspaceError::PathOutsideWorkspace(
622                    abs_path,
623                    self.canonical_root.clone(),
624                ));
625            }
626        };
627
628        Ok((effective_abs, rel))
629    }
630
631    /// Relativizes a path filter string (which may be absolute, file:// URI, or relative)
632    /// against this workspace root into a forward-slash relative path suitable for SQLite queries.
633    pub fn relativize_filter(&self, filter: &str) -> String {
634        let trimmed = filter.trim();
635        if trimmed.is_empty() {
636            return String::new();
637        }
638
639        // Handle file:// URI
640        let path_str = if trimmed.starts_with("file://") {
641            parse_file_uri(trimmed)
642                .map(|p| p.to_string_lossy().to_string())
643                .unwrap_or_else(|| trimmed.to_string())
644        } else {
645            trimmed.to_string()
646        };
647
648        let raw_path = Path::new(&path_str);
649        let simplified = dunce::simplified(raw_path);
650
651        if simplified.is_absolute() {
652            if let Ok((_, rel)) = self.resolve_path(simplified) {
653                return rel;
654            }
655            // If resolve_path failed (e.g. non-existent path), try prefix stripping on normalized strings
656            let norm_simplified = normalize_path(simplified);
657            let norm_root = normalize_path(&self.canonical_root);
658            if let Some(rel) = strip_prefix_lossy(&norm_simplified, &norm_root)
659                .or_else(|| strip_prefix_lossy(&norm_simplified, &self.root))
660            {
661                let forward = to_forward_slash(rel);
662                if !forward.starts_with("../") && forward != ".." {
663                    return forward.trim_matches('/').to_string();
664                }
665            }
666        }
667
668        // Relative path: pass through clean_path to collapse `.` and `..`, normalize slashes, and trim leading ./ or /
669        let cleaned = clean_path(Path::new(&path_str));
670        let forward = to_forward_slash(&cleaned);
671        let trimmed = forward.trim_start_matches("./").trim_matches('/');
672        if trimmed == "." {
673            String::new()
674        } else {
675            trimmed.to_string()
676        }
677    }
678
679    /// Resolve candidate database paths for this workspace:
680    /// 1. Explicit override path (if provided)
681    /// 2. In-tree `.code-kb/artifact.db` or `.code-kb/store.db`
682    pub fn candidate_db_paths(&self, explicit_db: Option<&Path>) -> Vec<PathBuf> {
683        let mut candidates = Vec::new();
684
685        if let Some(p) = explicit_db {
686            candidates.push(normalize_path(p));
687        }
688
689        // In-tree options
690        candidates.push(normalize_path(
691            &self.canonical_root.join(".code-kb").join("artifact.db"),
692        ));
693        candidates.push(normalize_path(
694            &self.canonical_root.join(".code-kb").join("store.db"),
695        ));
696        candidates.push(normalize_path(&self.canonical_root.join("artifact.db")));
697
698        candidates
699    }
700
701    /// Finds the first existing database file, or returns the default target location.
702    pub fn locate_db(&self, explicit_db: Option<&Path>) -> Result<PathBuf, WorkspaceError> {
703        if let Some(p) = explicit_db {
704            return Ok(normalize_path(p));
705        }
706
707        let candidates = self.candidate_db_paths(None);
708        for candidate in &candidates {
709            if candidate.exists() && candidate.is_file() {
710                return Ok(normalize_path(candidate));
711            }
712        }
713
714        Ok(normalize_path(
715            &self.canonical_root.join(".code-kb").join("artifact.db"),
716        ))
717    }
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    #[test]
725    #[cfg(windows)]
726    fn test_normalize_path() {
727        let p = PathBuf::from(r"\\?\C:\source\code-kb\src\main.rs");
728        let norm = normalize_path(&p);
729        assert!(!norm.to_string_lossy().starts_with(r"\\?\"));
730    }
731
732    #[test]
733    fn test_find_workspace_root_ignores_ancestor_code_kb_without_index() {
734        let temp = crate::safe_tempdir();
735        let home = temp.path();
736        std::fs::create_dir_all(home.join(".code-kb")).unwrap();
737        std::fs::write(home.join(".code-kb").join("telemetry.db"), b"").unwrap();
738        let project = home.join("project");
739        std::fs::create_dir_all(&project).unwrap();
740        std::fs::write(project.join("Cargo.toml"), "[package]\n").unwrap();
741
742        let root = Workspace::find_workspace_root(&project).unwrap();
743
744        assert!(paths_equal(&root, &project), "{}", root.display());
745    }
746
747    #[test]
748    fn test_find_workspace_root_uses_ancestor_index() {
749        let temp = crate::safe_tempdir();
750        let repo = temp.path().join("repo");
751        std::fs::create_dir_all(repo.join(".code-kb")).unwrap();
752        std::fs::write(repo.join(".code-kb").join("artifact.db"), b"").unwrap();
753        let nested = repo.join("src").join("deep");
754        std::fs::create_dir_all(&nested).unwrap();
755
756        let root = Workspace::find_workspace_root(&nested).unwrap();
757
758        assert!(paths_equal(&root, &repo), "{}", root.display());
759    }
760
761    #[test]
762    fn test_to_forward_slash() {
763        let p = PathBuf::from(r"src\models\mod.rs");
764        assert_eq!(to_forward_slash(&p), "src/models/mod.rs");
765    }
766
767    #[test]
768    fn test_workspace_resolve_path() {
769        let ws = Workspace::new(PathBuf::from("C:/source/test-project"));
770        let (abs, rel) = ws.resolve_path(Path::new("src/lib.rs")).unwrap();
771        assert_eq!(rel, "src/lib.rs");
772        assert!(abs.to_string_lossy().contains("test-project"));
773
774        #[cfg(windows)]
775        {
776            // Lowercase drive letter
777            let (_abs2, rel2) = ws
778                .resolve_path(Path::new("c:/source/test-project/src/lib.rs"))
779                .unwrap();
780            assert_eq!(rel2, "src/lib.rs");
781
782            // Case-insensitive directory on Windows
783            let (_abs3, rel3) = ws
784                .resolve_path(Path::new("C:/SOURCE/test-project/src/lib.rs"))
785                .unwrap();
786            assert_eq!(rel3, "src/lib.rs");
787
788            // file:// URI
789            let (_abs4, rel4) = ws
790                .resolve_path(Path::new("file:///C:/source/test-project/src/lib.rs"))
791                .unwrap();
792            assert_eq!(rel4, "src/lib.rs");
793
794            // file:// URI with lowercase drive letter
795            let (_abs5, rel5) = ws
796                .resolve_path(Path::new("file:///c:/source/test-project/src/lib.rs"))
797                .unwrap();
798            assert_eq!(rel5, "src/lib.rs");
799        }
800    }
801
802    #[test]
803    fn test_workspace_resolve_path_traversal_escape() {
804        let temp = crate::safe_tempdir();
805        let ws = Workspace::new(temp.path().to_path_buf());
806        let res = ws.resolve_path(Path::new("sub/../../outside.rs"));
807        assert!(
808            matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
809            "Expected PathOutsideWorkspace error, but got: {:?}",
810            res
811        );
812    }
813
814    #[test]
815    fn test_parse_file_uri() {
816        #[cfg(windows)]
817        let (uri, expected) = ("file:///C:/my%20folder/project", "C:/my folder/project");
818        #[cfg(not(windows))]
819        let (uri, expected) = ("file:///tmp/my%20folder/project", "/tmp/my folder/project");
820
821        let p1 = parse_file_uri(uri).unwrap();
822        assert_eq!(p1, normalize_path(Path::new(expected)));
823
824        // Plain path fallback
825        let p2 = parse_file_uri("C:/direct/path").unwrap();
826        assert_eq!(p2, normalize_path(Path::new("C:/direct/path")));
827    }
828
829    #[test]
830    fn test_relativize_filter() {
831        let temp = crate::safe_tempdir();
832        let ws = Workspace::new(temp.path().to_path_buf());
833
834        // Relative path
835        assert_eq!(ws.relativize_filter("."), "");
836        assert_eq!(ws.relativize_filter("./"), "");
837        assert_eq!(ws.relativize_filter("src/models"), "src/models");
838        assert_eq!(ws.relativize_filter("./src/models/"), "src/models");
839        assert_eq!(
840            ws.relativize_filter(r"src\models\mod.rs"),
841            "src/models/mod.rs"
842        );
843
844        // Relative path with ..
845        assert_eq!(
846            ws.relativize_filter("src/../src/models/mod.rs"),
847            "src/models/mod.rs"
848        );
849
850        // Absolute path inside workspace
851        let abs_file = temp.path().join("src").join("lib.rs");
852        std::fs::create_dir_all(abs_file.parent().unwrap()).unwrap();
853        std::fs::write(&abs_file, "").unwrap();
854
855        assert_eq!(
856            ws.relativize_filter(&abs_file.to_string_lossy()),
857            "src/lib.rs"
858        );
859
860        // File URI
861        let uri = format!("file://{}", abs_file.to_string_lossy().replace('\\', "/"));
862        assert_eq!(ws.relativize_filter(&uri), "src/lib.rs");
863
864        #[cfg(windows)]
865        {
866            // Case-insensitive absolute path for existing file resolves to canonical disk casing
867            let upper_abs = abs_file.to_string_lossy().to_uppercase();
868            assert_eq!(ws.relativize_filter(&upper_abs), "src/lib.rs");
869
870            // File URI with alternate case
871            let uri_cased = format!(
872                "file:///{}",
873                abs_file.to_string_lossy().replace('\\', "/").to_lowercase()
874            );
875            assert_eq!(ws.relativize_filter(&uri_cased), "src/lib.rs");
876        }
877    }
878
879    #[test]
880    fn test_paths_equal() {
881        assert!(paths_equal(
882            Path::new("src/lib.rs"),
883            Path::new("src/lib.rs")
884        ));
885        assert!(!paths_equal(
886            Path::new("src/lib.rs"),
887            Path::new("src/main.rs")
888        ));
889
890        #[cfg(windows)]
891        {
892            // Case-insensitive drive letters and paths
893            assert!(paths_equal(
894                Path::new(r"C:\source\code-kb\src\lib.rs"),
895                Path::new(r"c:\source\code-kb\src\lib.rs")
896            ));
897            assert!(paths_equal(
898                Path::new(r"C:\source\code-kb\src\lib.rs"),
899                Path::new(r"c:\SOURCE\CODE-KB\SRC\LIB.RS")
900            ));
901            // Verbatim prefixes
902            assert!(paths_equal(
903                Path::new(r"\\?\C:\source\code-kb\src\lib.rs"),
904                Path::new(r"C:\source\code-kb\src\lib.rs")
905            ));
906            assert!(paths_equal(
907                Path::new(r"\\?\c:\source\code-kb\src\lib.rs"),
908                Path::new(r"C:\source\code-kb\src\lib.rs")
909            ));
910            // UNC paths
911            assert!(paths_equal(
912                Path::new(r"\\server\share\file"),
913                Path::new(r"\\SERVER\SHARE\file")
914            ));
915            assert!(paths_equal(
916                Path::new(r"\\server\share\file"),
917                Path::new(r"\\server\share\file")
918            ));
919            assert!(!paths_equal(
920                Path::new(r"\\server\share1\file"),
921                Path::new(r"\\server\share2\file")
922            ));
923        }
924    }
925
926    #[test]
927    fn test_strip_prefix_lossy() {
928        let base = Path::new("src");
929        assert_eq!(
930            strip_prefix_lossy(Path::new("src/lib.rs"), base),
931            Some(Path::new("lib.rs"))
932        );
933        assert_eq!(strip_prefix_lossy(Path::new("tests/foo.rs"), base), None);
934
935        #[cfg(windows)]
936        {
937            let base_win = Path::new(r"C:\source\code-kb");
938            // Standard path
939            assert_eq!(
940                strip_prefix_lossy(Path::new(r"C:\source\code-kb\src\lib.rs"), base_win),
941                Some(Path::new(r"src\lib.rs"))
942            );
943            // Disk prefix casing
944            assert_eq!(
945                strip_prefix_lossy(Path::new(r"c:\source\code-kb\src\lib.rs"), base_win),
946                Some(Path::new(r"src\lib.rs"))
947            );
948            assert_eq!(
949                strip_prefix_lossy(Path::new(r"c:\SOURCE\CODE-KB\src\lib.rs"), base_win),
950                Some(Path::new(r"src\lib.rs"))
951            );
952            // Verbatim prefixes
953            assert_eq!(
954                strip_prefix_lossy(Path::new(r"\\?\C:\source\code-kb\src\lib.rs"), base_win),
955                Some(Path::new(r"src\lib.rs"))
956            );
957            assert_eq!(
958                strip_prefix_lossy(Path::new(r"\\?\c:\source\code-kb\src\lib.rs"), base_win),
959                Some(Path::new(r"src\lib.rs"))
960            );
961            // Negative non-matching paths
962            assert_eq!(
963                strip_prefix_lossy(Path::new(r"C:\other\code-kb\src\lib.rs"), base_win),
964                None
965            );
966            assert_eq!(
967                strip_prefix_lossy(Path::new(r"D:\source\code-kb\src\lib.rs"), base_win),
968                None
969            );
970        }
971    }
972
973    #[test]
974    fn test_parse_file_uri_two_slash_and_percent() {
975        #[cfg(windows)]
976        {
977            let p1 = parse_file_uri("file://C:/my%20folder/lib.rs").unwrap();
978            assert_eq!(p1, normalize_path(Path::new("C:/my folder/lib.rs")));
979
980            let p2 = parse_file_uri("file://c:/my%20folder/lib.rs").unwrap();
981            assert_eq!(p2, normalize_path(Path::new("c:/my folder/lib.rs")));
982
983            let p3 = parse_file_uri("file:///C:/my%20folder/lib.rs").unwrap();
984            assert_eq!(p3, normalize_path(Path::new("C:/my folder/lib.rs")));
985        }
986        #[cfg(not(windows))]
987        {
988            let p1 = parse_file_uri("file:///my%20folder/lib.rs").unwrap();
989            assert_eq!(p1, normalize_path(Path::new("/my folder/lib.rs")));
990        }
991    }
992
993    #[test]
994    fn test_workspace_verbatim_root_and_db_cleanup() {
995        let temp = crate::safe_tempdir();
996        let verbatim_path = format!(r"\\?\{}", temp.path().display());
997        let ws = Workspace::new(PathBuf::from(&verbatim_path));
998        assert!(!ws.root.to_string_lossy().starts_with(r"\\?\"));
999        assert!(!ws.canonical_root.to_string_lossy().starts_with(r"\\?\"));
1000
1001        let explicit = PathBuf::from(format!(r"\\?\{}\test.db", temp.path().display()));
1002        let located = ws.locate_db(Some(&explicit)).unwrap();
1003        assert!(!located.to_string_lossy().starts_with(r"\\?\"));
1004    }
1005
1006    #[test]
1007    fn test_trim_trailing_slash_edge_cases() {
1008        assert_eq!(trim_trailing_slash(Path::new("/")), PathBuf::from("/"));
1009        assert_eq!(trim_trailing_slash(Path::new("///")), PathBuf::from("/"));
1010        assert_eq!(
1011            trim_trailing_slash(Path::new("/a/b/")),
1012            PathBuf::from("/a/b")
1013        );
1014        assert_eq!(
1015            trim_trailing_slash(Path::new("foo/bar/")),
1016            PathBuf::from("foo/bar")
1017        );
1018
1019        #[cfg(windows)]
1020        {
1021            assert_eq!(
1022                trim_trailing_slash(Path::new("C:\\")),
1023                PathBuf::from("C:\\")
1024            );
1025            assert_eq!(trim_trailing_slash(Path::new("C:/")), PathBuf::from("C:\\"));
1026            assert_eq!(
1027                trim_trailing_slash(Path::new("C://")),
1028                PathBuf::from("C:\\")
1029            );
1030            assert_eq!(
1031                trim_trailing_slash(Path::new("C:\\\\")),
1032                PathBuf::from("C:\\")
1033            );
1034            assert_eq!(
1035                trim_trailing_slash(Path::new("C:/foo/")),
1036                PathBuf::from("C:/foo")
1037            );
1038        }
1039    }
1040
1041    #[test]
1042    fn test_unicode_and_emoji_uri_safety() {
1043        // Must not panic on non-ASCII character boundaries
1044        let p1 = parse_file_uri("file:///a๐Ÿ˜€/x");
1045        assert!(p1.is_some());
1046
1047        let p2 = parse_file_uri("file:///c๐Ÿ˜€/x");
1048        assert!(p2.is_some());
1049
1050        let p3 = parse_file_uri("file:///localhost๐Ÿ˜€/x");
1051        assert!(p3.is_some());
1052
1053        let p4 = parse_file_uri("file://C:/๐Ÿ˜€๐Ÿ˜€/main.rs");
1054        assert!(p4.is_some());
1055    }
1056
1057    #[test]
1058    #[cfg(unix)]
1059    fn test_escaping_symlink_rejected() {
1060        let ws_dir = crate::safe_tempdir();
1061        let ext_dir = crate::safe_tempdir();
1062
1063        let ext_file = ext_dir.path().join("secret.txt");
1064        std::fs::write(&ext_file, "secret").unwrap();
1065
1066        let symlink_path = ws_dir.path().join("link.txt");
1067        std::os::unix::fs::symlink(&ext_file, &symlink_path).unwrap();
1068        let ws = Workspace::new(ws_dir.path().to_path_buf());
1069        let res = ws.resolve_path(&symlink_path);
1070        assert!(
1071            matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
1072            "Expected PathOutsideWorkspace, got: {res:?}"
1073        );
1074    }
1075}