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/// Check if a relative path contains directories or file patterns that must never be indexed or watched.
354pub fn is_hard_excluded(rel_path: &str) -> bool {
355    let p = rel_path.replace('\\', "/");
356    let has_excluded_dir = p.split('/').any(|component| {
357        matches!(
358            component,
359            ".git"
360                | ".hg"
361                | ".svn"
362                | ".julie"
363                | ".miller"
364                | ".code-kb"
365                | ".memories"
366                | ".agents"
367                | ".razorback"
368                | ".worktrees"
369                | "worktrees"
370                | ".claude"
371                | ".venv"
372                | "venv"
373                | ".env"
374                | ".tox"
375                | ".vs"
376                | "node_modules"
377                | "vendor"
378                | "target"
379                | "dist"
380                | "build"
381                | ".cache"
382                | "obj"
383                | "TestResults"
384                | ".idea"
385                | ".vscode"
386        )
387    });
388
389    if has_excluded_dir {
390        return true;
391    }
392
393    const EXCLUDED_SUFFIXES: &[&str] = &[
394        ".min.js",
395        ".bundle.js",
396        ".generated.js",
397        ".generated.jsx",
398        ".generated.ts",
399        ".generated.tsx",
400        ".generated.d.ts",
401        ".tmp",
402        ".swp",
403        "~",
404        ".db-wal",
405        ".db-shm",
406        ".sqlite-wal",
407        ".sqlite-shm",
408    ];
409
410    EXCLUDED_SUFFIXES.iter().any(|suffix| p.ends_with(suffix))
411}
412
413/// Represents a bound workspace session.
414#[derive(Debug, Clone)]
415pub struct Workspace {
416    pub root: PathBuf,
417    pub canonical_root: PathBuf,
418    pub repo_name: String,
419}
420
421fn trim_trailing_slash(p: &Path) -> PathBuf {
422    let s = p.to_string_lossy();
423    if s.len() > 1 && (s.ends_with('/') || s.ends_with('\\')) {
424        let trimmed = s.trim_end_matches(['/', '\\']);
425        if trimmed.is_empty() {
426            return PathBuf::from(if cfg!(windows) && s.starts_with('\\') {
427                "\\"
428            } else {
429                "/"
430            });
431        }
432        if cfg!(windows)
433            && trimmed.len() == 2
434            && trimmed.as_bytes()[0].is_ascii_alphabetic()
435            && trimmed.as_bytes()[1] == b':'
436        {
437            return PathBuf::from(format!("{}\\", trimmed));
438        }
439        return PathBuf::from(trimmed);
440    }
441    p.to_path_buf()
442}
443
444/// The `HOME` and `USERPROFILE` values that are set and not empty.
445fn home_candidates() -> Vec<PathBuf> {
446    ["HOME", "USERPROFILE"]
447        .into_iter()
448        .filter_map(std::env::var_os)
449        .filter(|home| !home.is_empty())
450        .map(PathBuf::from)
451        .collect()
452}
453
454/// True when `folder` is one of `homes`, compared as canonical paths.
455fn is_home(folder: &Path, homes: &[PathBuf]) -> bool {
456    homes
457        .iter()
458        .filter_map(|home| dunce::canonicalize(home).ok())
459        .any(|home| paths_equal(&home, folder))
460}
461
462/// True for an absolute path, and on Windows also for an `X:` drive path.
463fn is_absolute_path(path: &Path) -> bool {
464    path.is_absolute() || (cfg!(windows) && path.to_string_lossy().chars().nth(1) == Some(':'))
465}
466
467/// The `ProjectRootRefused` reason for a folder with no project marker and no index.
468pub 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";
469
470/// True when `root` carries a repository or language project marker.
471pub fn is_project_root(root: &Path) -> bool {
472    [
473        ".git",
474        "Cargo.toml",
475        "package.json",
476        "go.mod",
477        "pyproject.toml",
478    ]
479    .iter()
480    .any(|marker| root.join(marker).exists())
481}
482
483impl Workspace {
484    /// Discover and bind a workspace from an optional path, falling back to CWD and upward traversal.
485    pub fn discover(start_path: Option<&Path>) -> Result<Self, WorkspaceError> {
486        let current = match start_path {
487            Some(p) => p.to_path_buf(),
488            None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
489        };
490
491        let root = Self::find_root_with_homes(&current, &home_candidates())?;
492        Ok(Self::new(root))
493    }
494
495    /// Finds the root like `find_workspace_root`. When that walk ends at one of `homes` or at a
496    /// filesystem root, returns the nearest folder below it with a project marker, if one exists.
497    fn find_root_with_homes(start: &Path, homes: &[PathBuf]) -> Result<PathBuf, WorkspaceError> {
498        let root = Self::find_workspace_root(start)?;
499        if root.parent().is_some() && !is_home(&root, homes) {
500            return Ok(root);
501        }
502        let Ok(start) = dunce::canonicalize(start) else {
503            return Ok(root);
504        };
505        let start = normalize_path(&start);
506        let folder = if start.is_file() {
507            start.parent().unwrap_or(&start)
508        } else {
509            &start
510        };
511        Ok(folder
512            .ancestors()
513            .take_while(|folder| {
514                strip_prefix_lossy(folder, &root).is_some_and(|rel| !rel.as_os_str().is_empty())
515            })
516            .find(|folder| is_project_root(folder))
517            .map_or(root.clone(), Path::to_path_buf))
518    }
519
520    /// Create workspace binding directly for a known root directory.
521    pub fn new(root: PathBuf) -> Self {
522        let root_str = root.to_string_lossy();
523        let root = if root_str.starts_with("file://") {
524            parse_file_uri(&root_str).unwrap_or_else(|| normalize_path(&root))
525        } else {
526            normalize_path(&root)
527        };
528        let root = trim_trailing_slash(&root);
529        let canonical_root =
530            normalize_path(&dunce::canonicalize(&root).unwrap_or_else(|_| root.clone()));
531        let repo_name = canonical_root
532            .file_name()
533            .map(|n| n.to_string_lossy().to_string())
534            .unwrap_or_else(|| "repo".to_string());
535
536        Self {
537            root,
538            canonical_root,
539            repo_name,
540        }
541    }
542
543    /// Find root by searching upwards for .git, .code-kb, or workspace markers.
544    pub fn find_workspace_root(start: &Path) -> Result<PathBuf, WorkspaceError> {
545        let raw = start.to_string_lossy();
546        let parsed = if raw.starts_with("file://") {
547            parse_file_uri(&raw).unwrap_or_else(|| start.to_path_buf())
548        } else {
549            start.to_path_buf()
550        };
551        let parsed = trim_trailing_slash(&parsed);
552        let curr = if parsed.is_file() {
553            parsed.parent().unwrap_or(&parsed).to_path_buf()
554        } else {
555            parsed.clone()
556        };
557
558        // Pass 1: Look for .git or an existing index all the way up. A bare `.code-kb`
559        // directory is not a marker: `~/.code-kb` holds telemetry and plugin downloads.
560        let mut probe = curr.clone();
561        loop {
562            if probe.join(".code-kb").join("artifact.db").exists() || probe.join(".git").exists() {
563                let canon = dunce::canonicalize(&probe).unwrap_or(probe);
564                return Ok(normalize_path(&canon));
565            }
566            if let Some(name) = probe.file_name().and_then(|n| n.to_str())
567                && is_hard_excluded(name)
568            {
569                break;
570            }
571            if let Some(parent) = probe.parent() {
572                if parent == probe {
573                    break;
574                }
575                probe = parent.to_path_buf();
576            } else {
577                break;
578            }
579        }
580
581        // Pass 2: Look for language project markers
582        let mut curr_marker = curr.clone();
583        loop {
584            if curr_marker.join("Cargo.toml").exists()
585                || curr_marker.join("package.json").exists()
586                || curr_marker.join("go.mod").exists()
587                || curr_marker.join("pyproject.toml").exists()
588            {
589                let canon = dunce::canonicalize(&curr_marker).unwrap_or(curr_marker);
590                return Ok(normalize_path(&canon));
591            }
592            if let Some(name) = curr_marker.file_name().and_then(|n| n.to_str())
593                && is_hard_excluded(name)
594            {
595                break;
596            }
597
598            if let Some(parent) = curr_marker.parent() {
599                if parent == curr_marker {
600                    break;
601                }
602                curr_marker = parent.to_path_buf();
603            } else {
604                break;
605            }
606        }
607
608        // Default to start directory if no markers found
609        let start_dir = if parsed.is_file() {
610            parsed.parent().unwrap_or(&parsed).to_path_buf()
611        } else {
612            parsed
613        };
614        let canon = dunce::canonicalize(&start_dir).unwrap_or(start_dir);
615        Ok(normalize_path(&canon))
616    }
617
618    /// Resolves the `project_root` of a tool call: an absolute path or `file://` URI of the
619    /// project or git worktree, or of any folder or file inside it.
620    pub fn from_project_root(input: &str) -> Result<Workspace, WorkspaceError> {
621        Self::from_project_root_with_homes(input, &home_candidates())
622    }
623
624    fn from_project_root_with_homes(
625        input: &str,
626        homes: &[PathBuf],
627    ) -> Result<Workspace, WorkspaceError> {
628        let input = input.trim();
629        let parsed = if input.starts_with("file://") {
630            parse_file_uri(input).unwrap_or_else(|| PathBuf::from(input))
631        } else {
632            PathBuf::from(input)
633        };
634        let parsed = normalize_path(&parsed);
635        if !parsed.is_absolute() {
636            return Err(WorkspaceError::RelativeProjectRoot(parsed));
637        }
638        let canonical = match dunce::canonicalize(&parsed) {
639            Ok(canonical) => normalize_path(&canonical),
640            Err(_) => return Err(WorkspaceError::ProjectRootNotFound(parsed)),
641        };
642        let root = Self::find_root_with_homes(&canonical, homes)?;
643
644        let refusal = if root.parent().is_none() {
645            Some("it is a filesystem root")
646        } else if is_home(&root, homes) {
647            Some("it is the home directory")
648        } else if !is_project_root(&root) && !root.join(".code-kb").join("artifact.db").exists() {
649            Some(NO_PROJECT_MARKER_REASON)
650        } else {
651            None
652        };
653        match refusal {
654            Some(reason) => Err(WorkspaceError::ProjectRootRefused { path: root, reason }),
655            None => Ok(Workspace::new(root)),
656        }
657    }
658
659    /// For an absolute path inside this workspace, returns the nearest folder from the path up to
660    /// the root (the root excluded) that holds `.git`: a nested git worktree or submodule.
661    pub fn nested_project_root(&self, path: &Path) -> Option<PathBuf> {
662        let (abs, _) = self.resolve_path(path).ok()?;
663        let start = if abs.is_dir() {
664            abs.as_path()
665        } else {
666            abs.parent()?
667        };
668        let nested = start
669            .ancestors()
670            .take_while(|folder| {
671                strip_prefix_lossy(folder, &self.canonical_root)
672                    .is_some_and(|rel| !rel.as_os_str().is_empty())
673            })
674            .find(|folder| folder.join(".git").exists())?;
675        let canonical = dunce::canonicalize(nested).unwrap_or_else(|_| nested.to_path_buf());
676        Some(normalize_path(&canonical))
677    }
678
679    /// Resolves an input path (relative, absolute, or file:// URI) to a canonical absolute path and relative path.
680    pub fn resolve_path(&self, input: &Path) -> Result<(PathBuf, String), WorkspaceError> {
681        let raw_str = input.to_string_lossy();
682        let path = if raw_str.starts_with("file://") {
683            parse_file_uri(&raw_str).unwrap_or_else(|| input.to_path_buf())
684        } else {
685            input.to_path_buf()
686        };
687        let path = normalize_path(&path);
688
689        let joined = if is_absolute_path(&path) {
690            path
691        } else {
692            let rel_str = if cfg!(not(windows)) && path.to_string_lossy().contains('\\') {
693                path.to_string_lossy().replace('\\', "/")
694            } else {
695                path.to_string_lossy().to_string()
696            };
697            self.canonical_root.join(Path::new(&rel_str))
698        };
699
700        // Lexically clean the path to collapse `.` and `..` components
701        let cleaned = clean_path(&joined);
702        let abs_path = normalize_path(&cleaned);
703
704        // If file exists, canonicalize to resolve any symlinks
705        let effective_abs = if abs_path.exists() {
706            dunce::canonicalize(&abs_path)
707                .map(|p| normalize_path(&p))
708                .unwrap_or_else(|_| abs_path.clone())
709        } else {
710            abs_path.clone()
711        };
712
713        let norm_root = dunce::canonicalize(&self.canonical_root)
714            .map(|p| normalize_path(&p))
715            .unwrap_or_else(|_| self.canonical_root.clone());
716
717        // Check if within canonical root (trying multiple normalization variants with case-insensitivity on Windows)
718        let rel = match strip_prefix_lossy(&effective_abs, &norm_root)
719            .or_else(|| strip_prefix_lossy(&effective_abs, &self.canonical_root))
720            .or_else(|| {
721                // Only fall back to uncanonicalized abs_path if the file does not exist yet (e.g. filters or uncreated files)
722                if !abs_path.exists() {
723                    strip_prefix_lossy(&abs_path, &norm_root)
724                        .or_else(|| strip_prefix_lossy(&abs_path, &self.canonical_root))
725                } else {
726                    None
727                }
728            }) {
729            Some(r) => {
730                let forward = to_forward_slash(r);
731                if forward.starts_with("../") || forward == ".." {
732                    return Err(WorkspaceError::PathOutsideWorkspace(
733                        abs_path,
734                        self.canonical_root.clone(),
735                    ));
736                }
737                forward
738            }
739            None => {
740                return Err(WorkspaceError::PathOutsideWorkspace(
741                    abs_path,
742                    self.canonical_root.clone(),
743                ));
744            }
745        };
746
747        Ok((effective_abs, rel))
748    }
749
750    /// Relativizes a path filter string (which may be absolute, file:// URI, or relative)
751    /// against this workspace root into a forward-slash relative path suitable for SQLite queries.
752    pub fn relativize_filter(&self, filter: &str) -> String {
753        let trimmed = filter.trim();
754        if trimmed.is_empty() {
755            return String::new();
756        }
757
758        // Handle file:// URI
759        let path_str = if trimmed.starts_with("file://") {
760            parse_file_uri(trimmed)
761                .map(|p| p.to_string_lossy().to_string())
762                .unwrap_or_else(|| trimmed.to_string())
763        } else {
764            trimmed.to_string()
765        };
766
767        let raw_path = Path::new(&path_str);
768        let simplified = dunce::simplified(raw_path);
769
770        if simplified.is_absolute() {
771            if let Ok((_, rel)) = self.resolve_path(simplified) {
772                return rel;
773            }
774            // If resolve_path failed (e.g. non-existent path), try prefix stripping on normalized strings
775            let norm_simplified = normalize_path(simplified);
776            let norm_root = normalize_path(&self.canonical_root);
777            if let Some(rel) = strip_prefix_lossy(&norm_simplified, &norm_root)
778                .or_else(|| strip_prefix_lossy(&norm_simplified, &self.root))
779            {
780                let forward = to_forward_slash(rel);
781                if !forward.starts_with("../") && forward != ".." {
782                    return forward.trim_matches('/').to_string();
783                }
784            }
785        }
786
787        // Relative path: pass through clean_path to collapse `.` and `..`, normalize slashes, and trim leading ./ or /
788        let cleaned = clean_path(Path::new(&path_str));
789        let forward = to_forward_slash(&cleaned);
790        let trimmed = forward.trim_start_matches("./").trim_matches('/');
791        if trimmed == "." {
792            String::new()
793        } else {
794            trimmed.to_string()
795        }
796    }
797
798    /// Resolve candidate database paths for this workspace:
799    /// 1. Explicit override path (if provided)
800    /// 2. In-tree `.code-kb/artifact.db` or `.code-kb/store.db`
801    pub fn candidate_db_paths(&self, explicit_db: Option<&Path>) -> Vec<PathBuf> {
802        let mut candidates = Vec::new();
803
804        if let Some(p) = explicit_db {
805            candidates.push(normalize_path(p));
806        }
807
808        // In-tree options
809        candidates.push(normalize_path(
810            &self.canonical_root.join(".code-kb").join("artifact.db"),
811        ));
812        candidates.push(normalize_path(
813            &self.canonical_root.join(".code-kb").join("store.db"),
814        ));
815        candidates.push(normalize_path(&self.canonical_root.join("artifact.db")));
816
817        candidates
818    }
819
820    /// Finds the first existing database file, or returns the default target location.
821    pub fn locate_db(&self, explicit_db: Option<&Path>) -> Result<PathBuf, WorkspaceError> {
822        if let Some(p) = explicit_db {
823            return Ok(normalize_path(p));
824        }
825
826        let candidates = self.candidate_db_paths(None);
827        for candidate in &candidates {
828            if candidate.exists() && candidate.is_file() {
829                return Ok(normalize_path(candidate));
830            }
831        }
832
833        Ok(normalize_path(
834            &self.canonical_root.join(".code-kb").join("artifact.db"),
835        ))
836    }
837}
838
839#[cfg(test)]
840mod tests {
841    #[test]
842    fn other_indexers_state_directories_are_hard_excluded() {
843        for dir in [".julie", ".miller", ".code-kb"] {
844            assert!(
845                super::is_hard_excluded(&format!("{dir}/state.lock")),
846                "{dir}"
847            );
848        }
849        assert!(!super::is_hard_excluded("src/miller.rs"));
850    }
851
852    use super::*;
853
854    #[test]
855    #[cfg(windows)]
856    fn test_normalize_path() {
857        let p = PathBuf::from(r"\\?\C:\source\code-kb\src\main.rs");
858        let norm = normalize_path(&p);
859        assert!(!norm.to_string_lossy().starts_with(r"\\?\"));
860    }
861
862    #[test]
863    fn test_find_workspace_root_ignores_ancestor_code_kb_without_index() {
864        let temp = crate::safe_tempdir();
865        let home = temp.path();
866        std::fs::create_dir_all(home.join(".code-kb")).unwrap();
867        std::fs::write(home.join(".code-kb").join("telemetry.db"), b"").unwrap();
868        let project = home.join("project");
869        std::fs::create_dir_all(&project).unwrap();
870        std::fs::write(project.join("Cargo.toml"), "[package]\n").unwrap();
871
872        let root = Workspace::find_workspace_root(&project).unwrap();
873
874        assert!(paths_equal(&root, &project), "{}", root.display());
875    }
876
877    #[test]
878    fn test_find_workspace_root_uses_ancestor_index() {
879        let temp = crate::safe_tempdir();
880        let repo = temp.path().join("repo");
881        std::fs::create_dir_all(repo.join(".code-kb")).unwrap();
882        std::fs::write(repo.join(".code-kb").join("artifact.db"), b"").unwrap();
883        let nested = repo.join("src").join("deep");
884        std::fs::create_dir_all(&nested).unwrap();
885
886        let root = Workspace::find_workspace_root(&nested).unwrap();
887
888        assert!(paths_equal(&root, &repo), "{}", root.display());
889    }
890
891    #[test]
892    fn test_to_forward_slash() {
893        let p = PathBuf::from(r"src\models\mod.rs");
894        assert_eq!(to_forward_slash(&p), "src/models/mod.rs");
895    }
896
897    #[test]
898    fn test_workspace_resolve_path() {
899        let ws = Workspace::new(PathBuf::from("C:/source/test-project"));
900        let (abs, rel) = ws.resolve_path(Path::new("src/lib.rs")).unwrap();
901        assert_eq!(rel, "src/lib.rs");
902        assert!(abs.to_string_lossy().contains("test-project"));
903
904        #[cfg(windows)]
905        {
906            // Lowercase drive letter
907            let (_abs2, rel2) = ws
908                .resolve_path(Path::new("c:/source/test-project/src/lib.rs"))
909                .unwrap();
910            assert_eq!(rel2, "src/lib.rs");
911
912            // Case-insensitive directory on Windows
913            let (_abs3, rel3) = ws
914                .resolve_path(Path::new("C:/SOURCE/test-project/src/lib.rs"))
915                .unwrap();
916            assert_eq!(rel3, "src/lib.rs");
917
918            // file:// URI
919            let (_abs4, rel4) = ws
920                .resolve_path(Path::new("file:///C:/source/test-project/src/lib.rs"))
921                .unwrap();
922            assert_eq!(rel4, "src/lib.rs");
923
924            // file:// URI with lowercase drive letter
925            let (_abs5, rel5) = ws
926                .resolve_path(Path::new("file:///c:/source/test-project/src/lib.rs"))
927                .unwrap();
928            assert_eq!(rel5, "src/lib.rs");
929        }
930    }
931
932    #[test]
933    fn test_workspace_resolve_path_traversal_escape() {
934        let temp = crate::safe_tempdir();
935        let ws = Workspace::new(temp.path().to_path_buf());
936        let res = ws.resolve_path(Path::new("sub/../../outside.rs"));
937        assert!(
938            matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
939            "Expected PathOutsideWorkspace error, but got: {:?}",
940            res
941        );
942    }
943
944    #[test]
945    fn test_parse_file_uri() {
946        #[cfg(windows)]
947        let (uri, expected) = ("file:///C:/my%20folder/project", "C:/my folder/project");
948        #[cfg(not(windows))]
949        let (uri, expected) = ("file:///tmp/my%20folder/project", "/tmp/my folder/project");
950
951        let p1 = parse_file_uri(uri).unwrap();
952        assert_eq!(p1, normalize_path(Path::new(expected)));
953
954        // Plain path fallback
955        let p2 = parse_file_uri("C:/direct/path").unwrap();
956        assert_eq!(p2, normalize_path(Path::new("C:/direct/path")));
957    }
958
959    #[test]
960    fn test_relativize_filter() {
961        let temp = crate::safe_tempdir();
962        let ws = Workspace::new(temp.path().to_path_buf());
963
964        // Relative path
965        assert_eq!(ws.relativize_filter("."), "");
966        assert_eq!(ws.relativize_filter("./"), "");
967        assert_eq!(ws.relativize_filter("src/models"), "src/models");
968        assert_eq!(ws.relativize_filter("./src/models/"), "src/models");
969        assert_eq!(
970            ws.relativize_filter(r"src\models\mod.rs"),
971            "src/models/mod.rs"
972        );
973
974        // Relative path with ..
975        assert_eq!(
976            ws.relativize_filter("src/../src/models/mod.rs"),
977            "src/models/mod.rs"
978        );
979
980        // Absolute path inside workspace
981        let abs_file = temp.path().join("src").join("lib.rs");
982        std::fs::create_dir_all(abs_file.parent().unwrap()).unwrap();
983        std::fs::write(&abs_file, "").unwrap();
984
985        assert_eq!(
986            ws.relativize_filter(&abs_file.to_string_lossy()),
987            "src/lib.rs"
988        );
989
990        // File URI
991        let uri = format!("file://{}", abs_file.to_string_lossy().replace('\\', "/"));
992        assert_eq!(ws.relativize_filter(&uri), "src/lib.rs");
993
994        #[cfg(windows)]
995        {
996            // Case-insensitive absolute path for existing file resolves to canonical disk casing
997            let upper_abs = abs_file.to_string_lossy().to_uppercase();
998            assert_eq!(ws.relativize_filter(&upper_abs), "src/lib.rs");
999
1000            // File URI with alternate case
1001            let uri_cased = format!(
1002                "file:///{}",
1003                abs_file.to_string_lossy().replace('\\', "/").to_lowercase()
1004            );
1005            assert_eq!(ws.relativize_filter(&uri_cased), "src/lib.rs");
1006        }
1007    }
1008
1009    #[test]
1010    fn test_paths_equal() {
1011        assert!(paths_equal(
1012            Path::new("src/lib.rs"),
1013            Path::new("src/lib.rs")
1014        ));
1015        assert!(!paths_equal(
1016            Path::new("src/lib.rs"),
1017            Path::new("src/main.rs")
1018        ));
1019
1020        #[cfg(windows)]
1021        {
1022            // Case-insensitive drive letters and paths
1023            assert!(paths_equal(
1024                Path::new(r"C:\source\code-kb\src\lib.rs"),
1025                Path::new(r"c:\source\code-kb\src\lib.rs")
1026            ));
1027            assert!(paths_equal(
1028                Path::new(r"C:\source\code-kb\src\lib.rs"),
1029                Path::new(r"c:\SOURCE\CODE-KB\SRC\LIB.RS")
1030            ));
1031            // Verbatim prefixes
1032            assert!(paths_equal(
1033                Path::new(r"\\?\C:\source\code-kb\src\lib.rs"),
1034                Path::new(r"C:\source\code-kb\src\lib.rs")
1035            ));
1036            assert!(paths_equal(
1037                Path::new(r"\\?\c:\source\code-kb\src\lib.rs"),
1038                Path::new(r"C:\source\code-kb\src\lib.rs")
1039            ));
1040            // UNC paths
1041            assert!(paths_equal(
1042                Path::new(r"\\server\share\file"),
1043                Path::new(r"\\SERVER\SHARE\file")
1044            ));
1045            assert!(paths_equal(
1046                Path::new(r"\\server\share\file"),
1047                Path::new(r"\\server\share\file")
1048            ));
1049            assert!(!paths_equal(
1050                Path::new(r"\\server\share1\file"),
1051                Path::new(r"\\server\share2\file")
1052            ));
1053        }
1054    }
1055
1056    #[test]
1057    fn test_strip_prefix_lossy() {
1058        let base = Path::new("src");
1059        assert_eq!(
1060            strip_prefix_lossy(Path::new("src/lib.rs"), base),
1061            Some(Path::new("lib.rs"))
1062        );
1063        assert_eq!(strip_prefix_lossy(Path::new("tests/foo.rs"), base), None);
1064
1065        #[cfg(windows)]
1066        {
1067            let base_win = Path::new(r"C:\source\code-kb");
1068            // Standard path
1069            assert_eq!(
1070                strip_prefix_lossy(Path::new(r"C:\source\code-kb\src\lib.rs"), base_win),
1071                Some(Path::new(r"src\lib.rs"))
1072            );
1073            // Disk prefix casing
1074            assert_eq!(
1075                strip_prefix_lossy(Path::new(r"c:\source\code-kb\src\lib.rs"), base_win),
1076                Some(Path::new(r"src\lib.rs"))
1077            );
1078            assert_eq!(
1079                strip_prefix_lossy(Path::new(r"c:\SOURCE\CODE-KB\src\lib.rs"), base_win),
1080                Some(Path::new(r"src\lib.rs"))
1081            );
1082            // Verbatim prefixes
1083            assert_eq!(
1084                strip_prefix_lossy(Path::new(r"\\?\C:\source\code-kb\src\lib.rs"), base_win),
1085                Some(Path::new(r"src\lib.rs"))
1086            );
1087            assert_eq!(
1088                strip_prefix_lossy(Path::new(r"\\?\c:\source\code-kb\src\lib.rs"), base_win),
1089                Some(Path::new(r"src\lib.rs"))
1090            );
1091            // Negative non-matching paths
1092            assert_eq!(
1093                strip_prefix_lossy(Path::new(r"C:\other\code-kb\src\lib.rs"), base_win),
1094                None
1095            );
1096            assert_eq!(
1097                strip_prefix_lossy(Path::new(r"D:\source\code-kb\src\lib.rs"), base_win),
1098                None
1099            );
1100        }
1101    }
1102
1103    #[test]
1104    fn test_parse_file_uri_two_slash_and_percent() {
1105        #[cfg(windows)]
1106        {
1107            let p1 = parse_file_uri("file://C:/my%20folder/lib.rs").unwrap();
1108            assert_eq!(p1, normalize_path(Path::new("C:/my folder/lib.rs")));
1109
1110            let p2 = parse_file_uri("file://c:/my%20folder/lib.rs").unwrap();
1111            assert_eq!(p2, normalize_path(Path::new("c:/my folder/lib.rs")));
1112
1113            let p3 = parse_file_uri("file:///C:/my%20folder/lib.rs").unwrap();
1114            assert_eq!(p3, normalize_path(Path::new("C:/my folder/lib.rs")));
1115        }
1116        #[cfg(not(windows))]
1117        {
1118            let p1 = parse_file_uri("file:///my%20folder/lib.rs").unwrap();
1119            assert_eq!(p1, normalize_path(Path::new("/my folder/lib.rs")));
1120        }
1121    }
1122
1123    #[test]
1124    fn test_workspace_verbatim_root_and_db_cleanup() {
1125        let temp = crate::safe_tempdir();
1126        let verbatim_path = format!(r"\\?\{}", temp.path().display());
1127        let ws = Workspace::new(PathBuf::from(&verbatim_path));
1128        assert!(!ws.root.to_string_lossy().starts_with(r"\\?\"));
1129        assert!(!ws.canonical_root.to_string_lossy().starts_with(r"\\?\"));
1130
1131        let explicit = PathBuf::from(format!(r"\\?\{}\test.db", temp.path().display()));
1132        let located = ws.locate_db(Some(&explicit)).unwrap();
1133        assert!(!located.to_string_lossy().starts_with(r"\\?\"));
1134    }
1135
1136    #[test]
1137    fn test_trim_trailing_slash_edge_cases() {
1138        assert_eq!(trim_trailing_slash(Path::new("/")), PathBuf::from("/"));
1139        assert_eq!(trim_trailing_slash(Path::new("///")), PathBuf::from("/"));
1140        assert_eq!(
1141            trim_trailing_slash(Path::new("/a/b/")),
1142            PathBuf::from("/a/b")
1143        );
1144        assert_eq!(
1145            trim_trailing_slash(Path::new("foo/bar/")),
1146            PathBuf::from("foo/bar")
1147        );
1148
1149        #[cfg(windows)]
1150        {
1151            assert_eq!(
1152                trim_trailing_slash(Path::new("C:\\")),
1153                PathBuf::from("C:\\")
1154            );
1155            assert_eq!(trim_trailing_slash(Path::new("C:/")), PathBuf::from("C:\\"));
1156            assert_eq!(
1157                trim_trailing_slash(Path::new("C://")),
1158                PathBuf::from("C:\\")
1159            );
1160            assert_eq!(
1161                trim_trailing_slash(Path::new("C:\\\\")),
1162                PathBuf::from("C:\\")
1163            );
1164            assert_eq!(
1165                trim_trailing_slash(Path::new("C:/foo/")),
1166                PathBuf::from("C:/foo")
1167            );
1168        }
1169    }
1170
1171    #[test]
1172    fn test_unicode_and_emoji_uri_safety() {
1173        // Must not panic on non-ASCII character boundaries
1174        let p1 = parse_file_uri("file:///a๐Ÿ˜€/x");
1175        assert!(p1.is_some());
1176
1177        let p2 = parse_file_uri("file:///c๐Ÿ˜€/x");
1178        assert!(p2.is_some());
1179
1180        let p3 = parse_file_uri("file:///localhost๐Ÿ˜€/x");
1181        assert!(p3.is_some());
1182
1183        let p4 = parse_file_uri("file://C:/๐Ÿ˜€๐Ÿ˜€/main.rs");
1184        assert!(p4.is_some());
1185    }
1186
1187    #[test]
1188    #[cfg(unix)]
1189    fn test_escaping_symlink_rejected() {
1190        let ws_dir = crate::safe_tempdir();
1191        let ext_dir = crate::safe_tempdir();
1192
1193        let ext_file = ext_dir.path().join("secret.txt");
1194        std::fs::write(&ext_file, "secret").unwrap();
1195
1196        let symlink_path = ws_dir.path().join("link.txt");
1197        std::os::unix::fs::symlink(&ext_file, &symlink_path).unwrap();
1198        let ws = Workspace::new(ws_dir.path().to_path_buf());
1199        let res = ws.resolve_path(&symlink_path);
1200        assert!(
1201            matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
1202            "Expected PathOutsideWorkspace, got: {res:?}"
1203        );
1204    }
1205
1206    const NO_MARKER_REASON: &str = "it has no project marker (.git, Cargo.toml, package.json, go.mod, pyproject.toml) and no code-kb index";
1207
1208    fn project_root_refusal(input: &Path, homes: &[PathBuf]) -> (PathBuf, &'static str) {
1209        match Workspace::from_project_root_with_homes(&input.to_string_lossy(), homes) {
1210            Err(WorkspaceError::ProjectRootRefused { path, reason }) => (path, reason),
1211            other => panic!("expected a refusal for {}, got {other:?}", input.display()),
1212        }
1213    }
1214
1215    fn resolved_project_root(input: &Path) -> PathBuf {
1216        Workspace::from_project_root_with_homes(&input.to_string_lossy(), &[])
1217            .unwrap()
1218            .canonical_root
1219    }
1220
1221    #[cfg(windows)]
1222    #[test]
1223    fn project_root_drive_relative_path_is_refused() {
1224        for input in ["C:src", "C:"] {
1225            let result = Workspace::from_project_root(input);
1226            assert!(
1227                matches!(result, Err(WorkspaceError::RelativeProjectRoot(_))),
1228                "{input:?}: {result:?}"
1229            );
1230        }
1231    }
1232
1233    #[test]
1234    fn project_root_relative_path_is_refused() {
1235        for input in ["src/main.rs", "", "   "] {
1236            let result = Workspace::from_project_root(input);
1237            assert!(
1238                matches!(result, Err(WorkspaceError::RelativeProjectRoot(_))),
1239                "{input:?}: {result:?}"
1240            );
1241        }
1242    }
1243
1244    #[test]
1245    fn project_root_missing_path_is_refused_as_not_found() {
1246        let temp = crate::safe_tempdir();
1247        let missing = temp.path().join("missing");
1248
1249        let result = Workspace::from_project_root(&missing.to_string_lossy());
1250
1251        assert!(
1252            matches!(&result, Err(WorkspaceError::ProjectRootNotFound(path)) if paths_equal(path, &missing)),
1253            "{result:?}"
1254        );
1255    }
1256
1257    #[test]
1258    fn project_root_file_uri_resolves_to_the_project() {
1259        let temp = crate::safe_tempdir();
1260        let project = temp.path().join("project");
1261        std::fs::create_dir_all(project.join(".git")).unwrap();
1262        let uri = format!("file://{}", to_forward_slash(&project));
1263
1264        let ws = Workspace::from_project_root(&uri).unwrap();
1265
1266        assert!(
1267            paths_equal(&ws.canonical_root, &project),
1268            "{}",
1269            ws.canonical_root.display()
1270        );
1271    }
1272
1273    #[test]
1274    fn project_root_subfolder_resolves_to_the_enclosing_project() {
1275        let temp = crate::safe_tempdir();
1276        let project = temp.path().join("project");
1277        std::fs::create_dir_all(project.join(".git")).unwrap();
1278        let nested = project.join("src").join("deep");
1279        std::fs::create_dir_all(&nested).unwrap();
1280
1281        let root = resolved_project_root(&nested);
1282
1283        assert!(paths_equal(&root, &project), "{}", root.display());
1284    }
1285
1286    #[test]
1287    fn project_root_file_resolves_to_the_enclosing_project() {
1288        let temp = crate::safe_tempdir();
1289        let project = temp.path().join("project");
1290        std::fs::create_dir_all(project.join("src")).unwrap();
1291        std::fs::write(project.join("Cargo.toml"), "[package]\n").unwrap();
1292        let file = project.join("src").join("main.rs");
1293        std::fs::write(&file, "fn main() {}\n").unwrap();
1294
1295        let root = resolved_project_root(&file);
1296
1297        assert!(paths_equal(&root, &project), "{}", root.display());
1298    }
1299
1300    #[test]
1301    fn project_root_git_worktree_nested_in_a_repo_resolves_to_itself() {
1302        let temp = crate::safe_tempdir();
1303        let outer = temp.path().join("outer");
1304        std::fs::create_dir_all(outer.join(".git")).unwrap();
1305        let worktree = outer.join("wt");
1306        std::fs::create_dir_all(&worktree).unwrap();
1307        std::fs::write(worktree.join(".git"), "gitdir: ../.git/worktrees/wt\n").unwrap();
1308
1309        let root = resolved_project_root(&worktree);
1310
1311        assert!(paths_equal(&root, &worktree), "{}", root.display());
1312    }
1313
1314    #[test]
1315    fn project_root_with_index_and_no_marker_is_accepted() {
1316        let temp = crate::safe_tempdir();
1317        let indexed = temp.path().join("indexed");
1318        std::fs::create_dir_all(indexed.join(".code-kb")).unwrap();
1319        std::fs::write(indexed.join(".code-kb").join("artifact.db"), b"").unwrap();
1320
1321        let root = resolved_project_root(&indexed);
1322
1323        assert!(paths_equal(&root, &indexed), "{}", root.display());
1324    }
1325
1326    #[test]
1327    fn project_root_without_marker_or_index_is_refused_and_left_untouched() {
1328        let temp = crate::safe_tempdir();
1329        let plain = temp.path().join("plain");
1330        std::fs::create_dir_all(&plain).unwrap();
1331
1332        let (path, reason) = project_root_refusal(&plain, &[]);
1333
1334        assert_eq!(reason, NO_MARKER_REASON);
1335        assert!(paths_equal(&path, &plain), "{}", path.display());
1336        assert!(std::fs::read_dir(&plain).unwrap().next().is_none());
1337    }
1338
1339    #[test]
1340    fn project_root_refusal_message_names_the_path_and_reason() {
1341        let temp = crate::safe_tempdir();
1342        let plain = temp.path().join("plain");
1343        std::fs::create_dir_all(&plain).unwrap();
1344
1345        let err =
1346            Workspace::from_project_root_with_homes(&plain.to_string_lossy(), &[]).unwrap_err();
1347        let WorkspaceError::ProjectRootRefused { path, .. } = &err else {
1348            panic!("expected a refusal, got {err:?}");
1349        };
1350
1351        assert_eq!(
1352            err.to_string(),
1353            format!(
1354                "project_root '{}' is refused: {NO_MARKER_REASON}",
1355                path.display()
1356            )
1357        );
1358    }
1359
1360    #[test]
1361    fn project_root_filesystem_root_is_refused() {
1362        let temp = crate::safe_tempdir();
1363        let fs_root = temp.path().ancestors().last().unwrap();
1364
1365        let (_, reason) = project_root_refusal(fs_root, &[]);
1366
1367        assert_eq!(reason, "it is a filesystem root");
1368    }
1369
1370    #[test]
1371    fn project_root_home_directory_is_refused_with_or_without_a_git_marker() {
1372        let temp = crate::safe_tempdir();
1373        let home = temp.path().join("home");
1374        std::fs::create_dir_all(&home).unwrap();
1375        let homes = [home.clone()];
1376
1377        let (bare_path, bare_reason) = project_root_refusal(&home, &homes);
1378        std::fs::create_dir_all(home.join(".git")).unwrap();
1379        let (git_path, git_reason) = project_root_refusal(&home, &homes);
1380
1381        assert_eq!(bare_reason, "it is the home directory");
1382        assert!(paths_equal(&bare_path, &home), "{}", bare_path.display());
1383        assert_eq!(git_reason, "it is the home directory");
1384        assert!(paths_equal(&git_path, &home), "{}", git_path.display());
1385    }
1386
1387    #[test]
1388    fn project_root_folder_inside_a_dotfiles_home_is_refused_as_the_home() {
1389        let temp = crate::safe_tempdir();
1390        let home = temp.path().join("home");
1391        std::fs::create_dir_all(home.join(".git")).unwrap();
1392        let notes = home.join("notes");
1393        std::fs::create_dir_all(&notes).unwrap();
1394
1395        let (path, reason) = project_root_refusal(&notes, std::slice::from_ref(&home));
1396
1397        assert_eq!(reason, "it is the home directory");
1398        assert!(paths_equal(&path, &home), "{}", path.display());
1399    }
1400
1401    #[test]
1402    fn project_root_language_project_inside_a_dotfiles_home_resolves_to_that_project() {
1403        let temp = crate::safe_tempdir();
1404        let home = temp.path().join("home");
1405        std::fs::create_dir_all(home.join(".git")).unwrap();
1406        let app = home.join("work").join("app");
1407        std::fs::create_dir_all(app.join("src")).unwrap();
1408        std::fs::write(app.join("Cargo.toml"), "[package]\n").unwrap();
1409        let homes = [home.clone()];
1410
1411        for input in [app.clone(), app.join("src")] {
1412            let ws =
1413                Workspace::from_project_root_with_homes(&input.to_string_lossy(), &homes).unwrap();
1414            assert!(
1415                paths_equal(&ws.canonical_root, &app),
1416                "{}: {}",
1417                input.display(),
1418                ws.canonical_root.display()
1419            );
1420        }
1421    }
1422
1423    fn repo_with_git() -> (tempfile::TempDir, Workspace) {
1424        let temp = crate::safe_tempdir();
1425        let repo = temp.path().join("repo");
1426        std::fs::create_dir_all(repo.join(".git")).unwrap();
1427        let ws = Workspace::new(repo);
1428        (temp, ws)
1429    }
1430
1431    #[test]
1432    fn nested_project_root_finds_a_git_worktree_from_a_file_inside_it() {
1433        let (_temp, ws) = repo_with_git();
1434        let worktree = ws
1435            .canonical_root
1436            .join(".claude")
1437            .join("worktrees")
1438            .join("x");
1439        std::fs::create_dir_all(worktree.join("src")).unwrap();
1440        std::fs::write(worktree.join(".git"), "gitdir: ../../../.git/worktrees/x\n").unwrap();
1441        let file = worktree.join("src").join("a.rs");
1442        std::fs::write(&file, "fn a() {}\n").unwrap();
1443
1444        let nested = ws.nested_project_root(&file).unwrap();
1445
1446        assert!(paths_equal(&nested, &worktree), "{}", nested.display());
1447    }
1448
1449    #[test]
1450    fn nested_project_root_finds_a_git_worktree_from_its_own_folder() {
1451        let (_temp, ws) = repo_with_git();
1452        let worktree = ws
1453            .canonical_root
1454            .join(".claude")
1455            .join("worktrees")
1456            .join("x");
1457        std::fs::create_dir_all(&worktree).unwrap();
1458        std::fs::write(worktree.join(".git"), "gitdir: ../../../.git/worktrees/x\n").unwrap();
1459
1460        let nested = ws.nested_project_root(&worktree).unwrap();
1461
1462        assert!(paths_equal(&nested, &worktree), "{}", nested.display());
1463    }
1464
1465    #[test]
1466    fn nested_project_root_ignores_a_language_marker_member() {
1467        let (_temp, ws) = repo_with_git();
1468        let member = ws.canonical_root.join("crates").join("foo");
1469        std::fs::create_dir_all(member.join("src")).unwrap();
1470        std::fs::write(member.join("Cargo.toml"), "[package]\n").unwrap();
1471        let file = member.join("src").join("lib.rs");
1472        std::fs::write(&file, "pub fn foo() {}\n").unwrap();
1473
1474        assert_eq!(ws.nested_project_root(&file), None);
1475    }
1476
1477    #[test]
1478    fn nested_project_root_is_none_for_the_root_and_a_file_directly_in_it() {
1479        let (_temp, ws) = repo_with_git();
1480        let file = ws.canonical_root.join("main.rs");
1481        std::fs::write(&file, "fn main() {}\n").unwrap();
1482
1483        assert_eq!(ws.nested_project_root(&file), None);
1484        assert_eq!(ws.nested_project_root(&ws.canonical_root), None);
1485    }
1486
1487    #[test]
1488    fn nested_project_root_ignores_a_folder_with_only_an_index() {
1489        let (_temp, ws) = repo_with_git();
1490        let indexed = ws.canonical_root.join("vendor").join("lib");
1491        std::fs::create_dir_all(indexed.join(".code-kb")).unwrap();
1492        std::fs::write(indexed.join(".code-kb").join("artifact.db"), b"").unwrap();
1493        let file = indexed.join("lib.c");
1494        std::fs::write(&file, "int x;\n").unwrap();
1495
1496        assert_eq!(ws.nested_project_root(&file), None);
1497    }
1498
1499    #[test]
1500    fn root_walk_under_a_dotfiles_home_takes_the_nearest_project_below_the_home() {
1501        let temp = crate::safe_tempdir();
1502        let home = temp.path().join("home");
1503        std::fs::create_dir_all(home.join(".git")).unwrap();
1504        let app = home.join("work").join("app");
1505        std::fs::create_dir_all(app.join("src")).unwrap();
1506        std::fs::write(app.join("Cargo.toml"), "[package]\n").unwrap();
1507        let file = app.join("src").join("main.rs");
1508        std::fs::write(&file, "fn main() {}\n").unwrap();
1509        let notes = home.join("notes");
1510        std::fs::create_dir_all(&notes).unwrap();
1511        let homes = [home.clone()];
1512
1513        let from_file = Workspace::find_root_with_homes(&file, &homes).unwrap();
1514        let from_notes = Workspace::find_root_with_homes(&notes, &homes).unwrap();
1515
1516        assert!(paths_equal(&from_file, &app), "{}", from_file.display());
1517        assert!(paths_equal(&from_notes, &home), "{}", from_notes.display());
1518    }
1519}