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();
465 loop {
466 if probe.join(".code-kb").exists() || probe.join(".git").exists() {
467 let canon = dunce::canonicalize(&probe).unwrap_or(probe);
468 return Ok(normalize_path(&canon));
469 }
470 if let Some(name) = probe.file_name().and_then(|n| n.to_str())
471 && is_hard_excluded(name)
472 {
473 break;
474 }
475 if let Some(parent) = probe.parent() {
476 if parent == probe {
477 break;
478 }
479 probe = parent.to_path_buf();
480 } else {
481 break;
482 }
483 }
484
485 let mut curr_marker = curr.clone();
487 loop {
488 if curr_marker.join("Cargo.toml").exists()
489 || curr_marker.join("package.json").exists()
490 || curr_marker.join("go.mod").exists()
491 || curr_marker.join("pyproject.toml").exists()
492 {
493 let canon = dunce::canonicalize(&curr_marker).unwrap_or(curr_marker);
494 return Ok(normalize_path(&canon));
495 }
496 if let Some(name) = curr_marker.file_name().and_then(|n| n.to_str())
497 && is_hard_excluded(name)
498 {
499 break;
500 }
501
502 if let Some(parent) = curr_marker.parent() {
503 if parent == curr_marker {
504 break;
505 }
506 curr_marker = parent.to_path_buf();
507 } else {
508 break;
509 }
510 }
511
512 let start_dir = if parsed.is_file() {
514 parsed.parent().unwrap_or(&parsed).to_path_buf()
515 } else {
516 parsed
517 };
518 let canon = dunce::canonicalize(&start_dir).unwrap_or(start_dir);
519 Ok(normalize_path(&canon))
520 }
521
522 pub fn resolve_path(&self, input: &Path) -> Result<(PathBuf, String), WorkspaceError> {
524 let raw_str = input.to_string_lossy();
525 let path = if raw_str.starts_with("file://") {
526 parse_file_uri(&raw_str).unwrap_or_else(|| input.to_path_buf())
527 } else {
528 input.to_path_buf()
529 };
530 let path = normalize_path(&path);
531
532 let is_abs = path.is_absolute()
533 || (cfg!(windows) && (path.to_string_lossy().chars().nth(1) == Some(':')));
534
535 let joined = if is_abs {
536 path
537 } else {
538 let rel_str = if cfg!(not(windows)) && path.to_string_lossy().contains('\\') {
539 path.to_string_lossy().replace('\\', "/")
540 } else {
541 path.to_string_lossy().to_string()
542 };
543 self.canonical_root.join(Path::new(&rel_str))
544 };
545
546 let cleaned = clean_path(&joined);
548 let abs_path = normalize_path(&cleaned);
549
550 let effective_abs = if abs_path.exists() {
552 dunce::canonicalize(&abs_path)
553 .map(|p| normalize_path(&p))
554 .unwrap_or_else(|_| abs_path.clone())
555 } else {
556 abs_path.clone()
557 };
558
559 let norm_root = dunce::canonicalize(&self.canonical_root)
560 .map(|p| normalize_path(&p))
561 .unwrap_or_else(|_| self.canonical_root.clone());
562
563 let rel = match strip_prefix_lossy(&effective_abs, &norm_root)
565 .or_else(|| strip_prefix_lossy(&effective_abs, &self.canonical_root))
566 .or_else(|| {
567 if !abs_path.exists() {
569 strip_prefix_lossy(&abs_path, &norm_root)
570 .or_else(|| strip_prefix_lossy(&abs_path, &self.canonical_root))
571 } else {
572 None
573 }
574 }) {
575 Some(r) => {
576 let forward = to_forward_slash(r);
577 if forward.starts_with("../") || forward == ".." {
578 return Err(WorkspaceError::PathOutsideWorkspace(
579 abs_path,
580 self.canonical_root.clone(),
581 ));
582 }
583 forward
584 }
585 None => {
586 return Err(WorkspaceError::PathOutsideWorkspace(
587 abs_path,
588 self.canonical_root.clone(),
589 ));
590 }
591 };
592
593 Ok((effective_abs, rel))
594 }
595
596 pub fn relativize_filter(&self, filter: &str) -> String {
599 let trimmed = filter.trim();
600 if trimmed.is_empty() {
601 return String::new();
602 }
603
604 let path_str = if trimmed.starts_with("file://") {
606 parse_file_uri(trimmed)
607 .map(|p| p.to_string_lossy().to_string())
608 .unwrap_or_else(|| trimmed.to_string())
609 } else {
610 trimmed.to_string()
611 };
612
613 let raw_path = Path::new(&path_str);
614 let simplified = dunce::simplified(raw_path);
615
616 if simplified.is_absolute() {
617 if let Ok((_, rel)) = self.resolve_path(simplified) {
618 return rel;
619 }
620 let norm_simplified = normalize_path(simplified);
622 let norm_root = normalize_path(&self.canonical_root);
623 if let Some(rel) = strip_prefix_lossy(&norm_simplified, &norm_root)
624 .or_else(|| strip_prefix_lossy(&norm_simplified, &self.root))
625 {
626 let forward = to_forward_slash(rel);
627 if !forward.starts_with("../") && forward != ".." {
628 return forward.trim_matches('/').to_string();
629 }
630 }
631 }
632
633 let cleaned = clean_path(Path::new(&path_str));
635 let forward = to_forward_slash(&cleaned);
636 let trimmed = forward.trim_start_matches("./").trim_matches('/');
637 if trimmed == "." {
638 String::new()
639 } else {
640 trimmed.to_string()
641 }
642 }
643
644 pub fn candidate_db_paths(&self, explicit_db: Option<&Path>) -> Vec<PathBuf> {
648 let mut candidates = Vec::new();
649
650 if let Some(p) = explicit_db {
651 candidates.push(normalize_path(p));
652 }
653
654 candidates.push(normalize_path(
656 &self.canonical_root.join(".code-kb").join("artifact.db"),
657 ));
658 candidates.push(normalize_path(
659 &self.canonical_root.join(".code-kb").join("store.db"),
660 ));
661 candidates.push(normalize_path(&self.canonical_root.join("artifact.db")));
662
663 candidates
664 }
665
666 pub fn locate_db(&self, explicit_db: Option<&Path>) -> Result<PathBuf, WorkspaceError> {
668 if let Some(p) = explicit_db {
669 return Ok(normalize_path(p));
670 }
671
672 let candidates = self.candidate_db_paths(None);
673 for candidate in &candidates {
674 if candidate.exists() && candidate.is_file() {
675 return Ok(normalize_path(candidate));
676 }
677 }
678
679 Ok(normalize_path(
680 &self.canonical_root.join(".code-kb").join("artifact.db"),
681 ))
682 }
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688
689 #[test]
690 #[cfg(windows)]
691 fn test_normalize_path() {
692 let p = PathBuf::from(r"\\?\C:\source\code-kb\src\main.rs");
693 let norm = normalize_path(&p);
694 assert!(!norm.to_string_lossy().starts_with(r"\\?\"));
695 }
696
697 #[test]
698 fn test_to_forward_slash() {
699 let p = PathBuf::from(r"src\models\mod.rs");
700 assert_eq!(to_forward_slash(&p), "src/models/mod.rs");
701 }
702
703 #[test]
704 fn test_workspace_resolve_path() {
705 let ws = Workspace::new(PathBuf::from("C:/source/test-project"));
706 let (abs, rel) = ws.resolve_path(Path::new("src/lib.rs")).unwrap();
707 assert_eq!(rel, "src/lib.rs");
708 assert!(abs.to_string_lossy().contains("test-project"));
709
710 #[cfg(windows)]
711 {
712 let (_abs2, rel2) = ws
714 .resolve_path(Path::new("c:/source/test-project/src/lib.rs"))
715 .unwrap();
716 assert_eq!(rel2, "src/lib.rs");
717
718 let (_abs3, rel3) = ws
720 .resolve_path(Path::new("C:/SOURCE/test-project/src/lib.rs"))
721 .unwrap();
722 assert_eq!(rel3, "src/lib.rs");
723
724 let (_abs4, rel4) = ws
726 .resolve_path(Path::new("file:///C:/source/test-project/src/lib.rs"))
727 .unwrap();
728 assert_eq!(rel4, "src/lib.rs");
729
730 let (_abs5, rel5) = ws
732 .resolve_path(Path::new("file:///c:/source/test-project/src/lib.rs"))
733 .unwrap();
734 assert_eq!(rel5, "src/lib.rs");
735 }
736 }
737
738 #[test]
739 fn test_workspace_resolve_path_traversal_escape() {
740 let temp = crate::safe_tempdir();
741 let ws = Workspace::new(temp.path().to_path_buf());
742 let res = ws.resolve_path(Path::new("sub/../../outside.rs"));
743 assert!(
744 matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
745 "Expected PathOutsideWorkspace error, but got: {:?}",
746 res
747 );
748 }
749
750 #[test]
751 fn test_parse_file_uri() {
752 #[cfg(windows)]
753 let (uri, expected) = ("file:///C:/my%20folder/project", "C:/my folder/project");
754 #[cfg(not(windows))]
755 let (uri, expected) = ("file:///tmp/my%20folder/project", "/tmp/my folder/project");
756
757 let p1 = parse_file_uri(uri).unwrap();
758 assert_eq!(p1, normalize_path(Path::new(expected)));
759
760 let p2 = parse_file_uri("C:/direct/path").unwrap();
762 assert_eq!(p2, normalize_path(Path::new("C:/direct/path")));
763 }
764
765 #[test]
766 fn test_relativize_filter() {
767 let temp = crate::safe_tempdir();
768 let ws = Workspace::new(temp.path().to_path_buf());
769
770 assert_eq!(ws.relativize_filter("."), "");
772 assert_eq!(ws.relativize_filter("./"), "");
773 assert_eq!(ws.relativize_filter("src/models"), "src/models");
774 assert_eq!(ws.relativize_filter("./src/models/"), "src/models");
775 assert_eq!(
776 ws.relativize_filter(r"src\models\mod.rs"),
777 "src/models/mod.rs"
778 );
779
780 assert_eq!(
782 ws.relativize_filter("src/../src/models/mod.rs"),
783 "src/models/mod.rs"
784 );
785
786 let abs_file = temp.path().join("src").join("lib.rs");
788 std::fs::create_dir_all(abs_file.parent().unwrap()).unwrap();
789 std::fs::write(&abs_file, "").unwrap();
790
791 assert_eq!(
792 ws.relativize_filter(&abs_file.to_string_lossy()),
793 "src/lib.rs"
794 );
795
796 let uri = format!("file://{}", abs_file.to_string_lossy().replace('\\', "/"));
798 assert_eq!(ws.relativize_filter(&uri), "src/lib.rs");
799
800 #[cfg(windows)]
801 {
802 let upper_abs = abs_file.to_string_lossy().to_uppercase();
804 assert_eq!(ws.relativize_filter(&upper_abs), "src/lib.rs");
805
806 let uri_cased = format!(
808 "file:///{}",
809 abs_file.to_string_lossy().replace('\\', "/").to_lowercase()
810 );
811 assert_eq!(ws.relativize_filter(&uri_cased), "src/lib.rs");
812 }
813 }
814
815 #[test]
816 fn test_paths_equal() {
817 assert!(paths_equal(
818 Path::new("src/lib.rs"),
819 Path::new("src/lib.rs")
820 ));
821 assert!(!paths_equal(
822 Path::new("src/lib.rs"),
823 Path::new("src/main.rs")
824 ));
825
826 #[cfg(windows)]
827 {
828 assert!(paths_equal(
830 Path::new(r"C:\source\code-kb\src\lib.rs"),
831 Path::new(r"c:\source\code-kb\src\lib.rs")
832 ));
833 assert!(paths_equal(
834 Path::new(r"C:\source\code-kb\src\lib.rs"),
835 Path::new(r"c:\SOURCE\CODE-KB\SRC\LIB.RS")
836 ));
837 assert!(paths_equal(
839 Path::new(r"\\?\C:\source\code-kb\src\lib.rs"),
840 Path::new(r"C:\source\code-kb\src\lib.rs")
841 ));
842 assert!(paths_equal(
843 Path::new(r"\\?\c:\source\code-kb\src\lib.rs"),
844 Path::new(r"C:\source\code-kb\src\lib.rs")
845 ));
846 assert!(paths_equal(
848 Path::new(r"\\server\share\file"),
849 Path::new(r"\\SERVER\SHARE\file")
850 ));
851 assert!(paths_equal(
852 Path::new(r"\\server\share\file"),
853 Path::new(r"\\server\share\file")
854 ));
855 assert!(!paths_equal(
856 Path::new(r"\\server\share1\file"),
857 Path::new(r"\\server\share2\file")
858 ));
859 }
860 }
861
862 #[test]
863 fn test_strip_prefix_lossy() {
864 let base = Path::new("src");
865 assert_eq!(
866 strip_prefix_lossy(Path::new("src/lib.rs"), base),
867 Some(Path::new("lib.rs"))
868 );
869 assert_eq!(strip_prefix_lossy(Path::new("tests/foo.rs"), base), None);
870
871 #[cfg(windows)]
872 {
873 let base_win = Path::new(r"C:\source\code-kb");
874 assert_eq!(
876 strip_prefix_lossy(Path::new(r"C:\source\code-kb\src\lib.rs"), base_win),
877 Some(Path::new(r"src\lib.rs"))
878 );
879 assert_eq!(
881 strip_prefix_lossy(Path::new(r"c:\source\code-kb\src\lib.rs"), base_win),
882 Some(Path::new(r"src\lib.rs"))
883 );
884 assert_eq!(
885 strip_prefix_lossy(Path::new(r"c:\SOURCE\CODE-KB\src\lib.rs"), base_win),
886 Some(Path::new(r"src\lib.rs"))
887 );
888 assert_eq!(
890 strip_prefix_lossy(Path::new(r"\\?\C:\source\code-kb\src\lib.rs"), base_win),
891 Some(Path::new(r"src\lib.rs"))
892 );
893 assert_eq!(
894 strip_prefix_lossy(Path::new(r"\\?\c:\source\code-kb\src\lib.rs"), base_win),
895 Some(Path::new(r"src\lib.rs"))
896 );
897 assert_eq!(
899 strip_prefix_lossy(Path::new(r"C:\other\code-kb\src\lib.rs"), base_win),
900 None
901 );
902 assert_eq!(
903 strip_prefix_lossy(Path::new(r"D:\source\code-kb\src\lib.rs"), base_win),
904 None
905 );
906 }
907 }
908
909 #[test]
910 fn test_parse_file_uri_two_slash_and_percent() {
911 #[cfg(windows)]
912 {
913 let p1 = parse_file_uri("file://C:/my%20folder/lib.rs").unwrap();
914 assert_eq!(p1, normalize_path(Path::new("C:/my folder/lib.rs")));
915
916 let p2 = parse_file_uri("file://c:/my%20folder/lib.rs").unwrap();
917 assert_eq!(p2, normalize_path(Path::new("c:/my folder/lib.rs")));
918
919 let p3 = parse_file_uri("file:///C:/my%20folder/lib.rs").unwrap();
920 assert_eq!(p3, normalize_path(Path::new("C:/my folder/lib.rs")));
921 }
922 #[cfg(not(windows))]
923 {
924 let p1 = parse_file_uri("file:///my%20folder/lib.rs").unwrap();
925 assert_eq!(p1, normalize_path(Path::new("/my folder/lib.rs")));
926 }
927 }
928
929 #[test]
930 fn test_workspace_verbatim_root_and_db_cleanup() {
931 let temp = crate::safe_tempdir();
932 let verbatim_path = format!(r"\\?\{}", temp.path().display());
933 let ws = Workspace::new(PathBuf::from(&verbatim_path));
934 assert!(!ws.root.to_string_lossy().starts_with(r"\\?\"));
935 assert!(!ws.canonical_root.to_string_lossy().starts_with(r"\\?\"));
936
937 let explicit = PathBuf::from(format!(r"\\?\{}\test.db", temp.path().display()));
938 let located = ws.locate_db(Some(&explicit)).unwrap();
939 assert!(!located.to_string_lossy().starts_with(r"\\?\"));
940 }
941
942 #[test]
943 fn test_trim_trailing_slash_edge_cases() {
944 assert_eq!(trim_trailing_slash(Path::new("/")), PathBuf::from("/"));
945 assert_eq!(trim_trailing_slash(Path::new("///")), PathBuf::from("/"));
946 assert_eq!(
947 trim_trailing_slash(Path::new("/a/b/")),
948 PathBuf::from("/a/b")
949 );
950 assert_eq!(
951 trim_trailing_slash(Path::new("foo/bar/")),
952 PathBuf::from("foo/bar")
953 );
954
955 #[cfg(windows)]
956 {
957 assert_eq!(
958 trim_trailing_slash(Path::new("C:\\")),
959 PathBuf::from("C:\\")
960 );
961 assert_eq!(trim_trailing_slash(Path::new("C:/")), PathBuf::from("C:\\"));
962 assert_eq!(
963 trim_trailing_slash(Path::new("C://")),
964 PathBuf::from("C:\\")
965 );
966 assert_eq!(
967 trim_trailing_slash(Path::new("C:\\\\")),
968 PathBuf::from("C:\\")
969 );
970 assert_eq!(
971 trim_trailing_slash(Path::new("C:/foo/")),
972 PathBuf::from("C:/foo")
973 );
974 }
975 }
976
977 #[test]
978 fn test_unicode_and_emoji_uri_safety() {
979 let p1 = parse_file_uri("file:///a๐/x");
981 assert!(p1.is_some());
982
983 let p2 = parse_file_uri("file:///c๐/x");
984 assert!(p2.is_some());
985
986 let p3 = parse_file_uri("file:///localhost๐/x");
987 assert!(p3.is_some());
988
989 let p4 = parse_file_uri("file://C:/๐๐/main.rs");
990 assert!(p4.is_some());
991 }
992
993 #[test]
994 #[cfg(unix)]
995 fn test_escaping_symlink_rejected() {
996 let ws_dir = crate::safe_tempdir();
997 let ext_dir = crate::safe_tempdir();
998
999 let ext_file = ext_dir.path().join("secret.txt");
1000 std::fs::write(&ext_file, "secret").unwrap();
1001
1002 let symlink_path = ws_dir.path().join("link.txt");
1003 std::os::unix::fs::symlink(&ext_file, &symlink_path).unwrap();
1004 let ws = Workspace::new(ws_dir.path().to_path_buf());
1005 let res = ws.resolve_path(&symlink_path);
1006 assert!(
1007 matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
1008 "Expected PathOutsideWorkspace, got: {res:?}"
1009 );
1010 }
1011}