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 log_dir(workspace_root: &Path) -> PathBuf {
190 workspace_root.join(".code-kb").join("logs")
191}
192
193pub fn log_files_newest_first(workspace_root: &Path) -> Vec<PathBuf> {
196 let mut files: Vec<(std::time::SystemTime, PathBuf)> =
197 std::fs::read_dir(log_dir(workspace_root))
198 .into_iter()
199 .flatten()
200 .flatten()
201 .filter(|entry| {
202 entry
203 .file_name()
204 .to_string_lossy()
205 .starts_with("code-kb.log")
206 })
207 .filter(|entry| entry.path().is_file())
208 .filter_map(|entry| {
209 let modified = entry.metadata().ok()?.modified().ok()?;
210 Some((modified, entry.path()))
211 })
212 .collect();
213 files.sort_by(|a, b| b.cmp(a));
214 files.into_iter().map(|(_, path)| path).collect()
215}
216
217pub fn latest_log_file(workspace_root: &Path) -> Option<PathBuf> {
219 log_files_newest_first(workspace_root).into_iter().next()
220}
221
222pub fn normalize_path(path: &Path) -> PathBuf {
224 let s = path.to_string_lossy();
225 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
226 let unc = format!(r"\\{rest}");
227 return dunce::simplified(Path::new(&unc)).to_path_buf();
228 }
229 if let Some(rest) = s.strip_prefix(r"\\?\") {
230 return dunce::simplified(Path::new(rest)).to_path_buf();
231 }
232 dunce::simplified(path).to_path_buf()
233}
234
235pub fn to_forward_slash(path: &Path) -> String {
237 let s = path.to_string_lossy();
238 s.replace('\\', "/")
239}
240
241#[cfg(windows)]
244fn components_equal(c1: &std::path::Component, c2: &std::path::Component) -> bool {
245 if c1 == c2 {
246 return true;
247 }
248 {
249 use std::path::Component;
250 match (c1, c2) {
251 (Component::Normal(s1), Component::Normal(s2)) => s1
252 .to_string_lossy()
253 .eq_ignore_ascii_case(&s2.to_string_lossy()),
254 (Component::Prefix(p1), Component::Prefix(p2)) => {
255 use std::path::Prefix;
256 match (p1.kind(), p2.kind()) {
257 (Prefix::Disk(d1), Prefix::Disk(d2))
258 | (Prefix::VerbatimDisk(d1), Prefix::VerbatimDisk(d2))
259 | (Prefix::Disk(d1), Prefix::VerbatimDisk(d2))
260 | (Prefix::VerbatimDisk(d1), Prefix::Disk(d2)) => d1.eq_ignore_ascii_case(&d2),
261 (Prefix::UNC(s1, sh1), Prefix::UNC(s2, sh2))
262 | (Prefix::VerbatimUNC(s1, sh1), Prefix::VerbatimUNC(s2, sh2))
263 | (Prefix::UNC(s1, sh1), Prefix::VerbatimUNC(s2, sh2))
264 | (Prefix::VerbatimUNC(s1, sh1), Prefix::UNC(s2, sh2)) => {
265 s1.to_string_lossy()
266 .eq_ignore_ascii_case(&s2.to_string_lossy())
267 && sh1
268 .to_string_lossy()
269 .eq_ignore_ascii_case(&sh2.to_string_lossy())
270 }
271 (Prefix::DeviceNS(d1), Prefix::DeviceNS(d2))
272 | (Prefix::Verbatim(d1), Prefix::Verbatim(d2)) => d1
273 .to_string_lossy()
274 .eq_ignore_ascii_case(&d2.to_string_lossy()),
275 _ => false,
276 }
277 }
278 _ => false,
279 }
280 }
281}
282
283pub fn strip_prefix_lossy<'a>(path: &'a Path, base: &Path) -> Option<&'a Path> {
286 if let Ok(rel) = path.strip_prefix(base) {
287 return Some(rel);
288 }
289
290 #[cfg(windows)]
291 {
292 let mut path_comps = path.components();
293 for base_comp in base.components() {
294 let path_comp = path_comps.next()?;
295 if !components_equal(&base_comp, &path_comp) {
296 return None;
297 }
298 }
299 Some(path_comps.as_path())
300 }
301 #[cfg(not(windows))]
302 {
303 None
304 }
305}
306
307pub fn paths_equal(p1: &Path, p2: &Path) -> bool {
311 let p1_norm = normalize_path(p1);
312 let p2_norm = normalize_path(p2);
313 if p1_norm == p2_norm {
314 return true;
315 }
316 if to_forward_slash(&p1_norm) == to_forward_slash(&p2_norm) {
317 return true;
318 }
319 if let (Ok(c1), Ok(c2)) = (dunce::canonicalize(p1), dunce::canonicalize(p2)) {
320 let c1_norm = normalize_path(&c1);
321 let c2_norm = normalize_path(&c2);
322 if c1_norm == c2_norm || to_forward_slash(&c1_norm) == to_forward_slash(&c2_norm) {
323 return true;
324 }
325 }
326 #[cfg(windows)]
327 {
328 let mut c1 = p1_norm.components();
329 let mut c2 = p2_norm.components();
330 loop {
331 match (c1.next(), c2.next()) {
332 (None, None) => return true,
333 (Some(comp1), Some(comp2)) => {
334 if !components_equal(&comp1, &comp2) {
335 return false;
336 }
337 }
338 _ => return false,
339 }
340 }
341 }
342 #[cfg(not(windows))]
343 {
344 false
345 }
346}
347
348pub fn is_hard_excluded(rel_path: &str) -> bool {
350 let p = rel_path.replace('\\', "/");
351 let has_excluded_dir = p.split('/').any(|component| {
352 matches!(
353 component,
354 ".git"
355 | ".hg"
356 | ".svn"
357 | ".julie"
358 | ".code-kb"
359 | ".memories"
360 | ".agents"
361 | ".razorback"
362 | ".worktrees"
363 | "worktrees"
364 | ".claude"
365 | ".venv"
366 | "venv"
367 | ".env"
368 | ".tox"
369 | ".vs"
370 | "node_modules"
371 | "vendor"
372 | "target"
373 | "dist"
374 | "build"
375 | ".cache"
376 | "obj"
377 | "TestResults"
378 | ".idea"
379 | ".vscode"
380 )
381 });
382
383 if has_excluded_dir {
384 return true;
385 }
386
387 const EXCLUDED_SUFFIXES: &[&str] = &[
388 ".min.js",
389 ".bundle.js",
390 ".generated.js",
391 ".generated.jsx",
392 ".generated.ts",
393 ".generated.tsx",
394 ".generated.d.ts",
395 ".tmp",
396 ".swp",
397 "~",
398 ];
399
400 EXCLUDED_SUFFIXES.iter().any(|suffix| p.ends_with(suffix))
401}
402
403#[derive(Debug, Clone)]
405pub struct Workspace {
406 pub root: PathBuf,
407 pub canonical_root: PathBuf,
408 pub repo_name: String,
409}
410
411fn trim_trailing_slash(p: &Path) -> PathBuf {
412 let s = p.to_string_lossy();
413 if s.len() > 1 && (s.ends_with('/') || s.ends_with('\\')) {
414 let trimmed = s.trim_end_matches(['/', '\\']);
415 if trimmed.is_empty() {
416 return PathBuf::from(if cfg!(windows) && s.starts_with('\\') {
417 "\\"
418 } else {
419 "/"
420 });
421 }
422 if cfg!(windows)
423 && trimmed.len() == 2
424 && trimmed.as_bytes()[0].is_ascii_alphabetic()
425 && trimmed.as_bytes()[1] == b':'
426 {
427 return PathBuf::from(format!("{}\\", trimmed));
428 }
429 return PathBuf::from(trimmed);
430 }
431 p.to_path_buf()
432}
433
434pub fn is_project_root(root: &Path) -> bool {
436 [
437 ".git",
438 "Cargo.toml",
439 "package.json",
440 "go.mod",
441 "pyproject.toml",
442 ]
443 .iter()
444 .any(|marker| root.join(marker).exists())
445}
446
447impl Workspace {
448 pub fn discover(start_path: Option<&Path>) -> Result<Self, WorkspaceError> {
450 let current = match start_path {
451 Some(p) => p.to_path_buf(),
452 None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
453 };
454
455 let root = Self::find_workspace_root(¤t)?;
456 Ok(Self::new(root))
457 }
458
459 pub fn new(root: PathBuf) -> Self {
461 let root_str = root.to_string_lossy();
462 let root = if root_str.starts_with("file://") {
463 parse_file_uri(&root_str).unwrap_or_else(|| normalize_path(&root))
464 } else {
465 normalize_path(&root)
466 };
467 let root = trim_trailing_slash(&root);
468 let canonical_root =
469 normalize_path(&dunce::canonicalize(&root).unwrap_or_else(|_| root.clone()));
470 let repo_name = canonical_root
471 .file_name()
472 .map(|n| n.to_string_lossy().to_string())
473 .unwrap_or_else(|| "repo".to_string());
474
475 Self {
476 root,
477 canonical_root,
478 repo_name,
479 }
480 }
481
482 pub fn find_workspace_root(start: &Path) -> Result<PathBuf, WorkspaceError> {
484 let raw = start.to_string_lossy();
485 let parsed = if raw.starts_with("file://") {
486 parse_file_uri(&raw).unwrap_or_else(|| start.to_path_buf())
487 } else {
488 start.to_path_buf()
489 };
490 let parsed = trim_trailing_slash(&parsed);
491 let curr = if parsed.is_file() {
492 parsed.parent().unwrap_or(&parsed).to_path_buf()
493 } else {
494 parsed.clone()
495 };
496
497 let mut probe = curr.clone();
500 loop {
501 if probe.join(".code-kb").join("artifact.db").exists() || probe.join(".git").exists() {
502 let canon = dunce::canonicalize(&probe).unwrap_or(probe);
503 return Ok(normalize_path(&canon));
504 }
505 if let Some(name) = probe.file_name().and_then(|n| n.to_str())
506 && is_hard_excluded(name)
507 {
508 break;
509 }
510 if let Some(parent) = probe.parent() {
511 if parent == probe {
512 break;
513 }
514 probe = parent.to_path_buf();
515 } else {
516 break;
517 }
518 }
519
520 let mut curr_marker = curr.clone();
522 loop {
523 if curr_marker.join("Cargo.toml").exists()
524 || curr_marker.join("package.json").exists()
525 || curr_marker.join("go.mod").exists()
526 || curr_marker.join("pyproject.toml").exists()
527 {
528 let canon = dunce::canonicalize(&curr_marker).unwrap_or(curr_marker);
529 return Ok(normalize_path(&canon));
530 }
531 if let Some(name) = curr_marker.file_name().and_then(|n| n.to_str())
532 && is_hard_excluded(name)
533 {
534 break;
535 }
536
537 if let Some(parent) = curr_marker.parent() {
538 if parent == curr_marker {
539 break;
540 }
541 curr_marker = parent.to_path_buf();
542 } else {
543 break;
544 }
545 }
546
547 let start_dir = if parsed.is_file() {
549 parsed.parent().unwrap_or(&parsed).to_path_buf()
550 } else {
551 parsed
552 };
553 let canon = dunce::canonicalize(&start_dir).unwrap_or(start_dir);
554 Ok(normalize_path(&canon))
555 }
556
557 pub fn resolve_path(&self, input: &Path) -> Result<(PathBuf, String), WorkspaceError> {
559 let raw_str = input.to_string_lossy();
560 let path = if raw_str.starts_with("file://") {
561 parse_file_uri(&raw_str).unwrap_or_else(|| input.to_path_buf())
562 } else {
563 input.to_path_buf()
564 };
565 let path = normalize_path(&path);
566
567 let is_abs = path.is_absolute()
568 || (cfg!(windows) && (path.to_string_lossy().chars().nth(1) == Some(':')));
569
570 let joined = if is_abs {
571 path
572 } else {
573 let rel_str = if cfg!(not(windows)) && path.to_string_lossy().contains('\\') {
574 path.to_string_lossy().replace('\\', "/")
575 } else {
576 path.to_string_lossy().to_string()
577 };
578 self.canonical_root.join(Path::new(&rel_str))
579 };
580
581 let cleaned = clean_path(&joined);
583 let abs_path = normalize_path(&cleaned);
584
585 let effective_abs = if abs_path.exists() {
587 dunce::canonicalize(&abs_path)
588 .map(|p| normalize_path(&p))
589 .unwrap_or_else(|_| abs_path.clone())
590 } else {
591 abs_path.clone()
592 };
593
594 let norm_root = dunce::canonicalize(&self.canonical_root)
595 .map(|p| normalize_path(&p))
596 .unwrap_or_else(|_| self.canonical_root.clone());
597
598 let rel = match strip_prefix_lossy(&effective_abs, &norm_root)
600 .or_else(|| strip_prefix_lossy(&effective_abs, &self.canonical_root))
601 .or_else(|| {
602 if !abs_path.exists() {
604 strip_prefix_lossy(&abs_path, &norm_root)
605 .or_else(|| strip_prefix_lossy(&abs_path, &self.canonical_root))
606 } else {
607 None
608 }
609 }) {
610 Some(r) => {
611 let forward = to_forward_slash(r);
612 if forward.starts_with("../") || forward == ".." {
613 return Err(WorkspaceError::PathOutsideWorkspace(
614 abs_path,
615 self.canonical_root.clone(),
616 ));
617 }
618 forward
619 }
620 None => {
621 return Err(WorkspaceError::PathOutsideWorkspace(
622 abs_path,
623 self.canonical_root.clone(),
624 ));
625 }
626 };
627
628 Ok((effective_abs, rel))
629 }
630
631 pub fn relativize_filter(&self, filter: &str) -> String {
634 let trimmed = filter.trim();
635 if trimmed.is_empty() {
636 return String::new();
637 }
638
639 let path_str = if trimmed.starts_with("file://") {
641 parse_file_uri(trimmed)
642 .map(|p| p.to_string_lossy().to_string())
643 .unwrap_or_else(|| trimmed.to_string())
644 } else {
645 trimmed.to_string()
646 };
647
648 let raw_path = Path::new(&path_str);
649 let simplified = dunce::simplified(raw_path);
650
651 if simplified.is_absolute() {
652 if let Ok((_, rel)) = self.resolve_path(simplified) {
653 return rel;
654 }
655 let norm_simplified = normalize_path(simplified);
657 let norm_root = normalize_path(&self.canonical_root);
658 if let Some(rel) = strip_prefix_lossy(&norm_simplified, &norm_root)
659 .or_else(|| strip_prefix_lossy(&norm_simplified, &self.root))
660 {
661 let forward = to_forward_slash(rel);
662 if !forward.starts_with("../") && forward != ".." {
663 return forward.trim_matches('/').to_string();
664 }
665 }
666 }
667
668 let cleaned = clean_path(Path::new(&path_str));
670 let forward = to_forward_slash(&cleaned);
671 let trimmed = forward.trim_start_matches("./").trim_matches('/');
672 if trimmed == "." {
673 String::new()
674 } else {
675 trimmed.to_string()
676 }
677 }
678
679 pub fn candidate_db_paths(&self, explicit_db: Option<&Path>) -> Vec<PathBuf> {
683 let mut candidates = Vec::new();
684
685 if let Some(p) = explicit_db {
686 candidates.push(normalize_path(p));
687 }
688
689 candidates.push(normalize_path(
691 &self.canonical_root.join(".code-kb").join("artifact.db"),
692 ));
693 candidates.push(normalize_path(
694 &self.canonical_root.join(".code-kb").join("store.db"),
695 ));
696 candidates.push(normalize_path(&self.canonical_root.join("artifact.db")));
697
698 candidates
699 }
700
701 pub fn locate_db(&self, explicit_db: Option<&Path>) -> Result<PathBuf, WorkspaceError> {
703 if let Some(p) = explicit_db {
704 return Ok(normalize_path(p));
705 }
706
707 let candidates = self.candidate_db_paths(None);
708 for candidate in &candidates {
709 if candidate.exists() && candidate.is_file() {
710 return Ok(normalize_path(candidate));
711 }
712 }
713
714 Ok(normalize_path(
715 &self.canonical_root.join(".code-kb").join("artifact.db"),
716 ))
717 }
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 #[test]
725 #[cfg(windows)]
726 fn test_normalize_path() {
727 let p = PathBuf::from(r"\\?\C:\source\code-kb\src\main.rs");
728 let norm = normalize_path(&p);
729 assert!(!norm.to_string_lossy().starts_with(r"\\?\"));
730 }
731
732 #[test]
733 fn test_find_workspace_root_ignores_ancestor_code_kb_without_index() {
734 let temp = crate::safe_tempdir();
735 let home = temp.path();
736 std::fs::create_dir_all(home.join(".code-kb")).unwrap();
737 std::fs::write(home.join(".code-kb").join("telemetry.db"), b"").unwrap();
738 let project = home.join("project");
739 std::fs::create_dir_all(&project).unwrap();
740 std::fs::write(project.join("Cargo.toml"), "[package]\n").unwrap();
741
742 let root = Workspace::find_workspace_root(&project).unwrap();
743
744 assert!(paths_equal(&root, &project), "{}", root.display());
745 }
746
747 #[test]
748 fn test_find_workspace_root_uses_ancestor_index() {
749 let temp = crate::safe_tempdir();
750 let repo = temp.path().join("repo");
751 std::fs::create_dir_all(repo.join(".code-kb")).unwrap();
752 std::fs::write(repo.join(".code-kb").join("artifact.db"), b"").unwrap();
753 let nested = repo.join("src").join("deep");
754 std::fs::create_dir_all(&nested).unwrap();
755
756 let root = Workspace::find_workspace_root(&nested).unwrap();
757
758 assert!(paths_equal(&root, &repo), "{}", root.display());
759 }
760
761 #[test]
762 fn test_to_forward_slash() {
763 let p = PathBuf::from(r"src\models\mod.rs");
764 assert_eq!(to_forward_slash(&p), "src/models/mod.rs");
765 }
766
767 #[test]
768 fn test_workspace_resolve_path() {
769 let ws = Workspace::new(PathBuf::from("C:/source/test-project"));
770 let (abs, rel) = ws.resolve_path(Path::new("src/lib.rs")).unwrap();
771 assert_eq!(rel, "src/lib.rs");
772 assert!(abs.to_string_lossy().contains("test-project"));
773
774 #[cfg(windows)]
775 {
776 let (_abs2, rel2) = ws
778 .resolve_path(Path::new("c:/source/test-project/src/lib.rs"))
779 .unwrap();
780 assert_eq!(rel2, "src/lib.rs");
781
782 let (_abs3, rel3) = ws
784 .resolve_path(Path::new("C:/SOURCE/test-project/src/lib.rs"))
785 .unwrap();
786 assert_eq!(rel3, "src/lib.rs");
787
788 let (_abs4, rel4) = ws
790 .resolve_path(Path::new("file:///C:/source/test-project/src/lib.rs"))
791 .unwrap();
792 assert_eq!(rel4, "src/lib.rs");
793
794 let (_abs5, rel5) = ws
796 .resolve_path(Path::new("file:///c:/source/test-project/src/lib.rs"))
797 .unwrap();
798 assert_eq!(rel5, "src/lib.rs");
799 }
800 }
801
802 #[test]
803 fn test_workspace_resolve_path_traversal_escape() {
804 let temp = crate::safe_tempdir();
805 let ws = Workspace::new(temp.path().to_path_buf());
806 let res = ws.resolve_path(Path::new("sub/../../outside.rs"));
807 assert!(
808 matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
809 "Expected PathOutsideWorkspace error, but got: {:?}",
810 res
811 );
812 }
813
814 #[test]
815 fn test_parse_file_uri() {
816 #[cfg(windows)]
817 let (uri, expected) = ("file:///C:/my%20folder/project", "C:/my folder/project");
818 #[cfg(not(windows))]
819 let (uri, expected) = ("file:///tmp/my%20folder/project", "/tmp/my folder/project");
820
821 let p1 = parse_file_uri(uri).unwrap();
822 assert_eq!(p1, normalize_path(Path::new(expected)));
823
824 let p2 = parse_file_uri("C:/direct/path").unwrap();
826 assert_eq!(p2, normalize_path(Path::new("C:/direct/path")));
827 }
828
829 #[test]
830 fn test_relativize_filter() {
831 let temp = crate::safe_tempdir();
832 let ws = Workspace::new(temp.path().to_path_buf());
833
834 assert_eq!(ws.relativize_filter("."), "");
836 assert_eq!(ws.relativize_filter("./"), "");
837 assert_eq!(ws.relativize_filter("src/models"), "src/models");
838 assert_eq!(ws.relativize_filter("./src/models/"), "src/models");
839 assert_eq!(
840 ws.relativize_filter(r"src\models\mod.rs"),
841 "src/models/mod.rs"
842 );
843
844 assert_eq!(
846 ws.relativize_filter("src/../src/models/mod.rs"),
847 "src/models/mod.rs"
848 );
849
850 let abs_file = temp.path().join("src").join("lib.rs");
852 std::fs::create_dir_all(abs_file.parent().unwrap()).unwrap();
853 std::fs::write(&abs_file, "").unwrap();
854
855 assert_eq!(
856 ws.relativize_filter(&abs_file.to_string_lossy()),
857 "src/lib.rs"
858 );
859
860 let uri = format!("file://{}", abs_file.to_string_lossy().replace('\\', "/"));
862 assert_eq!(ws.relativize_filter(&uri), "src/lib.rs");
863
864 #[cfg(windows)]
865 {
866 let upper_abs = abs_file.to_string_lossy().to_uppercase();
868 assert_eq!(ws.relativize_filter(&upper_abs), "src/lib.rs");
869
870 let uri_cased = format!(
872 "file:///{}",
873 abs_file.to_string_lossy().replace('\\', "/").to_lowercase()
874 );
875 assert_eq!(ws.relativize_filter(&uri_cased), "src/lib.rs");
876 }
877 }
878
879 #[test]
880 fn test_paths_equal() {
881 assert!(paths_equal(
882 Path::new("src/lib.rs"),
883 Path::new("src/lib.rs")
884 ));
885 assert!(!paths_equal(
886 Path::new("src/lib.rs"),
887 Path::new("src/main.rs")
888 ));
889
890 #[cfg(windows)]
891 {
892 assert!(paths_equal(
894 Path::new(r"C:\source\code-kb\src\lib.rs"),
895 Path::new(r"c:\source\code-kb\src\lib.rs")
896 ));
897 assert!(paths_equal(
898 Path::new(r"C:\source\code-kb\src\lib.rs"),
899 Path::new(r"c:\SOURCE\CODE-KB\SRC\LIB.RS")
900 ));
901 assert!(paths_equal(
903 Path::new(r"\\?\C:\source\code-kb\src\lib.rs"),
904 Path::new(r"C:\source\code-kb\src\lib.rs")
905 ));
906 assert!(paths_equal(
907 Path::new(r"\\?\c:\source\code-kb\src\lib.rs"),
908 Path::new(r"C:\source\code-kb\src\lib.rs")
909 ));
910 assert!(paths_equal(
912 Path::new(r"\\server\share\file"),
913 Path::new(r"\\SERVER\SHARE\file")
914 ));
915 assert!(paths_equal(
916 Path::new(r"\\server\share\file"),
917 Path::new(r"\\server\share\file")
918 ));
919 assert!(!paths_equal(
920 Path::new(r"\\server\share1\file"),
921 Path::new(r"\\server\share2\file")
922 ));
923 }
924 }
925
926 #[test]
927 fn test_strip_prefix_lossy() {
928 let base = Path::new("src");
929 assert_eq!(
930 strip_prefix_lossy(Path::new("src/lib.rs"), base),
931 Some(Path::new("lib.rs"))
932 );
933 assert_eq!(strip_prefix_lossy(Path::new("tests/foo.rs"), base), None);
934
935 #[cfg(windows)]
936 {
937 let base_win = Path::new(r"C:\source\code-kb");
938 assert_eq!(
940 strip_prefix_lossy(Path::new(r"C:\source\code-kb\src\lib.rs"), base_win),
941 Some(Path::new(r"src\lib.rs"))
942 );
943 assert_eq!(
945 strip_prefix_lossy(Path::new(r"c:\source\code-kb\src\lib.rs"), base_win),
946 Some(Path::new(r"src\lib.rs"))
947 );
948 assert_eq!(
949 strip_prefix_lossy(Path::new(r"c:\SOURCE\CODE-KB\src\lib.rs"), base_win),
950 Some(Path::new(r"src\lib.rs"))
951 );
952 assert_eq!(
954 strip_prefix_lossy(Path::new(r"\\?\C:\source\code-kb\src\lib.rs"), base_win),
955 Some(Path::new(r"src\lib.rs"))
956 );
957 assert_eq!(
958 strip_prefix_lossy(Path::new(r"\\?\c:\source\code-kb\src\lib.rs"), base_win),
959 Some(Path::new(r"src\lib.rs"))
960 );
961 assert_eq!(
963 strip_prefix_lossy(Path::new(r"C:\other\code-kb\src\lib.rs"), base_win),
964 None
965 );
966 assert_eq!(
967 strip_prefix_lossy(Path::new(r"D:\source\code-kb\src\lib.rs"), base_win),
968 None
969 );
970 }
971 }
972
973 #[test]
974 fn test_parse_file_uri_two_slash_and_percent() {
975 #[cfg(windows)]
976 {
977 let p1 = parse_file_uri("file://C:/my%20folder/lib.rs").unwrap();
978 assert_eq!(p1, normalize_path(Path::new("C:/my folder/lib.rs")));
979
980 let p2 = parse_file_uri("file://c:/my%20folder/lib.rs").unwrap();
981 assert_eq!(p2, normalize_path(Path::new("c:/my folder/lib.rs")));
982
983 let p3 = parse_file_uri("file:///C:/my%20folder/lib.rs").unwrap();
984 assert_eq!(p3, normalize_path(Path::new("C:/my folder/lib.rs")));
985 }
986 #[cfg(not(windows))]
987 {
988 let p1 = parse_file_uri("file:///my%20folder/lib.rs").unwrap();
989 assert_eq!(p1, normalize_path(Path::new("/my folder/lib.rs")));
990 }
991 }
992
993 #[test]
994 fn test_workspace_verbatim_root_and_db_cleanup() {
995 let temp = crate::safe_tempdir();
996 let verbatim_path = format!(r"\\?\{}", temp.path().display());
997 let ws = Workspace::new(PathBuf::from(&verbatim_path));
998 assert!(!ws.root.to_string_lossy().starts_with(r"\\?\"));
999 assert!(!ws.canonical_root.to_string_lossy().starts_with(r"\\?\"));
1000
1001 let explicit = PathBuf::from(format!(r"\\?\{}\test.db", temp.path().display()));
1002 let located = ws.locate_db(Some(&explicit)).unwrap();
1003 assert!(!located.to_string_lossy().starts_with(r"\\?\"));
1004 }
1005
1006 #[test]
1007 fn test_trim_trailing_slash_edge_cases() {
1008 assert_eq!(trim_trailing_slash(Path::new("/")), PathBuf::from("/"));
1009 assert_eq!(trim_trailing_slash(Path::new("///")), PathBuf::from("/"));
1010 assert_eq!(
1011 trim_trailing_slash(Path::new("/a/b/")),
1012 PathBuf::from("/a/b")
1013 );
1014 assert_eq!(
1015 trim_trailing_slash(Path::new("foo/bar/")),
1016 PathBuf::from("foo/bar")
1017 );
1018
1019 #[cfg(windows)]
1020 {
1021 assert_eq!(
1022 trim_trailing_slash(Path::new("C:\\")),
1023 PathBuf::from("C:\\")
1024 );
1025 assert_eq!(trim_trailing_slash(Path::new("C:/")), PathBuf::from("C:\\"));
1026 assert_eq!(
1027 trim_trailing_slash(Path::new("C://")),
1028 PathBuf::from("C:\\")
1029 );
1030 assert_eq!(
1031 trim_trailing_slash(Path::new("C:\\\\")),
1032 PathBuf::from("C:\\")
1033 );
1034 assert_eq!(
1035 trim_trailing_slash(Path::new("C:/foo/")),
1036 PathBuf::from("C:/foo")
1037 );
1038 }
1039 }
1040
1041 #[test]
1042 fn test_unicode_and_emoji_uri_safety() {
1043 let p1 = parse_file_uri("file:///a๐/x");
1045 assert!(p1.is_some());
1046
1047 let p2 = parse_file_uri("file:///c๐/x");
1048 assert!(p2.is_some());
1049
1050 let p3 = parse_file_uri("file:///localhost๐/x");
1051 assert!(p3.is_some());
1052
1053 let p4 = parse_file_uri("file://C:/๐๐/main.rs");
1054 assert!(p4.is_some());
1055 }
1056
1057 #[test]
1058 #[cfg(unix)]
1059 fn test_escaping_symlink_rejected() {
1060 let ws_dir = crate::safe_tempdir();
1061 let ext_dir = crate::safe_tempdir();
1062
1063 let ext_file = ext_dir.path().join("secret.txt");
1064 std::fs::write(&ext_file, "secret").unwrap();
1065
1066 let symlink_path = ws_dir.path().join("link.txt");
1067 std::os::unix::fs::symlink(&ext_file, &symlink_path).unwrap();
1068 let ws = Workspace::new(ws_dir.path().to_path_buf());
1069 let res = ws.resolve_path(&symlink_path);
1070 assert!(
1071 matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
1072 "Expected PathOutsideWorkspace, got: {res:?}"
1073 );
1074 }
1075}