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