1use std::path::{Path, PathBuf};
2use thiserror::Error;
3
4#[derive(Debug, Error)]
5pub enum WorkspaceError {
6 #[error("Failed to canonicalize path {path}: {source}")]
7 CanonicalizationFailed {
8 path: PathBuf,
9 #[source]
10 source: std::io::Error,
11 },
12 #[error("Path '{0}' is outside workspace root '{1}'")]
13 PathOutsideWorkspace(PathBuf, PathBuf),
14 #[error("Could not discover workspace root from '{0}'")]
15 DiscoveryFailed(PathBuf),
16 #[error("Database artifact not found at '{0}'")]
17 ArtifactNotFound(PathBuf),
18}
19
20pub fn clean_path(path: &Path) -> PathBuf {
22 use std::path::Component;
23 let s = path.to_string_lossy();
24 let norm = if cfg!(not(windows)) && s.contains('\\') {
25 std::borrow::Cow::Owned(PathBuf::from(s.replace('\\', "/")))
26 } else {
27 std::borrow::Cow::Borrowed(path)
28 };
29 let mut stack = Vec::new();
30 for comp in norm.components() {
31 match comp {
32 Component::CurDir => {}
33 Component::ParentDir => {
34 if let Some(Component::Normal(_)) = stack.last() {
35 stack.pop();
36 } else {
37 stack.push(comp);
38 }
39 }
40 _ => stack.push(comp),
41 }
42 }
43 stack.into_iter().collect()
44}
45
46fn percent_decode(input: &str) -> String {
47 let mut bytes = Vec::with_capacity(input.len());
48 let input_bytes = input.as_bytes();
49 let mut i = 0;
50 while i < input_bytes.len() {
51 if input_bytes[i] == b'%'
52 && i + 2 < input_bytes.len()
53 && let Ok(hex) = std::str::from_utf8(&input_bytes[i + 1..i + 3])
54 && let Ok(byte) = u8::from_str_radix(hex, 16)
55 {
56 bytes.push(byte);
57 i += 3;
58 continue;
59 }
60 bytes.push(input_bytes[i]);
61 i += 1;
62 }
63 String::from_utf8_lossy(&bytes).into_owned()
64}
65
66fn extract_drive_letter_and_remainder(s: &str) -> Option<(char, &str)> {
69 let bytes = s.as_bytes();
70 if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() {
71 return None;
72 }
73 let drive = bytes[0] as char;
74
75 if bytes.len() >= 2
77 && (bytes[1] == b':' || bytes[1] == b'|')
78 && (bytes.len() == 2
79 || bytes[2] == b'/'
80 || bytes[2] == b'\\'
81 || bytes[2] == b'?'
82 || bytes[2] == b'#')
83 {
84 return Some((drive, &s[2..]));
85 }
86
87 if bytes.len() >= 4 {
89 let delim = &bytes[1..4];
90 if (delim.eq_ignore_ascii_case(b"%7c") || delim.eq_ignore_ascii_case(b"%3a"))
91 && (bytes.len() == 4
92 || bytes[4] == b'/'
93 || bytes[4] == b'\\'
94 || bytes[4] == b'?'
95 || bytes[4] == b'#')
96 {
97 return Some((drive, &s[4..]));
98 }
99 }
100
101 None
102}
103
104fn strip_localhost_prefix(s: &str) -> &str {
106 let without_slash = s.strip_prefix('/').unwrap_or(s);
107 let bytes = without_slash.as_bytes();
108 if bytes.len() >= 10
109 && bytes[..9].eq_ignore_ascii_case(b"localhost")
110 && (bytes[9] == b'/' || bytes[9] == b'\\')
111 {
112 &without_slash[10..]
113 } else {
114 s
115 }
116}
117
118fn normalize_drive_pipe_str(s: &str) -> String {
121 let clean = strip_localhost_prefix(s);
122 let target = clean.strip_prefix('/').unwrap_or(clean);
123 let target = strip_localhost_prefix(target);
124 if let Some((drive, remainder)) = extract_drive_letter_and_remainder(target) {
125 if remainder.is_empty() || remainder.starts_with('?') || remainder.starts_with('#') {
126 format!("{}:/{}", drive, remainder)
127 } else {
128 format!("{}:{}", drive, remainder)
129 }
130 } else {
131 s.to_string()
132 }
133}
134
135pub fn parse_file_uri(cand: &str) -> Option<PathBuf> {
139 if let Some(rest) = cand.strip_prefix("file://") {
140 let path_part = rest.strip_prefix('/').unwrap_or(rest);
141 let path_part = strip_localhost_prefix(path_part);
142 let normalized_cand = if let Some((drive, remainder)) =
143 extract_drive_letter_and_remainder(path_part)
144 {
145 if remainder.is_empty() || remainder.starts_with('?') || remainder.starts_with('#') {
146 format!("file:///{}:/{}", drive, remainder)
147 } else if remainder.starts_with('/') || remainder.starts_with('\\') {
148 format!("file:///{}:{}", drive, remainder)
149 } else {
150 format!("file:///{}:/{}", drive, remainder)
151 }
152 } else {
153 cand.to_string()
154 };
155
156 let file_path = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
157 url::Url::parse(&normalized_cand)
158 .ok()
159 .and_then(|url| url.to_file_path().ok())
160 }))
161 .ok()
162 .flatten();
163
164 if let Some(path) = file_path {
165 return Some(normalize_path(&path));
166 }
167 if let Some(s) = cand.strip_prefix("file:///") {
169 let decoded = percent_decode(s);
170 let normalized = normalize_drive_pipe_str(&decoded);
171 if cfg!(windows) {
172 Some(normalize_path(Path::new(&normalized)))
173 } else {
174 Some(normalize_path(&PathBuf::from(format!("/{}", normalized))))
175 }
176 } else {
177 let s = cand.strip_prefix("file://").unwrap_or(cand);
178 let decoded = percent_decode(s);
179 let normalized = normalize_drive_pipe_str(&decoded);
180 Some(normalize_path(Path::new(&normalized)))
181 }
182 } else {
183 let normalized = normalize_drive_pipe_str(cand);
184 Some(normalize_path(Path::new(&normalized)))
185 }
186}
187
188pub fn normalize_path(path: &Path) -> PathBuf {
190 let s = path.to_string_lossy();
191 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
192 let unc = format!(r"\\{rest}");
193 return dunce::simplified(Path::new(&unc)).to_path_buf();
194 }
195 if let Some(rest) = s.strip_prefix(r"\\?\") {
196 return dunce::simplified(Path::new(rest)).to_path_buf();
197 }
198 dunce::simplified(path).to_path_buf()
199}
200
201pub fn to_forward_slash(path: &Path) -> String {
203 let s = path.to_string_lossy();
204 s.replace('\\', "/")
205}
206
207#[cfg(windows)]
210fn components_equal(c1: &std::path::Component, c2: &std::path::Component) -> bool {
211 if c1 == c2 {
212 return true;
213 }
214 {
215 use std::path::Component;
216 match (c1, c2) {
217 (Component::Normal(s1), Component::Normal(s2)) => s1
218 .to_string_lossy()
219 .eq_ignore_ascii_case(&s2.to_string_lossy()),
220 (Component::Prefix(p1), Component::Prefix(p2)) => {
221 use std::path::Prefix;
222 match (p1.kind(), p2.kind()) {
223 (Prefix::Disk(d1), Prefix::Disk(d2))
224 | (Prefix::VerbatimDisk(d1), Prefix::VerbatimDisk(d2))
225 | (Prefix::Disk(d1), Prefix::VerbatimDisk(d2))
226 | (Prefix::VerbatimDisk(d1), Prefix::Disk(d2)) => d1.eq_ignore_ascii_case(&d2),
227 (Prefix::UNC(s1, sh1), Prefix::UNC(s2, sh2))
228 | (Prefix::VerbatimUNC(s1, sh1), Prefix::VerbatimUNC(s2, sh2))
229 | (Prefix::UNC(s1, sh1), Prefix::VerbatimUNC(s2, sh2))
230 | (Prefix::VerbatimUNC(s1, sh1), Prefix::UNC(s2, sh2)) => {
231 s1.to_string_lossy()
232 .eq_ignore_ascii_case(&s2.to_string_lossy())
233 && sh1
234 .to_string_lossy()
235 .eq_ignore_ascii_case(&sh2.to_string_lossy())
236 }
237 (Prefix::DeviceNS(d1), Prefix::DeviceNS(d2))
238 | (Prefix::Verbatim(d1), Prefix::Verbatim(d2)) => d1
239 .to_string_lossy()
240 .eq_ignore_ascii_case(&d2.to_string_lossy()),
241 _ => false,
242 }
243 }
244 _ => false,
245 }
246 }
247}
248
249pub fn strip_prefix_lossy<'a>(path: &'a Path, base: &Path) -> Option<&'a Path> {
252 if let Ok(rel) = path.strip_prefix(base) {
253 return Some(rel);
254 }
255
256 #[cfg(windows)]
257 {
258 let mut path_comps = path.components();
259 for base_comp in base.components() {
260 let path_comp = path_comps.next()?;
261 if !components_equal(&base_comp, &path_comp) {
262 return None;
263 }
264 }
265 Some(path_comps.as_path())
266 }
267 #[cfg(not(windows))]
268 {
269 None
270 }
271}
272
273pub fn paths_equal(p1: &Path, p2: &Path) -> bool {
277 let p1_norm = normalize_path(p1);
278 let p2_norm = normalize_path(p2);
279 if p1_norm == p2_norm {
280 return true;
281 }
282 if to_forward_slash(&p1_norm) == to_forward_slash(&p2_norm) {
283 return true;
284 }
285 if let (Ok(c1), Ok(c2)) = (dunce::canonicalize(p1), dunce::canonicalize(p2)) {
286 let c1_norm = normalize_path(&c1);
287 let c2_norm = normalize_path(&c2);
288 if c1_norm == c2_norm || to_forward_slash(&c1_norm) == to_forward_slash(&c2_norm) {
289 return true;
290 }
291 }
292 #[cfg(windows)]
293 {
294 let mut c1 = p1_norm.components();
295 let mut c2 = p2_norm.components();
296 loop {
297 match (c1.next(), c2.next()) {
298 (None, None) => return true,
299 (Some(comp1), Some(comp2)) => {
300 if !components_equal(&comp1, &comp2) {
301 return false;
302 }
303 }
304 _ => return false,
305 }
306 }
307 }
308 #[cfg(not(windows))]
309 {
310 false
311 }
312}
313
314pub fn is_hard_excluded(rel_path: &str) -> bool {
316 let p = rel_path.replace('\\', "/");
317 let has_excluded_dir = p.split('/').any(|component| {
318 matches!(
319 component,
320 ".git"
321 | ".hg"
322 | ".svn"
323 | ".julie"
324 | ".code-kb"
325 | ".memories"
326 | ".agents"
327 | ".razorback"
328 | ".worktrees"
329 | "worktrees"
330 | ".claude"
331 | ".venv"
332 | "venv"
333 | ".env"
334 | ".tox"
335 | ".vs"
336 | "node_modules"
337 | "vendor"
338 | "target"
339 | "dist"
340 | "build"
341 | ".cache"
342 | "obj"
343 | "TestResults"
344 | ".idea"
345 | ".vscode"
346 )
347 });
348
349 if has_excluded_dir {
350 return true;
351 }
352
353 const EXCLUDED_SUFFIXES: &[&str] = &[
354 ".min.js",
355 ".bundle.js",
356 ".generated.js",
357 ".generated.jsx",
358 ".generated.ts",
359 ".generated.tsx",
360 ".generated.d.ts",
361 ".tmp",
362 ".swp",
363 "~",
364 ];
365
366 EXCLUDED_SUFFIXES.iter().any(|suffix| p.ends_with(suffix))
367}
368
369#[derive(Debug, Clone)]
371pub struct Workspace {
372 pub root: PathBuf,
373 pub canonical_root: PathBuf,
374 pub repo_name: String,
375}
376
377fn trim_trailing_slash(p: &Path) -> PathBuf {
378 let s = p.to_string_lossy();
379 if s.len() > 1 && (s.ends_with('/') || s.ends_with('\\')) {
380 let trimmed = s.trim_end_matches(['/', '\\']);
381 if trimmed.is_empty() {
382 return PathBuf::from(if cfg!(windows) && s.starts_with('\\') {
383 "\\"
384 } else {
385 "/"
386 });
387 }
388 if cfg!(windows)
389 && trimmed.len() == 2
390 && trimmed.as_bytes()[0].is_ascii_alphabetic()
391 && trimmed.as_bytes()[1] == b':'
392 {
393 return PathBuf::from(format!("{}\\", trimmed));
394 }
395 return PathBuf::from(trimmed);
396 }
397 p.to_path_buf()
398}
399
400pub fn is_project_root(root: &Path) -> bool {
402 [
403 ".git",
404 "Cargo.toml",
405 "package.json",
406 "go.mod",
407 "pyproject.toml",
408 ]
409 .iter()
410 .any(|marker| root.join(marker).exists())
411}
412
413impl Workspace {
414 pub fn discover(start_path: Option<&Path>) -> Result<Self, WorkspaceError> {
416 let current = match start_path {
417 Some(p) => p.to_path_buf(),
418 None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
419 };
420
421 let root = Self::find_workspace_root(¤t)?;
422 Ok(Self::new(root))
423 }
424
425 pub fn new(root: PathBuf) -> Self {
427 let root_str = root.to_string_lossy();
428 let root = if root_str.starts_with("file://") {
429 parse_file_uri(&root_str).unwrap_or_else(|| normalize_path(&root))
430 } else {
431 normalize_path(&root)
432 };
433 let root = trim_trailing_slash(&root);
434 let canonical_root =
435 normalize_path(&dunce::canonicalize(&root).unwrap_or_else(|_| root.clone()));
436 let repo_name = canonical_root
437 .file_name()
438 .map(|n| n.to_string_lossy().to_string())
439 .unwrap_or_else(|| "repo".to_string());
440
441 Self {
442 root,
443 canonical_root,
444 repo_name,
445 }
446 }
447
448 pub fn find_workspace_root(start: &Path) -> Result<PathBuf, WorkspaceError> {
450 let raw = start.to_string_lossy();
451 let parsed = if raw.starts_with("file://") {
452 parse_file_uri(&raw).unwrap_or_else(|| start.to_path_buf())
453 } else {
454 start.to_path_buf()
455 };
456 let parsed = trim_trailing_slash(&parsed);
457 let curr = if parsed.is_file() {
458 parsed.parent().unwrap_or(&parsed).to_path_buf()
459 } else {
460 parsed.clone()
461 };
462
463 let mut probe = curr.clone();
466 loop {
467 if probe.join(".code-kb").join("artifact.db").exists() || probe.join(".git").exists() {
468 let canon = dunce::canonicalize(&probe).unwrap_or(probe);
469 return Ok(normalize_path(&canon));
470 }
471 if let Some(name) = probe.file_name().and_then(|n| n.to_str())
472 && is_hard_excluded(name)
473 {
474 break;
475 }
476 if let Some(parent) = probe.parent() {
477 if parent == probe {
478 break;
479 }
480 probe = parent.to_path_buf();
481 } else {
482 break;
483 }
484 }
485
486 let mut curr_marker = curr.clone();
488 loop {
489 if curr_marker.join("Cargo.toml").exists()
490 || curr_marker.join("package.json").exists()
491 || curr_marker.join("go.mod").exists()
492 || curr_marker.join("pyproject.toml").exists()
493 {
494 let canon = dunce::canonicalize(&curr_marker).unwrap_or(curr_marker);
495 return Ok(normalize_path(&canon));
496 }
497 if let Some(name) = curr_marker.file_name().and_then(|n| n.to_str())
498 && is_hard_excluded(name)
499 {
500 break;
501 }
502
503 if let Some(parent) = curr_marker.parent() {
504 if parent == curr_marker {
505 break;
506 }
507 curr_marker = parent.to_path_buf();
508 } else {
509 break;
510 }
511 }
512
513 let start_dir = if parsed.is_file() {
515 parsed.parent().unwrap_or(&parsed).to_path_buf()
516 } else {
517 parsed
518 };
519 let canon = dunce::canonicalize(&start_dir).unwrap_or(start_dir);
520 Ok(normalize_path(&canon))
521 }
522
523 pub fn resolve_path(&self, input: &Path) -> Result<(PathBuf, String), WorkspaceError> {
525 let raw_str = input.to_string_lossy();
526 let path = if raw_str.starts_with("file://") {
527 parse_file_uri(&raw_str).unwrap_or_else(|| input.to_path_buf())
528 } else {
529 input.to_path_buf()
530 };
531 let path = normalize_path(&path);
532
533 let is_abs = path.is_absolute()
534 || (cfg!(windows) && (path.to_string_lossy().chars().nth(1) == Some(':')));
535
536 let joined = if is_abs {
537 path
538 } else {
539 let rel_str = if cfg!(not(windows)) && path.to_string_lossy().contains('\\') {
540 path.to_string_lossy().replace('\\', "/")
541 } else {
542 path.to_string_lossy().to_string()
543 };
544 self.canonical_root.join(Path::new(&rel_str))
545 };
546
547 let cleaned = clean_path(&joined);
549 let abs_path = normalize_path(&cleaned);
550
551 let effective_abs = if abs_path.exists() {
553 dunce::canonicalize(&abs_path)
554 .map(|p| normalize_path(&p))
555 .unwrap_or_else(|_| abs_path.clone())
556 } else {
557 abs_path.clone()
558 };
559
560 let norm_root = dunce::canonicalize(&self.canonical_root)
561 .map(|p| normalize_path(&p))
562 .unwrap_or_else(|_| self.canonical_root.clone());
563
564 let rel = match strip_prefix_lossy(&effective_abs, &norm_root)
566 .or_else(|| strip_prefix_lossy(&effective_abs, &self.canonical_root))
567 .or_else(|| {
568 if !abs_path.exists() {
570 strip_prefix_lossy(&abs_path, &norm_root)
571 .or_else(|| strip_prefix_lossy(&abs_path, &self.canonical_root))
572 } else {
573 None
574 }
575 }) {
576 Some(r) => {
577 let forward = to_forward_slash(r);
578 if forward.starts_with("../") || forward == ".." {
579 return Err(WorkspaceError::PathOutsideWorkspace(
580 abs_path,
581 self.canonical_root.clone(),
582 ));
583 }
584 forward
585 }
586 None => {
587 return Err(WorkspaceError::PathOutsideWorkspace(
588 abs_path,
589 self.canonical_root.clone(),
590 ));
591 }
592 };
593
594 Ok((effective_abs, rel))
595 }
596
597 pub fn relativize_filter(&self, filter: &str) -> String {
600 let trimmed = filter.trim();
601 if trimmed.is_empty() {
602 return String::new();
603 }
604
605 let path_str = if trimmed.starts_with("file://") {
607 parse_file_uri(trimmed)
608 .map(|p| p.to_string_lossy().to_string())
609 .unwrap_or_else(|| trimmed.to_string())
610 } else {
611 trimmed.to_string()
612 };
613
614 let raw_path = Path::new(&path_str);
615 let simplified = dunce::simplified(raw_path);
616
617 if simplified.is_absolute() {
618 if let Ok((_, rel)) = self.resolve_path(simplified) {
619 return rel;
620 }
621 let norm_simplified = normalize_path(simplified);
623 let norm_root = normalize_path(&self.canonical_root);
624 if let Some(rel) = strip_prefix_lossy(&norm_simplified, &norm_root)
625 .or_else(|| strip_prefix_lossy(&norm_simplified, &self.root))
626 {
627 let forward = to_forward_slash(rel);
628 if !forward.starts_with("../") && forward != ".." {
629 return forward.trim_matches('/').to_string();
630 }
631 }
632 }
633
634 let cleaned = clean_path(Path::new(&path_str));
636 let forward = to_forward_slash(&cleaned);
637 let trimmed = forward.trim_start_matches("./").trim_matches('/');
638 if trimmed == "." {
639 String::new()
640 } else {
641 trimmed.to_string()
642 }
643 }
644
645 pub fn candidate_db_paths(&self, explicit_db: Option<&Path>) -> Vec<PathBuf> {
649 let mut candidates = Vec::new();
650
651 if let Some(p) = explicit_db {
652 candidates.push(normalize_path(p));
653 }
654
655 candidates.push(normalize_path(
657 &self.canonical_root.join(".code-kb").join("artifact.db"),
658 ));
659 candidates.push(normalize_path(
660 &self.canonical_root.join(".code-kb").join("store.db"),
661 ));
662 candidates.push(normalize_path(&self.canonical_root.join("artifact.db")));
663
664 candidates
665 }
666
667 pub fn locate_db(&self, explicit_db: Option<&Path>) -> Result<PathBuf, WorkspaceError> {
669 if let Some(p) = explicit_db {
670 return Ok(normalize_path(p));
671 }
672
673 let candidates = self.candidate_db_paths(None);
674 for candidate in &candidates {
675 if candidate.exists() && candidate.is_file() {
676 return Ok(normalize_path(candidate));
677 }
678 }
679
680 Ok(normalize_path(
681 &self.canonical_root.join(".code-kb").join("artifact.db"),
682 ))
683 }
684}
685
686#[cfg(test)]
687mod tests {
688 use super::*;
689
690 #[test]
691 #[cfg(windows)]
692 fn test_normalize_path() {
693 let p = PathBuf::from(r"\\?\C:\source\code-kb\src\main.rs");
694 let norm = normalize_path(&p);
695 assert!(!norm.to_string_lossy().starts_with(r"\\?\"));
696 }
697
698 #[test]
699 fn test_find_workspace_root_ignores_ancestor_code_kb_without_index() {
700 let temp = crate::safe_tempdir();
701 let home = temp.path();
702 std::fs::create_dir_all(home.join(".code-kb")).unwrap();
703 std::fs::write(home.join(".code-kb").join("telemetry.db"), b"").unwrap();
704 let project = home.join("project");
705 std::fs::create_dir_all(&project).unwrap();
706 std::fs::write(project.join("Cargo.toml"), "[package]\n").unwrap();
707
708 let root = Workspace::find_workspace_root(&project).unwrap();
709
710 assert!(paths_equal(&root, &project), "{}", root.display());
711 }
712
713 #[test]
714 fn test_find_workspace_root_uses_ancestor_index() {
715 let temp = crate::safe_tempdir();
716 let repo = temp.path().join("repo");
717 std::fs::create_dir_all(repo.join(".code-kb")).unwrap();
718 std::fs::write(repo.join(".code-kb").join("artifact.db"), b"").unwrap();
719 let nested = repo.join("src").join("deep");
720 std::fs::create_dir_all(&nested).unwrap();
721
722 let root = Workspace::find_workspace_root(&nested).unwrap();
723
724 assert!(paths_equal(&root, &repo), "{}", root.display());
725 }
726
727 #[test]
728 fn test_to_forward_slash() {
729 let p = PathBuf::from(r"src\models\mod.rs");
730 assert_eq!(to_forward_slash(&p), "src/models/mod.rs");
731 }
732
733 #[test]
734 fn test_workspace_resolve_path() {
735 let ws = Workspace::new(PathBuf::from("C:/source/test-project"));
736 let (abs, rel) = ws.resolve_path(Path::new("src/lib.rs")).unwrap();
737 assert_eq!(rel, "src/lib.rs");
738 assert!(abs.to_string_lossy().contains("test-project"));
739
740 #[cfg(windows)]
741 {
742 let (_abs2, rel2) = ws
744 .resolve_path(Path::new("c:/source/test-project/src/lib.rs"))
745 .unwrap();
746 assert_eq!(rel2, "src/lib.rs");
747
748 let (_abs3, rel3) = ws
750 .resolve_path(Path::new("C:/SOURCE/test-project/src/lib.rs"))
751 .unwrap();
752 assert_eq!(rel3, "src/lib.rs");
753
754 let (_abs4, rel4) = ws
756 .resolve_path(Path::new("file:///C:/source/test-project/src/lib.rs"))
757 .unwrap();
758 assert_eq!(rel4, "src/lib.rs");
759
760 let (_abs5, rel5) = ws
762 .resolve_path(Path::new("file:///c:/source/test-project/src/lib.rs"))
763 .unwrap();
764 assert_eq!(rel5, "src/lib.rs");
765 }
766 }
767
768 #[test]
769 fn test_workspace_resolve_path_traversal_escape() {
770 let temp = crate::safe_tempdir();
771 let ws = Workspace::new(temp.path().to_path_buf());
772 let res = ws.resolve_path(Path::new("sub/../../outside.rs"));
773 assert!(
774 matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
775 "Expected PathOutsideWorkspace error, but got: {:?}",
776 res
777 );
778 }
779
780 #[test]
781 fn test_parse_file_uri() {
782 #[cfg(windows)]
783 let (uri, expected) = ("file:///C:/my%20folder/project", "C:/my folder/project");
784 #[cfg(not(windows))]
785 let (uri, expected) = ("file:///tmp/my%20folder/project", "/tmp/my folder/project");
786
787 let p1 = parse_file_uri(uri).unwrap();
788 assert_eq!(p1, normalize_path(Path::new(expected)));
789
790 let p2 = parse_file_uri("C:/direct/path").unwrap();
792 assert_eq!(p2, normalize_path(Path::new("C:/direct/path")));
793 }
794
795 #[test]
796 fn test_relativize_filter() {
797 let temp = crate::safe_tempdir();
798 let ws = Workspace::new(temp.path().to_path_buf());
799
800 assert_eq!(ws.relativize_filter("."), "");
802 assert_eq!(ws.relativize_filter("./"), "");
803 assert_eq!(ws.relativize_filter("src/models"), "src/models");
804 assert_eq!(ws.relativize_filter("./src/models/"), "src/models");
805 assert_eq!(
806 ws.relativize_filter(r"src\models\mod.rs"),
807 "src/models/mod.rs"
808 );
809
810 assert_eq!(
812 ws.relativize_filter("src/../src/models/mod.rs"),
813 "src/models/mod.rs"
814 );
815
816 let abs_file = temp.path().join("src").join("lib.rs");
818 std::fs::create_dir_all(abs_file.parent().unwrap()).unwrap();
819 std::fs::write(&abs_file, "").unwrap();
820
821 assert_eq!(
822 ws.relativize_filter(&abs_file.to_string_lossy()),
823 "src/lib.rs"
824 );
825
826 let uri = format!("file://{}", abs_file.to_string_lossy().replace('\\', "/"));
828 assert_eq!(ws.relativize_filter(&uri), "src/lib.rs");
829
830 #[cfg(windows)]
831 {
832 let upper_abs = abs_file.to_string_lossy().to_uppercase();
834 assert_eq!(ws.relativize_filter(&upper_abs), "src/lib.rs");
835
836 let uri_cased = format!(
838 "file:///{}",
839 abs_file.to_string_lossy().replace('\\', "/").to_lowercase()
840 );
841 assert_eq!(ws.relativize_filter(&uri_cased), "src/lib.rs");
842 }
843 }
844
845 #[test]
846 fn test_paths_equal() {
847 assert!(paths_equal(
848 Path::new("src/lib.rs"),
849 Path::new("src/lib.rs")
850 ));
851 assert!(!paths_equal(
852 Path::new("src/lib.rs"),
853 Path::new("src/main.rs")
854 ));
855
856 #[cfg(windows)]
857 {
858 assert!(paths_equal(
860 Path::new(r"C:\source\code-kb\src\lib.rs"),
861 Path::new(r"c:\source\code-kb\src\lib.rs")
862 ));
863 assert!(paths_equal(
864 Path::new(r"C:\source\code-kb\src\lib.rs"),
865 Path::new(r"c:\SOURCE\CODE-KB\SRC\LIB.RS")
866 ));
867 assert!(paths_equal(
869 Path::new(r"\\?\C:\source\code-kb\src\lib.rs"),
870 Path::new(r"C:\source\code-kb\src\lib.rs")
871 ));
872 assert!(paths_equal(
873 Path::new(r"\\?\c:\source\code-kb\src\lib.rs"),
874 Path::new(r"C:\source\code-kb\src\lib.rs")
875 ));
876 assert!(paths_equal(
878 Path::new(r"\\server\share\file"),
879 Path::new(r"\\SERVER\SHARE\file")
880 ));
881 assert!(paths_equal(
882 Path::new(r"\\server\share\file"),
883 Path::new(r"\\server\share\file")
884 ));
885 assert!(!paths_equal(
886 Path::new(r"\\server\share1\file"),
887 Path::new(r"\\server\share2\file")
888 ));
889 }
890 }
891
892 #[test]
893 fn test_strip_prefix_lossy() {
894 let base = Path::new("src");
895 assert_eq!(
896 strip_prefix_lossy(Path::new("src/lib.rs"), base),
897 Some(Path::new("lib.rs"))
898 );
899 assert_eq!(strip_prefix_lossy(Path::new("tests/foo.rs"), base), None);
900
901 #[cfg(windows)]
902 {
903 let base_win = Path::new(r"C:\source\code-kb");
904 assert_eq!(
906 strip_prefix_lossy(Path::new(r"C:\source\code-kb\src\lib.rs"), base_win),
907 Some(Path::new(r"src\lib.rs"))
908 );
909 assert_eq!(
911 strip_prefix_lossy(Path::new(r"c:\source\code-kb\src\lib.rs"), base_win),
912 Some(Path::new(r"src\lib.rs"))
913 );
914 assert_eq!(
915 strip_prefix_lossy(Path::new(r"c:\SOURCE\CODE-KB\src\lib.rs"), base_win),
916 Some(Path::new(r"src\lib.rs"))
917 );
918 assert_eq!(
920 strip_prefix_lossy(Path::new(r"\\?\C:\source\code-kb\src\lib.rs"), base_win),
921 Some(Path::new(r"src\lib.rs"))
922 );
923 assert_eq!(
924 strip_prefix_lossy(Path::new(r"\\?\c:\source\code-kb\src\lib.rs"), base_win),
925 Some(Path::new(r"src\lib.rs"))
926 );
927 assert_eq!(
929 strip_prefix_lossy(Path::new(r"C:\other\code-kb\src\lib.rs"), base_win),
930 None
931 );
932 assert_eq!(
933 strip_prefix_lossy(Path::new(r"D:\source\code-kb\src\lib.rs"), base_win),
934 None
935 );
936 }
937 }
938
939 #[test]
940 fn test_parse_file_uri_two_slash_and_percent() {
941 #[cfg(windows)]
942 {
943 let p1 = parse_file_uri("file://C:/my%20folder/lib.rs").unwrap();
944 assert_eq!(p1, normalize_path(Path::new("C:/my folder/lib.rs")));
945
946 let p2 = parse_file_uri("file://c:/my%20folder/lib.rs").unwrap();
947 assert_eq!(p2, normalize_path(Path::new("c:/my folder/lib.rs")));
948
949 let p3 = parse_file_uri("file:///C:/my%20folder/lib.rs").unwrap();
950 assert_eq!(p3, normalize_path(Path::new("C:/my folder/lib.rs")));
951 }
952 #[cfg(not(windows))]
953 {
954 let p1 = parse_file_uri("file:///my%20folder/lib.rs").unwrap();
955 assert_eq!(p1, normalize_path(Path::new("/my folder/lib.rs")));
956 }
957 }
958
959 #[test]
960 fn test_workspace_verbatim_root_and_db_cleanup() {
961 let temp = crate::safe_tempdir();
962 let verbatim_path = format!(r"\\?\{}", temp.path().display());
963 let ws = Workspace::new(PathBuf::from(&verbatim_path));
964 assert!(!ws.root.to_string_lossy().starts_with(r"\\?\"));
965 assert!(!ws.canonical_root.to_string_lossy().starts_with(r"\\?\"));
966
967 let explicit = PathBuf::from(format!(r"\\?\{}\test.db", temp.path().display()));
968 let located = ws.locate_db(Some(&explicit)).unwrap();
969 assert!(!located.to_string_lossy().starts_with(r"\\?\"));
970 }
971
972 #[test]
973 fn test_trim_trailing_slash_edge_cases() {
974 assert_eq!(trim_trailing_slash(Path::new("/")), PathBuf::from("/"));
975 assert_eq!(trim_trailing_slash(Path::new("///")), PathBuf::from("/"));
976 assert_eq!(
977 trim_trailing_slash(Path::new("/a/b/")),
978 PathBuf::from("/a/b")
979 );
980 assert_eq!(
981 trim_trailing_slash(Path::new("foo/bar/")),
982 PathBuf::from("foo/bar")
983 );
984
985 #[cfg(windows)]
986 {
987 assert_eq!(
988 trim_trailing_slash(Path::new("C:\\")),
989 PathBuf::from("C:\\")
990 );
991 assert_eq!(trim_trailing_slash(Path::new("C:/")), PathBuf::from("C:\\"));
992 assert_eq!(
993 trim_trailing_slash(Path::new("C://")),
994 PathBuf::from("C:\\")
995 );
996 assert_eq!(
997 trim_trailing_slash(Path::new("C:\\\\")),
998 PathBuf::from("C:\\")
999 );
1000 assert_eq!(
1001 trim_trailing_slash(Path::new("C:/foo/")),
1002 PathBuf::from("C:/foo")
1003 );
1004 }
1005 }
1006
1007 #[test]
1008 fn test_unicode_and_emoji_uri_safety() {
1009 let p1 = parse_file_uri("file:///a๐/x");
1011 assert!(p1.is_some());
1012
1013 let p2 = parse_file_uri("file:///c๐/x");
1014 assert!(p2.is_some());
1015
1016 let p3 = parse_file_uri("file:///localhost๐/x");
1017 assert!(p3.is_some());
1018
1019 let p4 = parse_file_uri("file://C:/๐๐/main.rs");
1020 assert!(p4.is_some());
1021 }
1022
1023 #[test]
1024 #[cfg(unix)]
1025 fn test_escaping_symlink_rejected() {
1026 let ws_dir = crate::safe_tempdir();
1027 let ext_dir = crate::safe_tempdir();
1028
1029 let ext_file = ext_dir.path().join("secret.txt");
1030 std::fs::write(&ext_file, "secret").unwrap();
1031
1032 let symlink_path = ws_dir.path().join("link.txt");
1033 std::os::unix::fs::symlink(&ext_file, &symlink_path).unwrap();
1034 let ws = Workspace::new(ws_dir.path().to_path_buf());
1035 let res = ws.resolve_path(&symlink_path);
1036 assert!(
1037 matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
1038 "Expected PathOutsideWorkspace, got: {res:?}"
1039 );
1040 }
1041}