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