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    #[error("project_root '{0}' is a relative path")]
19    RelativeProjectRoot(PathBuf),
20    #[error("project_root '{0}' does not exist")]
21    ProjectRootNotFound(PathBuf),
22    #[error("project_root '{path}' is refused: {reason}")]
23    ProjectRootRefused { path: PathBuf, reason: &'static str },
24}
25
26/// Lexically clean a path by collapsing `.` and `..` components.
27pub fn clean_path(path: &Path) -> PathBuf {
28    use std::path::Component;
29    let s = path.to_string_lossy();
30    let norm = if cfg!(not(windows)) && s.contains('\\') {
31        std::borrow::Cow::Owned(PathBuf::from(s.replace('\\', "/")))
32    } else {
33        std::borrow::Cow::Borrowed(path)
34    };
35    let mut stack = Vec::new();
36    for comp in norm.components() {
37        match comp {
38            Component::CurDir => {}
39            Component::ParentDir => {
40                if let Some(Component::Normal(_)) = stack.last() {
41                    stack.pop();
42                } else {
43                    stack.push(comp);
44                }
45            }
46            _ => stack.push(comp),
47        }
48    }
49    stack.into_iter().collect()
50}
51
52fn percent_decode(input: &str) -> String {
53    let mut bytes = Vec::with_capacity(input.len());
54    let input_bytes = input.as_bytes();
55    let mut i = 0;
56    while i < input_bytes.len() {
57        if input_bytes[i] == b'%'
58            && i + 2 < input_bytes.len()
59            && let Ok(hex) = std::str::from_utf8(&input_bytes[i + 1..i + 3])
60            && let Ok(byte) = u8::from_str_radix(hex, 16)
61        {
62            bytes.push(byte);
63            i += 3;
64            continue;
65        }
66        bytes.push(input_bytes[i]);
67        i += 1;
68    }
69    String::from_utf8_lossy(&bytes).into_owned()
70}
71
72/// Extract drive letter and remainder if the string begins with a drive specification
73/// delimited by ':', '|', or percent-encoded "%7C" / "%3A".
74fn extract_drive_letter_and_remainder(s: &str) -> Option<(char, &str)> {
75    let bytes = s.as_bytes();
76    if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() {
77        return None;
78    }
79    let drive = bytes[0] as char;
80
81    // Single-byte delimiters: ':' or '|'
82    if bytes.len() >= 2
83        && (bytes[1] == b':' || bytes[1] == b'|')
84        && (bytes.len() == 2
85            || bytes[2] == b'/'
86            || bytes[2] == b'\\'
87            || bytes[2] == b'?'
88            || bytes[2] == b'#')
89    {
90        return Some((drive, &s[2..]));
91    }
92
93    // Three-byte percent-encoded delimiters: "%7C", "%7c", "%3A", "%3a"
94    if bytes.len() >= 4 {
95        let delim = &bytes[1..4];
96        if (delim.eq_ignore_ascii_case(b"%7c") || delim.eq_ignore_ascii_case(b"%3a"))
97            && (bytes.len() == 4
98                || bytes[4] == b'/'
99                || bytes[4] == b'\\'
100                || bytes[4] == b'?'
101                || bytes[4] == b'#')
102        {
103            return Some((drive, &s[4..]));
104        }
105    }
106
107    None
108}
109
110/// Strip an optional "localhost/" or "localhost\" prefix (with or without a leading slash).
111fn strip_localhost_prefix(s: &str) -> &str {
112    let without_slash = s.strip_prefix('/').unwrap_or(s);
113    let bytes = without_slash.as_bytes();
114    if bytes.len() >= 10
115        && bytes[..9].eq_ignore_ascii_case(b"localhost")
116        && (bytes[9] == b'/' || bytes[9] == b'\\')
117    {
118        &without_slash[10..]
119    } else {
120        s
121    }
122}
123
124/// Convert a path string starting with a pipe drive specification (e.g. "C|/..." or "/C|/...")
125/// to use a standard colon ':' delimiter (e.g. "C:/...").
126fn normalize_drive_pipe_str(s: &str) -> String {
127    let clean = strip_localhost_prefix(s);
128    let target = clean.strip_prefix('/').unwrap_or(clean);
129    let target = strip_localhost_prefix(target);
130    if let Some((drive, remainder)) = extract_drive_letter_and_remainder(target) {
131        if remainder.is_empty() || remainder.starts_with('?') || remainder.starts_with('#') {
132            format!("{}:/{}", drive, remainder)
133        } else {
134            format!("{}:{}", drive, remainder)
135        }
136    } else {
137        s.to_string()
138    }
139}
140
141/// Parse an MCP file URI or plain path into a normalized PathBuf.
142/// Handles standard file URIs (`file:///path`), two-slash drive letter URIs (`file://C:/...`),
143/// pipe drive delimiters (`file:///C|/...`, `file://C|/...`), percent-encoding (`%20`, `%7C`), and plain paths.
144pub fn parse_file_uri(cand: &str) -> Option<PathBuf> {
145    if let Some(rest) = cand.strip_prefix("file://") {
146        let path_part = rest.strip_prefix('/').unwrap_or(rest);
147        let path_part = strip_localhost_prefix(path_part);
148        let normalized_cand = if let Some((drive, remainder)) =
149            extract_drive_letter_and_remainder(path_part)
150        {
151            if remainder.is_empty() || remainder.starts_with('?') || remainder.starts_with('#') {
152                format!("file:///{}:/{}", drive, remainder)
153            } else if remainder.starts_with('/') || remainder.starts_with('\\') {
154                format!("file:///{}:{}", drive, remainder)
155            } else {
156                format!("file:///{}:/{}", drive, remainder)
157            }
158        } else {
159            cand.to_string()
160        };
161
162        let file_path = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
163            url::Url::parse(&normalized_cand)
164                .ok()
165                .and_then(|url| url.to_file_path().ok())
166        }))
167        .ok()
168        .flatten();
169
170        if let Some(path) = file_path {
171            return Some(normalize_path(&path));
172        }
173        // Fallback for non-standard file:// patterns with percent decoding
174        if let Some(s) = cand.strip_prefix("file:///") {
175            let decoded = percent_decode(s);
176            let normalized = normalize_drive_pipe_str(&decoded);
177            if cfg!(windows) {
178                Some(normalize_path(Path::new(&normalized)))
179            } else {
180                Some(normalize_path(&PathBuf::from(format!("/{}", normalized))))
181            }
182        } else {
183            let s = cand.strip_prefix("file://").unwrap_or(cand);
184            let decoded = percent_decode(s);
185            let normalized = normalize_drive_pipe_str(&decoded);
186            Some(normalize_path(Path::new(&normalized)))
187        }
188    } else {
189        let normalized = normalize_drive_pipe_str(cand);
190        Some(normalize_path(Path::new(&normalized)))
191    }
192}
193
194/// Directory that holds the workspace's rolling `code-kb.log.*` files.
195pub fn log_dir(workspace_root: &Path) -> PathBuf {
196    workspace_root.join(".code-kb").join("logs")
197}
198
199/// The `code-kb.log.*` files in `log_dir`, newest first by modification time. Other files in
200/// the log directory are ignored so a stray file never enters a bug report.
201pub fn log_files_newest_first(log_dir: &Path) -> Vec<PathBuf> {
202    let mut files: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(log_dir)
203        .into_iter()
204        .flatten()
205        .flatten()
206        .filter(|entry| {
207            entry
208                .file_name()
209                .to_string_lossy()
210                .starts_with("code-kb.log")
211        })
212        .filter(|entry| entry.path().is_file())
213        .filter_map(|entry| {
214            let modified = entry.metadata().ok()?.modified().ok()?;
215            Some((modified, entry.path()))
216        })
217        .collect();
218    files.sort_by(|a, b| b.cmp(a));
219    files.into_iter().map(|(_, path)| path).collect()
220}
221
222/// Most recently modified `code-kb.log.*` file, if any.
223pub fn latest_log_file(log_dir: &Path) -> Option<PathBuf> {
224    log_files_newest_first(log_dir).into_iter().next()
225}
226
227/// Strip Windows verbatim prefix (\\?\, \\?\UNC\) using dunce.
228pub fn normalize_path(path: &Path) -> PathBuf {
229    let s = path.to_string_lossy();
230    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
231        let unc = format!(r"\\{rest}");
232        return dunce::simplified(Path::new(&unc)).to_path_buf();
233    }
234    if let Some(rest) = s.strip_prefix(r"\\?\") {
235        return dunce::simplified(Path::new(rest)).to_path_buf();
236    }
237    dunce::simplified(path).to_path_buf()
238}
239
240/// Convert a path to forward-slash string representation for stable relative paths.
241pub fn to_forward_slash(path: &Path) -> String {
242    let s = path.to_string_lossy();
243    s.replace('\\', "/")
244}
245
246/// Compare two path components for equality.
247/// On Windows, compares `Component::Normal` case-insensitively and drive letters in `Component::Prefix` case-insensitively.
248#[cfg(windows)]
249fn components_equal(c1: &std::path::Component, c2: &std::path::Component) -> bool {
250    if c1 == c2 {
251        return true;
252    }
253    {
254        use std::path::Component;
255        match (c1, c2) {
256            (Component::Normal(s1), Component::Normal(s2)) => s1
257                .to_string_lossy()
258                .eq_ignore_ascii_case(&s2.to_string_lossy()),
259            (Component::Prefix(p1), Component::Prefix(p2)) => {
260                use std::path::Prefix;
261                match (p1.kind(), p2.kind()) {
262                    (Prefix::Disk(d1), Prefix::Disk(d2))
263                    | (Prefix::VerbatimDisk(d1), Prefix::VerbatimDisk(d2))
264                    | (Prefix::Disk(d1), Prefix::VerbatimDisk(d2))
265                    | (Prefix::VerbatimDisk(d1), Prefix::Disk(d2)) => d1.eq_ignore_ascii_case(&d2),
266                    (Prefix::UNC(s1, sh1), Prefix::UNC(s2, sh2))
267                    | (Prefix::VerbatimUNC(s1, sh1), Prefix::VerbatimUNC(s2, sh2))
268                    | (Prefix::UNC(s1, sh1), Prefix::VerbatimUNC(s2, sh2))
269                    | (Prefix::VerbatimUNC(s1, sh1), Prefix::UNC(s2, sh2)) => {
270                        s1.to_string_lossy()
271                            .eq_ignore_ascii_case(&s2.to_string_lossy())
272                            && sh1
273                                .to_string_lossy()
274                                .eq_ignore_ascii_case(&sh2.to_string_lossy())
275                    }
276                    (Prefix::DeviceNS(d1), Prefix::DeviceNS(d2))
277                    | (Prefix::Verbatim(d1), Prefix::Verbatim(d2)) => d1
278                        .to_string_lossy()
279                        .eq_ignore_ascii_case(&d2.to_string_lossy()),
280                    _ => false,
281                }
282            }
283            _ => false,
284        }
285    }
286}
287
288/// Strips `base` from `path`. On Windows, if standard `strip_prefix` fails,
289/// performs case-insensitive component comparison to support Windows case-preserving filesystems.
290pub fn strip_prefix_lossy<'a>(path: &'a Path, base: &Path) -> Option<&'a Path> {
291    if let Ok(rel) = path.strip_prefix(base) {
292        return Some(rel);
293    }
294
295    #[cfg(windows)]
296    {
297        let mut path_comps = path.components();
298        for base_comp in base.components() {
299            let path_comp = path_comps.next()?;
300            if !components_equal(&base_comp, &path_comp) {
301                return None;
302            }
303        }
304        Some(path_comps.as_path())
305    }
306    #[cfg(not(windows))]
307    {
308        None
309    }
310}
311
312/// Compare two paths for logical identity.
313/// On Windows, normalizes verbatim prefixes via dunce and compares disk prefixes and components case-insensitively.
314/// On non-Windows, compares paths directly.
315pub fn paths_equal(p1: &Path, p2: &Path) -> bool {
316    let p1_norm = normalize_path(p1);
317    let p2_norm = normalize_path(p2);
318    if p1_norm == p2_norm {
319        return true;
320    }
321    if to_forward_slash(&p1_norm) == to_forward_slash(&p2_norm) {
322        return true;
323    }
324    if let (Ok(c1), Ok(c2)) = (dunce::canonicalize(p1), dunce::canonicalize(p2)) {
325        let c1_norm = normalize_path(&c1);
326        let c2_norm = normalize_path(&c2);
327        if c1_norm == c2_norm || to_forward_slash(&c1_norm) == to_forward_slash(&c2_norm) {
328            return true;
329        }
330    }
331    #[cfg(windows)]
332    {
333        let mut c1 = p1_norm.components();
334        let mut c2 = p2_norm.components();
335        loop {
336            match (c1.next(), c2.next()) {
337                (None, None) => return true,
338                (Some(comp1), Some(comp2)) => {
339                    if !components_equal(&comp1, &comp2) {
340                        return false;
341                    }
342                }
343                _ => return false,
344            }
345        }
346    }
347    #[cfg(not(windows))]
348    {
349        false
350    }
351}
352
353/// Folder names that are never indexed or watched, at any depth.
354pub const HARD_EXCLUDED_DIRS: &[&str] = &[
355    ".git",
356    ".hg",
357    ".svn",
358    ".julie",
359    ".miller",
360    ".code-kb",
361    ".memories",
362    ".agents",
363    ".razorback",
364    ".worktrees",
365    "worktrees",
366    ".claude",
367    ".venv",
368    "venv",
369    ".env",
370    ".tox",
371    ".vs",
372    "node_modules",
373    "vendor",
374    "target",
375    "dist",
376    "build",
377    ".cache",
378    "obj",
379    "TestResults",
380    ".idea",
381    ".vscode",
382];
383
384/// File name endings that are never indexed or watched.
385pub const HARD_EXCLUDED_SUFFIXES: &[&str] = &[
386    ".min.js",
387    ".bundle.js",
388    ".generated.js",
389    ".generated.jsx",
390    ".generated.ts",
391    ".generated.tsx",
392    ".generated.d.ts",
393    ".tmp",
394    ".swp",
395    "~",
396    ".db-wal",
397    ".db-shm",
398    ".sqlite-wal",
399    ".sqlite-shm",
400];
401
402/// Check if a relative path contains directories or file patterns that must never be indexed or watched.
403pub fn is_hard_excluded(rel_path: &str) -> bool {
404    let p = rel_path.replace('\\', "/");
405    p.split('/')
406        .any(|component| HARD_EXCLUDED_DIRS.contains(&component))
407        || HARD_EXCLUDED_SUFFIXES
408            .iter()
409            .any(|suffix| p.ends_with(suffix))
410}
411
412/// Represents a bound workspace session.
413#[derive(Debug, Clone)]
414pub struct Workspace {
415    pub root: PathBuf,
416    pub canonical_root: PathBuf,
417    pub repo_name: String,
418}
419
420fn trim_trailing_slash(p: &Path) -> PathBuf {
421    let s = p.to_string_lossy();
422    if s.len() > 1 && (s.ends_with('/') || s.ends_with('\\')) {
423        let trimmed = s.trim_end_matches(['/', '\\']);
424        if trimmed.is_empty() {
425            return PathBuf::from(if cfg!(windows) && s.starts_with('\\') {
426                "\\"
427            } else {
428                "/"
429            });
430        }
431        if cfg!(windows)
432            && trimmed.len() == 2
433            && trimmed.as_bytes()[0].is_ascii_alphabetic()
434            && trimmed.as_bytes()[1] == b':'
435        {
436            return PathBuf::from(format!("{}\\", trimmed));
437        }
438        return PathBuf::from(trimmed);
439    }
440    p.to_path_buf()
441}
442
443/// The `HOME` and `USERPROFILE` values that are set and not empty.
444fn home_candidates() -> Vec<PathBuf> {
445    ["HOME", "USERPROFILE"]
446        .into_iter()
447        .filter_map(std::env::var_os)
448        .filter(|home| !home.is_empty())
449        .map(PathBuf::from)
450        .collect()
451}
452
453/// True when `folder` is one of `homes`, compared as canonical paths.
454fn is_home(folder: &Path, homes: &[PathBuf]) -> bool {
455    homes
456        .iter()
457        .filter_map(|home| dunce::canonicalize(home).ok())
458        .any(|home| paths_equal(&home, folder))
459}
460
461/// True for an absolute path, and on Windows also for an `X:` drive path.
462fn is_absolute_path(path: &Path) -> bool {
463    path.is_absolute() || (cfg!(windows) && path.to_string_lossy().chars().nth(1) == Some(':'))
464}
465
466/// The `ProjectRootRefused` reason for a folder with no project marker and no index.
467pub const NO_PROJECT_MARKER_REASON: &str = "it has no project marker (.git, Cargo.toml, package.json, go.mod, pyproject.toml) and no code-kb index";
468
469/// True when `root` carries a repository or language project marker.
470pub fn is_project_root(root: &Path) -> bool {
471    [
472        ".git",
473        "Cargo.toml",
474        "package.json",
475        "go.mod",
476        "pyproject.toml",
477    ]
478    .iter()
479    .any(|marker| root.join(marker).exists())
480}
481
482impl Workspace {
483    /// Discover and bind a workspace from an optional path, falling back to CWD and upward traversal.
484    pub fn discover(start_path: Option<&Path>) -> Result<Self, WorkspaceError> {
485        let current = match start_path {
486            Some(p) => p.to_path_buf(),
487            None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
488        };
489
490        let root = Self::find_root_with_homes(&current, &home_candidates())?;
491        Ok(Self::new(root))
492    }
493
494    /// Finds the root like `find_workspace_root`. When that walk ends at one of `homes` or at a
495    /// filesystem root, returns the nearest folder below it with a project marker, if one exists.
496    fn find_root_with_homes(start: &Path, homes: &[PathBuf]) -> Result<PathBuf, WorkspaceError> {
497        let root = Self::find_workspace_root(start)?;
498        if root.parent().is_some() && !is_home(&root, homes) {
499            return Ok(root);
500        }
501        let Ok(start) = dunce::canonicalize(start) else {
502            return Ok(root);
503        };
504        let start = normalize_path(&start);
505        let folder = if start.is_file() {
506            start.parent().unwrap_or(&start)
507        } else {
508            &start
509        };
510        Ok(folder
511            .ancestors()
512            .take_while(|folder| {
513                strip_prefix_lossy(folder, &root).is_some_and(|rel| !rel.as_os_str().is_empty())
514            })
515            .find(|folder| is_project_root(folder))
516            .map_or(root.clone(), Path::to_path_buf))
517    }
518
519    /// Create workspace binding directly for a known root directory.
520    pub fn new(root: PathBuf) -> Self {
521        let root_str = root.to_string_lossy();
522        let root = if root_str.starts_with("file://") {
523            parse_file_uri(&root_str).unwrap_or_else(|| normalize_path(&root))
524        } else {
525            normalize_path(&root)
526        };
527        let root = trim_trailing_slash(&root);
528        let canonical_root =
529            normalize_path(&dunce::canonicalize(&root).unwrap_or_else(|_| root.clone()));
530        let repo_name = canonical_root
531            .file_name()
532            .map(|n| n.to_string_lossy().to_string())
533            .unwrap_or_else(|| "repo".to_string());
534
535        Self {
536            root,
537            canonical_root,
538            repo_name,
539        }
540    }
541
542    /// Find root by searching upwards for .git, .code-kb, or workspace markers.
543    pub fn find_workspace_root(start: &Path) -> Result<PathBuf, WorkspaceError> {
544        let raw = start.to_string_lossy();
545        let parsed = if raw.starts_with("file://") {
546            parse_file_uri(&raw).unwrap_or_else(|| start.to_path_buf())
547        } else {
548            start.to_path_buf()
549        };
550        let parsed = trim_trailing_slash(&parsed);
551        let curr = if parsed.is_file() {
552            parsed.parent().unwrap_or(&parsed).to_path_buf()
553        } else {
554            parsed.clone()
555        };
556
557        // Pass 1: Look for .git or an existing index all the way up. A bare `.code-kb`
558        // directory is not a marker: `~/.code-kb` holds telemetry and plugin downloads.
559        let mut probe = curr.clone();
560        loop {
561            if probe.join(".code-kb").join("artifact.db").exists() || probe.join(".git").exists() {
562                let canon = dunce::canonicalize(&probe).unwrap_or(probe);
563                return Ok(normalize_path(&canon));
564            }
565            if let Some(name) = probe.file_name().and_then(|n| n.to_str())
566                && is_hard_excluded(name)
567            {
568                break;
569            }
570            if let Some(parent) = probe.parent() {
571                if parent == probe {
572                    break;
573                }
574                probe = parent.to_path_buf();
575            } else {
576                break;
577            }
578        }
579
580        // Pass 2: Look for language project markers
581        let mut curr_marker = curr.clone();
582        loop {
583            if curr_marker.join("Cargo.toml").exists()
584                || curr_marker.join("package.json").exists()
585                || curr_marker.join("go.mod").exists()
586                || curr_marker.join("pyproject.toml").exists()
587            {
588                let canon = dunce::canonicalize(&curr_marker).unwrap_or(curr_marker);
589                return Ok(normalize_path(&canon));
590            }
591            if let Some(name) = curr_marker.file_name().and_then(|n| n.to_str())
592                && is_hard_excluded(name)
593            {
594                break;
595            }
596
597            if let Some(parent) = curr_marker.parent() {
598                if parent == curr_marker {
599                    break;
600                }
601                curr_marker = parent.to_path_buf();
602            } else {
603                break;
604            }
605        }
606
607        // Default to start directory if no markers found
608        let start_dir = if parsed.is_file() {
609            parsed.parent().unwrap_or(&parsed).to_path_buf()
610        } else {
611            parsed
612        };
613        let canon = dunce::canonicalize(&start_dir).unwrap_or(start_dir);
614        Ok(normalize_path(&canon))
615    }
616
617    /// Resolves the `project_root` of a tool call: an absolute path or `file://` URI of the
618    /// project or git worktree, or of any folder or file inside it.
619    pub fn from_project_root(input: &str) -> Result<Workspace, WorkspaceError> {
620        Self::from_project_root_with_homes(input, &home_candidates())
621    }
622
623    fn from_project_root_with_homes(
624        input: &str,
625        homes: &[PathBuf],
626    ) -> Result<Workspace, WorkspaceError> {
627        let input = input.trim();
628        let parsed = if input.starts_with("file://") {
629            parse_file_uri(input).unwrap_or_else(|| PathBuf::from(input))
630        } else {
631            PathBuf::from(input)
632        };
633        let parsed = normalize_path(&parsed);
634        if !parsed.is_absolute() {
635            return Err(WorkspaceError::RelativeProjectRoot(parsed));
636        }
637        let canonical = match dunce::canonicalize(&parsed) {
638            Ok(canonical) => normalize_path(&canonical),
639            Err(_) => return Err(WorkspaceError::ProjectRootNotFound(parsed)),
640        };
641        let root = Self::find_root_with_homes(&canonical, homes)?;
642
643        let refusal = if root.parent().is_none() {
644            Some("it is a filesystem root")
645        } else if is_home(&root, homes) {
646            Some("it is the home directory")
647        } else if !is_project_root(&root) && !root.join(".code-kb").join("artifact.db").exists() {
648            Some(NO_PROJECT_MARKER_REASON)
649        } else {
650            None
651        };
652        match refusal {
653            Some(reason) => Err(WorkspaceError::ProjectRootRefused { path: root, reason }),
654            None => Ok(Workspace::new(root)),
655        }
656    }
657
658    /// For an absolute path inside this workspace, returns the nearest folder from the path up to
659    /// the root (the root excluded) that holds `.git`: a nested git worktree or submodule.
660    pub fn nested_project_root(&self, path: &Path) -> Option<PathBuf> {
661        let (abs, _) = self.resolve_path(path).ok()?;
662        let start = if abs.is_dir() {
663            abs.as_path()
664        } else {
665            abs.parent()?
666        };
667        let nested = start
668            .ancestors()
669            .take_while(|folder| {
670                strip_prefix_lossy(folder, &self.canonical_root)
671                    .is_some_and(|rel| !rel.as_os_str().is_empty())
672            })
673            .find(|folder| folder.join(".git").exists())?;
674        let canonical = dunce::canonicalize(nested).unwrap_or_else(|_| nested.to_path_buf());
675        Some(normalize_path(&canonical))
676    }
677
678    /// Resolves an input path (relative, absolute, or file:// URI) to a canonical absolute path and relative path.
679    pub fn resolve_path(&self, input: &Path) -> Result<(PathBuf, String), WorkspaceError> {
680        let raw_str = input.to_string_lossy();
681        let path = if raw_str.starts_with("file://") {
682            parse_file_uri(&raw_str).unwrap_or_else(|| input.to_path_buf())
683        } else {
684            input.to_path_buf()
685        };
686        let path = normalize_path(&path);
687
688        let joined = if is_absolute_path(&path) {
689            path
690        } else {
691            let rel_str = if cfg!(not(windows)) && path.to_string_lossy().contains('\\') {
692                path.to_string_lossy().replace('\\', "/")
693            } else {
694                path.to_string_lossy().to_string()
695            };
696            self.canonical_root.join(Path::new(&rel_str))
697        };
698
699        // Lexically clean the path to collapse `.` and `..` components
700        let cleaned = clean_path(&joined);
701        let abs_path = normalize_path(&cleaned);
702
703        // If file exists, canonicalize to resolve any symlinks
704        let effective_abs = if abs_path.exists() {
705            dunce::canonicalize(&abs_path)
706                .map(|p| normalize_path(&p))
707                .unwrap_or_else(|_| abs_path.clone())
708        } else {
709            abs_path.clone()
710        };
711
712        let norm_root = dunce::canonicalize(&self.canonical_root)
713            .map(|p| normalize_path(&p))
714            .unwrap_or_else(|_| self.canonical_root.clone());
715
716        // Check if within canonical root (trying multiple normalization variants with case-insensitivity on Windows)
717        let rel = match strip_prefix_lossy(&effective_abs, &norm_root)
718            .or_else(|| strip_prefix_lossy(&effective_abs, &self.canonical_root))
719            .or_else(|| {
720                // Only fall back to uncanonicalized abs_path if the file does not exist yet (e.g. filters or uncreated files)
721                if !abs_path.exists() {
722                    strip_prefix_lossy(&abs_path, &norm_root)
723                        .or_else(|| strip_prefix_lossy(&abs_path, &self.canonical_root))
724                } else {
725                    None
726                }
727            }) {
728            Some(r) => {
729                let forward = to_forward_slash(r);
730                if forward.starts_with("../") || forward == ".." {
731                    return Err(WorkspaceError::PathOutsideWorkspace(
732                        abs_path,
733                        self.canonical_root.clone(),
734                    ));
735                }
736                forward
737            }
738            None => {
739                return Err(WorkspaceError::PathOutsideWorkspace(
740                    abs_path,
741                    self.canonical_root.clone(),
742                ));
743            }
744        };
745
746        Ok((effective_abs, rel))
747    }
748
749    /// Relativizes a path filter string (which may be absolute, file:// URI, or relative)
750    /// against this workspace root into a forward-slash relative path suitable for SQLite queries.
751    pub fn relativize_filter(&self, filter: &str) -> String {
752        let trimmed = filter.trim();
753        if trimmed.is_empty() {
754            return String::new();
755        }
756
757        // Handle file:// URI
758        let path_str = if trimmed.starts_with("file://") {
759            parse_file_uri(trimmed)
760                .map(|p| p.to_string_lossy().to_string())
761                .unwrap_or_else(|| trimmed.to_string())
762        } else {
763            trimmed.to_string()
764        };
765
766        let raw_path = Path::new(&path_str);
767        let simplified = dunce::simplified(raw_path);
768
769        if simplified.is_absolute() {
770            if let Ok((_, rel)) = self.resolve_path(simplified) {
771                return rel;
772            }
773            // If resolve_path failed (e.g. non-existent path), try prefix stripping on normalized strings
774            let norm_simplified = normalize_path(simplified);
775            let norm_root = normalize_path(&self.canonical_root);
776            if let Some(rel) = strip_prefix_lossy(&norm_simplified, &norm_root)
777                .or_else(|| strip_prefix_lossy(&norm_simplified, &self.root))
778            {
779                let forward = to_forward_slash(rel);
780                if !forward.starts_with("../") && forward != ".." {
781                    return forward.trim_matches('/').to_string();
782                }
783            }
784        }
785
786        // Relative path: pass through clean_path to collapse `.` and `..`, normalize slashes, and trim leading ./ or /
787        let cleaned = clean_path(Path::new(&path_str));
788        let forward = to_forward_slash(&cleaned);
789        let trimmed = forward.trim_start_matches("./").trim_matches('/');
790        if trimmed == "." {
791            String::new()
792        } else {
793            trimmed.to_string()
794        }
795    }
796
797    /// Resolve candidate database paths for this workspace:
798    /// 1. Explicit override path (if provided)
799    /// 2. In-tree `.code-kb/artifact.db` or `.code-kb/store.db`
800    pub fn candidate_db_paths(&self, explicit_db: Option<&Path>) -> Vec<PathBuf> {
801        let mut candidates = Vec::new();
802
803        if let Some(p) = explicit_db {
804            candidates.push(normalize_path(p));
805        }
806
807        // In-tree options
808        candidates.push(normalize_path(
809            &self.canonical_root.join(".code-kb").join("artifact.db"),
810        ));
811        candidates.push(normalize_path(
812            &self.canonical_root.join(".code-kb").join("store.db"),
813        ));
814        candidates.push(normalize_path(&self.canonical_root.join("artifact.db")));
815
816        candidates
817    }
818
819    /// Finds the first existing database file, or returns the default target location.
820    pub fn locate_db(&self, explicit_db: Option<&Path>) -> Result<PathBuf, WorkspaceError> {
821        if let Some(p) = explicit_db {
822            return Ok(normalize_path(p));
823        }
824
825        let candidates = self.candidate_db_paths(None);
826        for candidate in &candidates {
827            if candidate.exists() && candidate.is_file() {
828                return Ok(normalize_path(candidate));
829            }
830        }
831
832        Ok(normalize_path(
833            &self.canonical_root.join(".code-kb").join("artifact.db"),
834        ))
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    #[test]
841    fn other_indexers_state_directories_are_hard_excluded() {
842        for dir in [".julie", ".miller", ".code-kb"] {
843            assert!(
844                super::is_hard_excluded(&format!("{dir}/state.lock")),
845                "{dir}"
846            );
847        }
848        assert!(!super::is_hard_excluded("src/miller.rs"));
849    }
850
851    use super::*;
852
853    #[test]
854    #[cfg(windows)]
855    fn test_normalize_path() {
856        let p = PathBuf::from(r"\\?\C:\source\code-kb\src\main.rs");
857        let norm = normalize_path(&p);
858        assert!(!norm.to_string_lossy().starts_with(r"\\?\"));
859    }
860
861    #[test]
862    fn test_find_workspace_root_ignores_ancestor_code_kb_without_index() {
863        let temp = crate::safe_tempdir();
864        let home = temp.path();
865        std::fs::create_dir_all(home.join(".code-kb")).unwrap();
866        std::fs::write(home.join(".code-kb").join("telemetry.db"), b"").unwrap();
867        let project = home.join("project");
868        std::fs::create_dir_all(&project).unwrap();
869        std::fs::write(project.join("Cargo.toml"), "[package]\n").unwrap();
870
871        let root = Workspace::find_workspace_root(&project).unwrap();
872
873        assert!(paths_equal(&root, &project), "{}", root.display());
874    }
875
876    #[test]
877    fn test_find_workspace_root_uses_ancestor_index() {
878        let temp = crate::safe_tempdir();
879        let repo = temp.path().join("repo");
880        std::fs::create_dir_all(repo.join(".code-kb")).unwrap();
881        std::fs::write(repo.join(".code-kb").join("artifact.db"), b"").unwrap();
882        let nested = repo.join("src").join("deep");
883        std::fs::create_dir_all(&nested).unwrap();
884
885        let root = Workspace::find_workspace_root(&nested).unwrap();
886
887        assert!(paths_equal(&root, &repo), "{}", root.display());
888    }
889
890    #[test]
891    fn test_to_forward_slash() {
892        let p = PathBuf::from(r"src\models\mod.rs");
893        assert_eq!(to_forward_slash(&p), "src/models/mod.rs");
894    }
895
896    #[test]
897    fn test_workspace_resolve_path() {
898        let ws = Workspace::new(PathBuf::from("C:/source/test-project"));
899        let (abs, rel) = ws.resolve_path(Path::new("src/lib.rs")).unwrap();
900        assert_eq!(rel, "src/lib.rs");
901        assert!(abs.to_string_lossy().contains("test-project"));
902
903        #[cfg(windows)]
904        {
905            // Lowercase drive letter
906            let (_abs2, rel2) = ws
907                .resolve_path(Path::new("c:/source/test-project/src/lib.rs"))
908                .unwrap();
909            assert_eq!(rel2, "src/lib.rs");
910
911            // Case-insensitive directory on Windows
912            let (_abs3, rel3) = ws
913                .resolve_path(Path::new("C:/SOURCE/test-project/src/lib.rs"))
914                .unwrap();
915            assert_eq!(rel3, "src/lib.rs");
916
917            // file:// URI
918            let (_abs4, rel4) = ws
919                .resolve_path(Path::new("file:///C:/source/test-project/src/lib.rs"))
920                .unwrap();
921            assert_eq!(rel4, "src/lib.rs");
922
923            // file:// URI with lowercase drive letter
924            let (_abs5, rel5) = ws
925                .resolve_path(Path::new("file:///c:/source/test-project/src/lib.rs"))
926                .unwrap();
927            assert_eq!(rel5, "src/lib.rs");
928        }
929    }
930
931    #[test]
932    fn test_workspace_resolve_path_traversal_escape() {
933        let temp = crate::safe_tempdir();
934        let ws = Workspace::new(temp.path().to_path_buf());
935        let res = ws.resolve_path(Path::new("sub/../../outside.rs"));
936        assert!(
937            matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
938            "Expected PathOutsideWorkspace error, but got: {:?}",
939            res
940        );
941    }
942
943    #[test]
944    fn test_parse_file_uri() {
945        #[cfg(windows)]
946        let (uri, expected) = ("file:///C:/my%20folder/project", "C:/my folder/project");
947        #[cfg(not(windows))]
948        let (uri, expected) = ("file:///tmp/my%20folder/project", "/tmp/my folder/project");
949
950        let p1 = parse_file_uri(uri).unwrap();
951        assert_eq!(p1, normalize_path(Path::new(expected)));
952
953        // Plain path fallback
954        let p2 = parse_file_uri("C:/direct/path").unwrap();
955        assert_eq!(p2, normalize_path(Path::new("C:/direct/path")));
956    }
957
958    #[test]
959    fn test_relativize_filter() {
960        let temp = crate::safe_tempdir();
961        let ws = Workspace::new(temp.path().to_path_buf());
962
963        // Relative path
964        assert_eq!(ws.relativize_filter("."), "");
965        assert_eq!(ws.relativize_filter("./"), "");
966        assert_eq!(ws.relativize_filter("src/models"), "src/models");
967        assert_eq!(ws.relativize_filter("./src/models/"), "src/models");
968        assert_eq!(
969            ws.relativize_filter(r"src\models\mod.rs"),
970            "src/models/mod.rs"
971        );
972
973        // Relative path with ..
974        assert_eq!(
975            ws.relativize_filter("src/../src/models/mod.rs"),
976            "src/models/mod.rs"
977        );
978
979        // Absolute path inside workspace
980        let abs_file = temp.path().join("src").join("lib.rs");
981        std::fs::create_dir_all(abs_file.parent().unwrap()).unwrap();
982        std::fs::write(&abs_file, "").unwrap();
983
984        assert_eq!(
985            ws.relativize_filter(&abs_file.to_string_lossy()),
986            "src/lib.rs"
987        );
988
989        // File URI
990        let uri = format!("file://{}", abs_file.to_string_lossy().replace('\\', "/"));
991        assert_eq!(ws.relativize_filter(&uri), "src/lib.rs");
992
993        #[cfg(windows)]
994        {
995            // Case-insensitive absolute path for existing file resolves to canonical disk casing
996            let upper_abs = abs_file.to_string_lossy().to_uppercase();
997            assert_eq!(ws.relativize_filter(&upper_abs), "src/lib.rs");
998
999            // File URI with alternate case
1000            let uri_cased = format!(
1001                "file:///{}",
1002                abs_file.to_string_lossy().replace('\\', "/").to_lowercase()
1003            );
1004            assert_eq!(ws.relativize_filter(&uri_cased), "src/lib.rs");
1005        }
1006    }
1007
1008    #[test]
1009    fn test_paths_equal() {
1010        assert!(paths_equal(
1011            Path::new("src/lib.rs"),
1012            Path::new("src/lib.rs")
1013        ));
1014        assert!(!paths_equal(
1015            Path::new("src/lib.rs"),
1016            Path::new("src/main.rs")
1017        ));
1018
1019        #[cfg(windows)]
1020        {
1021            // Case-insensitive drive letters and paths
1022            assert!(paths_equal(
1023                Path::new(r"C:\source\code-kb\src\lib.rs"),
1024                Path::new(r"c:\source\code-kb\src\lib.rs")
1025            ));
1026            assert!(paths_equal(
1027                Path::new(r"C:\source\code-kb\src\lib.rs"),
1028                Path::new(r"c:\SOURCE\CODE-KB\SRC\LIB.RS")
1029            ));
1030            // Verbatim prefixes
1031            assert!(paths_equal(
1032                Path::new(r"\\?\C:\source\code-kb\src\lib.rs"),
1033                Path::new(r"C:\source\code-kb\src\lib.rs")
1034            ));
1035            assert!(paths_equal(
1036                Path::new(r"\\?\c:\source\code-kb\src\lib.rs"),
1037                Path::new(r"C:\source\code-kb\src\lib.rs")
1038            ));
1039            // UNC paths
1040            assert!(paths_equal(
1041                Path::new(r"\\server\share\file"),
1042                Path::new(r"\\SERVER\SHARE\file")
1043            ));
1044            assert!(paths_equal(
1045                Path::new(r"\\server\share\file"),
1046                Path::new(r"\\server\share\file")
1047            ));
1048            assert!(!paths_equal(
1049                Path::new(r"\\server\share1\file"),
1050                Path::new(r"\\server\share2\file")
1051            ));
1052        }
1053    }
1054
1055    #[test]
1056    fn test_strip_prefix_lossy() {
1057        let base = Path::new("src");
1058        assert_eq!(
1059            strip_prefix_lossy(Path::new("src/lib.rs"), base),
1060            Some(Path::new("lib.rs"))
1061        );
1062        assert_eq!(strip_prefix_lossy(Path::new("tests/foo.rs"), base), None);
1063
1064        #[cfg(windows)]
1065        {
1066            let base_win = Path::new(r"C:\source\code-kb");
1067            // Standard path
1068            assert_eq!(
1069                strip_prefix_lossy(Path::new(r"C:\source\code-kb\src\lib.rs"), base_win),
1070                Some(Path::new(r"src\lib.rs"))
1071            );
1072            // Disk prefix casing
1073            assert_eq!(
1074                strip_prefix_lossy(Path::new(r"c:\source\code-kb\src\lib.rs"), base_win),
1075                Some(Path::new(r"src\lib.rs"))
1076            );
1077            assert_eq!(
1078                strip_prefix_lossy(Path::new(r"c:\SOURCE\CODE-KB\src\lib.rs"), base_win),
1079                Some(Path::new(r"src\lib.rs"))
1080            );
1081            // Verbatim prefixes
1082            assert_eq!(
1083                strip_prefix_lossy(Path::new(r"\\?\C:\source\code-kb\src\lib.rs"), base_win),
1084                Some(Path::new(r"src\lib.rs"))
1085            );
1086            assert_eq!(
1087                strip_prefix_lossy(Path::new(r"\\?\c:\source\code-kb\src\lib.rs"), base_win),
1088                Some(Path::new(r"src\lib.rs"))
1089            );
1090            // Negative non-matching paths
1091            assert_eq!(
1092                strip_prefix_lossy(Path::new(r"C:\other\code-kb\src\lib.rs"), base_win),
1093                None
1094            );
1095            assert_eq!(
1096                strip_prefix_lossy(Path::new(r"D:\source\code-kb\src\lib.rs"), base_win),
1097                None
1098            );
1099        }
1100    }
1101
1102    #[test]
1103    fn test_parse_file_uri_two_slash_and_percent() {
1104        #[cfg(windows)]
1105        {
1106            let p1 = parse_file_uri("file://C:/my%20folder/lib.rs").unwrap();
1107            assert_eq!(p1, normalize_path(Path::new("C:/my folder/lib.rs")));
1108
1109            let p2 = parse_file_uri("file://c:/my%20folder/lib.rs").unwrap();
1110            assert_eq!(p2, normalize_path(Path::new("c:/my folder/lib.rs")));
1111
1112            let p3 = parse_file_uri("file:///C:/my%20folder/lib.rs").unwrap();
1113            assert_eq!(p3, normalize_path(Path::new("C:/my folder/lib.rs")));
1114        }
1115        #[cfg(not(windows))]
1116        {
1117            let p1 = parse_file_uri("file:///my%20folder/lib.rs").unwrap();
1118            assert_eq!(p1, normalize_path(Path::new("/my folder/lib.rs")));
1119        }
1120    }
1121
1122    #[test]
1123    fn test_workspace_verbatim_root_and_db_cleanup() {
1124        let temp = crate::safe_tempdir();
1125        let verbatim_path = format!(r"\\?\{}", temp.path().display());
1126        let ws = Workspace::new(PathBuf::from(&verbatim_path));
1127        assert!(!ws.root.to_string_lossy().starts_with(r"\\?\"));
1128        assert!(!ws.canonical_root.to_string_lossy().starts_with(r"\\?\"));
1129
1130        let explicit = PathBuf::from(format!(r"\\?\{}\test.db", temp.path().display()));
1131        let located = ws.locate_db(Some(&explicit)).unwrap();
1132        assert!(!located.to_string_lossy().starts_with(r"\\?\"));
1133    }
1134
1135    #[test]
1136    fn test_trim_trailing_slash_edge_cases() {
1137        assert_eq!(trim_trailing_slash(Path::new("/")), PathBuf::from("/"));
1138        assert_eq!(trim_trailing_slash(Path::new("///")), PathBuf::from("/"));
1139        assert_eq!(
1140            trim_trailing_slash(Path::new("/a/b/")),
1141            PathBuf::from("/a/b")
1142        );
1143        assert_eq!(
1144            trim_trailing_slash(Path::new("foo/bar/")),
1145            PathBuf::from("foo/bar")
1146        );
1147
1148        #[cfg(windows)]
1149        {
1150            assert_eq!(
1151                trim_trailing_slash(Path::new("C:\\")),
1152                PathBuf::from("C:\\")
1153            );
1154            assert_eq!(trim_trailing_slash(Path::new("C:/")), PathBuf::from("C:\\"));
1155            assert_eq!(
1156                trim_trailing_slash(Path::new("C://")),
1157                PathBuf::from("C:\\")
1158            );
1159            assert_eq!(
1160                trim_trailing_slash(Path::new("C:\\\\")),
1161                PathBuf::from("C:\\")
1162            );
1163            assert_eq!(
1164                trim_trailing_slash(Path::new("C:/foo/")),
1165                PathBuf::from("C:/foo")
1166            );
1167        }
1168    }
1169
1170    #[test]
1171    fn test_unicode_and_emoji_uri_safety() {
1172        // Must not panic on non-ASCII character boundaries
1173        let p1 = parse_file_uri("file:///a๐Ÿ˜€/x");
1174        assert!(p1.is_some());
1175
1176        let p2 = parse_file_uri("file:///c๐Ÿ˜€/x");
1177        assert!(p2.is_some());
1178
1179        let p3 = parse_file_uri("file:///localhost๐Ÿ˜€/x");
1180        assert!(p3.is_some());
1181
1182        let p4 = parse_file_uri("file://C:/๐Ÿ˜€๐Ÿ˜€/main.rs");
1183        assert!(p4.is_some());
1184    }
1185
1186    #[test]
1187    #[cfg(unix)]
1188    fn test_escaping_symlink_rejected() {
1189        let ws_dir = crate::safe_tempdir();
1190        let ext_dir = crate::safe_tempdir();
1191
1192        let ext_file = ext_dir.path().join("secret.txt");
1193        std::fs::write(&ext_file, "secret").unwrap();
1194
1195        let symlink_path = ws_dir.path().join("link.txt");
1196        std::os::unix::fs::symlink(&ext_file, &symlink_path).unwrap();
1197        let ws = Workspace::new(ws_dir.path().to_path_buf());
1198        let res = ws.resolve_path(&symlink_path);
1199        assert!(
1200            matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
1201            "Expected PathOutsideWorkspace, got: {res:?}"
1202        );
1203    }
1204
1205    const NO_MARKER_REASON: &str = "it has no project marker (.git, Cargo.toml, package.json, go.mod, pyproject.toml) and no code-kb index";
1206
1207    fn project_root_refusal(input: &Path, homes: &[PathBuf]) -> (PathBuf, &'static str) {
1208        match Workspace::from_project_root_with_homes(&input.to_string_lossy(), homes) {
1209            Err(WorkspaceError::ProjectRootRefused { path, reason }) => (path, reason),
1210            other => panic!("expected a refusal for {}, got {other:?}", input.display()),
1211        }
1212    }
1213
1214    fn resolved_project_root(input: &Path) -> PathBuf {
1215        Workspace::from_project_root_with_homes(&input.to_string_lossy(), &[])
1216            .unwrap()
1217            .canonical_root
1218    }
1219
1220    #[cfg(windows)]
1221    #[test]
1222    fn project_root_drive_relative_path_is_refused() {
1223        for input in ["C:src", "C:"] {
1224            let result = Workspace::from_project_root(input);
1225            assert!(
1226                matches!(result, Err(WorkspaceError::RelativeProjectRoot(_))),
1227                "{input:?}: {result:?}"
1228            );
1229        }
1230    }
1231
1232    #[test]
1233    fn project_root_relative_path_is_refused() {
1234        for input in ["src/main.rs", "", "   "] {
1235            let result = Workspace::from_project_root(input);
1236            assert!(
1237                matches!(result, Err(WorkspaceError::RelativeProjectRoot(_))),
1238                "{input:?}: {result:?}"
1239            );
1240        }
1241    }
1242
1243    #[test]
1244    fn project_root_missing_path_is_refused_as_not_found() {
1245        let temp = crate::safe_tempdir();
1246        let missing = temp.path().join("missing");
1247
1248        let result = Workspace::from_project_root(&missing.to_string_lossy());
1249
1250        assert!(
1251            matches!(&result, Err(WorkspaceError::ProjectRootNotFound(path)) if paths_equal(path, &missing)),
1252            "{result:?}"
1253        );
1254    }
1255
1256    #[test]
1257    fn project_root_file_uri_resolves_to_the_project() {
1258        let temp = crate::safe_tempdir();
1259        let project = temp.path().join("project");
1260        std::fs::create_dir_all(project.join(".git")).unwrap();
1261        let uri = format!("file://{}", to_forward_slash(&project));
1262
1263        let ws = Workspace::from_project_root(&uri).unwrap();
1264
1265        assert!(
1266            paths_equal(&ws.canonical_root, &project),
1267            "{}",
1268            ws.canonical_root.display()
1269        );
1270    }
1271
1272    #[test]
1273    fn project_root_subfolder_resolves_to_the_enclosing_project() {
1274        let temp = crate::safe_tempdir();
1275        let project = temp.path().join("project");
1276        std::fs::create_dir_all(project.join(".git")).unwrap();
1277        let nested = project.join("src").join("deep");
1278        std::fs::create_dir_all(&nested).unwrap();
1279
1280        let root = resolved_project_root(&nested);
1281
1282        assert!(paths_equal(&root, &project), "{}", root.display());
1283    }
1284
1285    #[test]
1286    fn project_root_file_resolves_to_the_enclosing_project() {
1287        let temp = crate::safe_tempdir();
1288        let project = temp.path().join("project");
1289        std::fs::create_dir_all(project.join("src")).unwrap();
1290        std::fs::write(project.join("Cargo.toml"), "[package]\n").unwrap();
1291        let file = project.join("src").join("main.rs");
1292        std::fs::write(&file, "fn main() {}\n").unwrap();
1293
1294        let root = resolved_project_root(&file);
1295
1296        assert!(paths_equal(&root, &project), "{}", root.display());
1297    }
1298
1299    #[test]
1300    fn project_root_git_worktree_nested_in_a_repo_resolves_to_itself() {
1301        let temp = crate::safe_tempdir();
1302        let outer = temp.path().join("outer");
1303        std::fs::create_dir_all(outer.join(".git")).unwrap();
1304        let worktree = outer.join("wt");
1305        std::fs::create_dir_all(&worktree).unwrap();
1306        std::fs::write(worktree.join(".git"), "gitdir: ../.git/worktrees/wt\n").unwrap();
1307
1308        let root = resolved_project_root(&worktree);
1309
1310        assert!(paths_equal(&root, &worktree), "{}", root.display());
1311    }
1312
1313    #[test]
1314    fn project_root_with_index_and_no_marker_is_accepted() {
1315        let temp = crate::safe_tempdir();
1316        let indexed = temp.path().join("indexed");
1317        std::fs::create_dir_all(indexed.join(".code-kb")).unwrap();
1318        std::fs::write(indexed.join(".code-kb").join("artifact.db"), b"").unwrap();
1319
1320        let root = resolved_project_root(&indexed);
1321
1322        assert!(paths_equal(&root, &indexed), "{}", root.display());
1323    }
1324
1325    #[test]
1326    fn project_root_without_marker_or_index_is_refused_and_left_untouched() {
1327        let temp = crate::safe_tempdir();
1328        let plain = temp.path().join("plain");
1329        std::fs::create_dir_all(&plain).unwrap();
1330
1331        let (path, reason) = project_root_refusal(&plain, &[]);
1332
1333        assert_eq!(reason, NO_MARKER_REASON);
1334        assert!(paths_equal(&path, &plain), "{}", path.display());
1335        assert!(std::fs::read_dir(&plain).unwrap().next().is_none());
1336    }
1337
1338    #[test]
1339    fn project_root_refusal_message_names_the_path_and_reason() {
1340        let temp = crate::safe_tempdir();
1341        let plain = temp.path().join("plain");
1342        std::fs::create_dir_all(&plain).unwrap();
1343
1344        let err =
1345            Workspace::from_project_root_with_homes(&plain.to_string_lossy(), &[]).unwrap_err();
1346        let WorkspaceError::ProjectRootRefused { path, .. } = &err else {
1347            panic!("expected a refusal, got {err:?}");
1348        };
1349
1350        assert_eq!(
1351            err.to_string(),
1352            format!(
1353                "project_root '{}' is refused: {NO_MARKER_REASON}",
1354                path.display()
1355            )
1356        );
1357    }
1358
1359    #[test]
1360    fn project_root_filesystem_root_is_refused() {
1361        let temp = crate::safe_tempdir();
1362        let fs_root = temp.path().ancestors().last().unwrap();
1363
1364        let (_, reason) = project_root_refusal(fs_root, &[]);
1365
1366        assert_eq!(reason, "it is a filesystem root");
1367    }
1368
1369    #[test]
1370    fn project_root_home_directory_is_refused_with_or_without_a_git_marker() {
1371        let temp = crate::safe_tempdir();
1372        let home = temp.path().join("home");
1373        std::fs::create_dir_all(&home).unwrap();
1374        let homes = [home.clone()];
1375
1376        let (bare_path, bare_reason) = project_root_refusal(&home, &homes);
1377        std::fs::create_dir_all(home.join(".git")).unwrap();
1378        let (git_path, git_reason) = project_root_refusal(&home, &homes);
1379
1380        assert_eq!(bare_reason, "it is the home directory");
1381        assert!(paths_equal(&bare_path, &home), "{}", bare_path.display());
1382        assert_eq!(git_reason, "it is the home directory");
1383        assert!(paths_equal(&git_path, &home), "{}", git_path.display());
1384    }
1385
1386    #[test]
1387    fn project_root_folder_inside_a_dotfiles_home_is_refused_as_the_home() {
1388        let temp = crate::safe_tempdir();
1389        let home = temp.path().join("home");
1390        std::fs::create_dir_all(home.join(".git")).unwrap();
1391        let notes = home.join("notes");
1392        std::fs::create_dir_all(&notes).unwrap();
1393
1394        let (path, reason) = project_root_refusal(&notes, std::slice::from_ref(&home));
1395
1396        assert_eq!(reason, "it is the home directory");
1397        assert!(paths_equal(&path, &home), "{}", path.display());
1398    }
1399
1400    #[test]
1401    fn project_root_language_project_inside_a_dotfiles_home_resolves_to_that_project() {
1402        let temp = crate::safe_tempdir();
1403        let home = temp.path().join("home");
1404        std::fs::create_dir_all(home.join(".git")).unwrap();
1405        let app = home.join("work").join("app");
1406        std::fs::create_dir_all(app.join("src")).unwrap();
1407        std::fs::write(app.join("Cargo.toml"), "[package]\n").unwrap();
1408        let homes = [home.clone()];
1409
1410        for input in [app.clone(), app.join("src")] {
1411            let ws =
1412                Workspace::from_project_root_with_homes(&input.to_string_lossy(), &homes).unwrap();
1413            assert!(
1414                paths_equal(&ws.canonical_root, &app),
1415                "{}: {}",
1416                input.display(),
1417                ws.canonical_root.display()
1418            );
1419        }
1420    }
1421
1422    fn repo_with_git() -> (tempfile::TempDir, Workspace) {
1423        let temp = crate::safe_tempdir();
1424        let repo = temp.path().join("repo");
1425        std::fs::create_dir_all(repo.join(".git")).unwrap();
1426        let ws = Workspace::new(repo);
1427        (temp, ws)
1428    }
1429
1430    #[test]
1431    fn nested_project_root_finds_a_git_worktree_from_a_file_inside_it() {
1432        let (_temp, ws) = repo_with_git();
1433        let worktree = ws
1434            .canonical_root
1435            .join(".claude")
1436            .join("worktrees")
1437            .join("x");
1438        std::fs::create_dir_all(worktree.join("src")).unwrap();
1439        std::fs::write(worktree.join(".git"), "gitdir: ../../../.git/worktrees/x\n").unwrap();
1440        let file = worktree.join("src").join("a.rs");
1441        std::fs::write(&file, "fn a() {}\n").unwrap();
1442
1443        let nested = ws.nested_project_root(&file).unwrap();
1444
1445        assert!(paths_equal(&nested, &worktree), "{}", nested.display());
1446    }
1447
1448    #[test]
1449    fn nested_project_root_finds_a_git_worktree_from_its_own_folder() {
1450        let (_temp, ws) = repo_with_git();
1451        let worktree = ws
1452            .canonical_root
1453            .join(".claude")
1454            .join("worktrees")
1455            .join("x");
1456        std::fs::create_dir_all(&worktree).unwrap();
1457        std::fs::write(worktree.join(".git"), "gitdir: ../../../.git/worktrees/x\n").unwrap();
1458
1459        let nested = ws.nested_project_root(&worktree).unwrap();
1460
1461        assert!(paths_equal(&nested, &worktree), "{}", nested.display());
1462    }
1463
1464    #[test]
1465    fn nested_project_root_ignores_a_language_marker_member() {
1466        let (_temp, ws) = repo_with_git();
1467        let member = ws.canonical_root.join("crates").join("foo");
1468        std::fs::create_dir_all(member.join("src")).unwrap();
1469        std::fs::write(member.join("Cargo.toml"), "[package]\n").unwrap();
1470        let file = member.join("src").join("lib.rs");
1471        std::fs::write(&file, "pub fn foo() {}\n").unwrap();
1472
1473        assert_eq!(ws.nested_project_root(&file), None);
1474    }
1475
1476    #[test]
1477    fn nested_project_root_is_none_for_the_root_and_a_file_directly_in_it() {
1478        let (_temp, ws) = repo_with_git();
1479        let file = ws.canonical_root.join("main.rs");
1480        std::fs::write(&file, "fn main() {}\n").unwrap();
1481
1482        assert_eq!(ws.nested_project_root(&file), None);
1483        assert_eq!(ws.nested_project_root(&ws.canonical_root), None);
1484    }
1485
1486    #[test]
1487    fn nested_project_root_ignores_a_folder_with_only_an_index() {
1488        let (_temp, ws) = repo_with_git();
1489        let indexed = ws.canonical_root.join("vendor").join("lib");
1490        std::fs::create_dir_all(indexed.join(".code-kb")).unwrap();
1491        std::fs::write(indexed.join(".code-kb").join("artifact.db"), b"").unwrap();
1492        let file = indexed.join("lib.c");
1493        std::fs::write(&file, "int x;\n").unwrap();
1494
1495        assert_eq!(ws.nested_project_root(&file), None);
1496    }
1497
1498    #[test]
1499    fn root_walk_under_a_dotfiles_home_takes_the_nearest_project_below_the_home() {
1500        let temp = crate::safe_tempdir();
1501        let home = temp.path().join("home");
1502        std::fs::create_dir_all(home.join(".git")).unwrap();
1503        let app = home.join("work").join("app");
1504        std::fs::create_dir_all(app.join("src")).unwrap();
1505        std::fs::write(app.join("Cargo.toml"), "[package]\n").unwrap();
1506        let file = app.join("src").join("main.rs");
1507        std::fs::write(&file, "fn main() {}\n").unwrap();
1508        let notes = home.join("notes");
1509        std::fs::create_dir_all(&notes).unwrap();
1510        let homes = [home.clone()];
1511
1512        let from_file = Workspace::find_root_with_homes(&file, &homes).unwrap();
1513        let from_notes = Workspace::find_root_with_homes(&notes, &homes).unwrap();
1514
1515        assert!(paths_equal(&from_file, &app), "{}", from_file.display());
1516        assert!(paths_equal(&from_notes, &home), "{}", from_notes.display());
1517    }
1518}