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
26pub 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
72fn 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 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 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
110fn 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
124fn 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
141pub 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 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
194pub fn log_dir(workspace_root: &Path) -> PathBuf {
196 workspace_root.join(".code-kb").join("logs")
197}
198
199pub 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
222pub fn latest_log_file(log_dir: &Path) -> Option<PathBuf> {
224 log_files_newest_first(log_dir).into_iter().next()
225}
226
227pub 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
240pub fn to_forward_slash(path: &Path) -> String {
242 let s = path.to_string_lossy();
243 s.replace('\\', "/")
244}
245
246#[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
288pub 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
312pub 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
353pub 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#[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
444fn 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
454fn 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
462fn is_absolute_path(path: &Path) -> bool {
464 path.is_absolute() || (cfg!(windows) && path.to_string_lossy().chars().nth(1) == Some(':'))
465}
466
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";
469
470pub 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 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(¤t, &home_candidates())?;
492 Ok(Self::new(root))
493 }
494
495 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 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 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 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 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 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 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 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 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 let cleaned = clean_path(&joined);
702 let abs_path = normalize_path(&cleaned);
703
704 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(
976 ws.relativize_filter("src/../src/models/mod.rs"),
977 "src/models/mod.rs"
978 );
979
980 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 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 let upper_abs = abs_file.to_string_lossy().to_uppercase();
998 assert_eq!(ws.relativize_filter(&upper_abs), "src/lib.rs");
999
1000 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 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 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 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 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 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 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 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 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(¬es).unwrap();
1394
1395 let (path, reason) = project_root_refusal(¬es, 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(¬es).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(¬es, &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}