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 | ".miller"
359 | ".code-kb"
360 | ".memories"
361 | ".agents"
362 | ".razorback"
363 | ".worktrees"
364 | "worktrees"
365 | ".claude"
366 | ".venv"
367 | "venv"
368 | ".env"
369 | ".tox"
370 | ".vs"
371 | "node_modules"
372 | "vendor"
373 | "target"
374 | "dist"
375 | "build"
376 | ".cache"
377 | "obj"
378 | "TestResults"
379 | ".idea"
380 | ".vscode"
381 )
382 });
383
384 if has_excluded_dir {
385 return true;
386 }
387
388 const EXCLUDED_SUFFIXES: &[&str] = &[
389 ".min.js",
390 ".bundle.js",
391 ".generated.js",
392 ".generated.jsx",
393 ".generated.ts",
394 ".generated.tsx",
395 ".generated.d.ts",
396 ".tmp",
397 ".swp",
398 "~",
399 ".db-wal",
400 ".db-shm",
401 ".sqlite-wal",
402 ".sqlite-shm",
403 ];
404
405 EXCLUDED_SUFFIXES.iter().any(|suffix| p.ends_with(suffix))
406}
407
408#[derive(Debug, Clone)]
410pub struct Workspace {
411 pub root: PathBuf,
412 pub canonical_root: PathBuf,
413 pub repo_name: String,
414}
415
416fn trim_trailing_slash(p: &Path) -> PathBuf {
417 let s = p.to_string_lossy();
418 if s.len() > 1 && (s.ends_with('/') || s.ends_with('\\')) {
419 let trimmed = s.trim_end_matches(['/', '\\']);
420 if trimmed.is_empty() {
421 return PathBuf::from(if cfg!(windows) && s.starts_with('\\') {
422 "\\"
423 } else {
424 "/"
425 });
426 }
427 if cfg!(windows)
428 && trimmed.len() == 2
429 && trimmed.as_bytes()[0].is_ascii_alphabetic()
430 && trimmed.as_bytes()[1] == b':'
431 {
432 return PathBuf::from(format!("{}\\", trimmed));
433 }
434 return PathBuf::from(trimmed);
435 }
436 p.to_path_buf()
437}
438
439pub fn is_project_root(root: &Path) -> bool {
441 [
442 ".git",
443 "Cargo.toml",
444 "package.json",
445 "go.mod",
446 "pyproject.toml",
447 ]
448 .iter()
449 .any(|marker| root.join(marker).exists())
450}
451
452impl Workspace {
453 pub fn discover(start_path: Option<&Path>) -> Result<Self, WorkspaceError> {
455 let current = match start_path {
456 Some(p) => p.to_path_buf(),
457 None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
458 };
459
460 let root = Self::find_workspace_root(¤t)?;
461 Ok(Self::new(root))
462 }
463
464 pub fn new(root: PathBuf) -> Self {
466 let root_str = root.to_string_lossy();
467 let root = if root_str.starts_with("file://") {
468 parse_file_uri(&root_str).unwrap_or_else(|| normalize_path(&root))
469 } else {
470 normalize_path(&root)
471 };
472 let root = trim_trailing_slash(&root);
473 let canonical_root =
474 normalize_path(&dunce::canonicalize(&root).unwrap_or_else(|_| root.clone()));
475 let repo_name = canonical_root
476 .file_name()
477 .map(|n| n.to_string_lossy().to_string())
478 .unwrap_or_else(|| "repo".to_string());
479
480 Self {
481 root,
482 canonical_root,
483 repo_name,
484 }
485 }
486
487 pub fn find_workspace_root(start: &Path) -> Result<PathBuf, WorkspaceError> {
489 let raw = start.to_string_lossy();
490 let parsed = if raw.starts_with("file://") {
491 parse_file_uri(&raw).unwrap_or_else(|| start.to_path_buf())
492 } else {
493 start.to_path_buf()
494 };
495 let parsed = trim_trailing_slash(&parsed);
496 let curr = if parsed.is_file() {
497 parsed.parent().unwrap_or(&parsed).to_path_buf()
498 } else {
499 parsed.clone()
500 };
501
502 let mut probe = curr.clone();
505 loop {
506 if probe.join(".code-kb").join("artifact.db").exists() || probe.join(".git").exists() {
507 let canon = dunce::canonicalize(&probe).unwrap_or(probe);
508 return Ok(normalize_path(&canon));
509 }
510 if let Some(name) = probe.file_name().and_then(|n| n.to_str())
511 && is_hard_excluded(name)
512 {
513 break;
514 }
515 if let Some(parent) = probe.parent() {
516 if parent == probe {
517 break;
518 }
519 probe = parent.to_path_buf();
520 } else {
521 break;
522 }
523 }
524
525 let mut curr_marker = curr.clone();
527 loop {
528 if curr_marker.join("Cargo.toml").exists()
529 || curr_marker.join("package.json").exists()
530 || curr_marker.join("go.mod").exists()
531 || curr_marker.join("pyproject.toml").exists()
532 {
533 let canon = dunce::canonicalize(&curr_marker).unwrap_or(curr_marker);
534 return Ok(normalize_path(&canon));
535 }
536 if let Some(name) = curr_marker.file_name().and_then(|n| n.to_str())
537 && is_hard_excluded(name)
538 {
539 break;
540 }
541
542 if let Some(parent) = curr_marker.parent() {
543 if parent == curr_marker {
544 break;
545 }
546 curr_marker = parent.to_path_buf();
547 } else {
548 break;
549 }
550 }
551
552 let start_dir = if parsed.is_file() {
554 parsed.parent().unwrap_or(&parsed).to_path_buf()
555 } else {
556 parsed
557 };
558 let canon = dunce::canonicalize(&start_dir).unwrap_or(start_dir);
559 Ok(normalize_path(&canon))
560 }
561
562 pub fn resolve_path(&self, input: &Path) -> Result<(PathBuf, String), WorkspaceError> {
564 let raw_str = input.to_string_lossy();
565 let path = if raw_str.starts_with("file://") {
566 parse_file_uri(&raw_str).unwrap_or_else(|| input.to_path_buf())
567 } else {
568 input.to_path_buf()
569 };
570 let path = normalize_path(&path);
571
572 let is_abs = path.is_absolute()
573 || (cfg!(windows) && (path.to_string_lossy().chars().nth(1) == Some(':')));
574
575 let joined = if is_abs {
576 path
577 } else {
578 let rel_str = if cfg!(not(windows)) && path.to_string_lossy().contains('\\') {
579 path.to_string_lossy().replace('\\', "/")
580 } else {
581 path.to_string_lossy().to_string()
582 };
583 self.canonical_root.join(Path::new(&rel_str))
584 };
585
586 let cleaned = clean_path(&joined);
588 let abs_path = normalize_path(&cleaned);
589
590 let effective_abs = if abs_path.exists() {
592 dunce::canonicalize(&abs_path)
593 .map(|p| normalize_path(&p))
594 .unwrap_or_else(|_| abs_path.clone())
595 } else {
596 abs_path.clone()
597 };
598
599 let norm_root = dunce::canonicalize(&self.canonical_root)
600 .map(|p| normalize_path(&p))
601 .unwrap_or_else(|_| self.canonical_root.clone());
602
603 let rel = match strip_prefix_lossy(&effective_abs, &norm_root)
605 .or_else(|| strip_prefix_lossy(&effective_abs, &self.canonical_root))
606 .or_else(|| {
607 if !abs_path.exists() {
609 strip_prefix_lossy(&abs_path, &norm_root)
610 .or_else(|| strip_prefix_lossy(&abs_path, &self.canonical_root))
611 } else {
612 None
613 }
614 }) {
615 Some(r) => {
616 let forward = to_forward_slash(r);
617 if forward.starts_with("../") || forward == ".." {
618 return Err(WorkspaceError::PathOutsideWorkspace(
619 abs_path,
620 self.canonical_root.clone(),
621 ));
622 }
623 forward
624 }
625 None => {
626 return Err(WorkspaceError::PathOutsideWorkspace(
627 abs_path,
628 self.canonical_root.clone(),
629 ));
630 }
631 };
632
633 Ok((effective_abs, rel))
634 }
635
636 pub fn relativize_filter(&self, filter: &str) -> String {
639 let trimmed = filter.trim();
640 if trimmed.is_empty() {
641 return String::new();
642 }
643
644 let path_str = if trimmed.starts_with("file://") {
646 parse_file_uri(trimmed)
647 .map(|p| p.to_string_lossy().to_string())
648 .unwrap_or_else(|| trimmed.to_string())
649 } else {
650 trimmed.to_string()
651 };
652
653 let raw_path = Path::new(&path_str);
654 let simplified = dunce::simplified(raw_path);
655
656 if simplified.is_absolute() {
657 if let Ok((_, rel)) = self.resolve_path(simplified) {
658 return rel;
659 }
660 let norm_simplified = normalize_path(simplified);
662 let norm_root = normalize_path(&self.canonical_root);
663 if let Some(rel) = strip_prefix_lossy(&norm_simplified, &norm_root)
664 .or_else(|| strip_prefix_lossy(&norm_simplified, &self.root))
665 {
666 let forward = to_forward_slash(rel);
667 if !forward.starts_with("../") && forward != ".." {
668 return forward.trim_matches('/').to_string();
669 }
670 }
671 }
672
673 let cleaned = clean_path(Path::new(&path_str));
675 let forward = to_forward_slash(&cleaned);
676 let trimmed = forward.trim_start_matches("./").trim_matches('/');
677 if trimmed == "." {
678 String::new()
679 } else {
680 trimmed.to_string()
681 }
682 }
683
684 pub fn candidate_db_paths(&self, explicit_db: Option<&Path>) -> Vec<PathBuf> {
688 let mut candidates = Vec::new();
689
690 if let Some(p) = explicit_db {
691 candidates.push(normalize_path(p));
692 }
693
694 candidates.push(normalize_path(
696 &self.canonical_root.join(".code-kb").join("artifact.db"),
697 ));
698 candidates.push(normalize_path(
699 &self.canonical_root.join(".code-kb").join("store.db"),
700 ));
701 candidates.push(normalize_path(&self.canonical_root.join("artifact.db")));
702
703 candidates
704 }
705
706 pub fn locate_db(&self, explicit_db: Option<&Path>) -> Result<PathBuf, WorkspaceError> {
708 if let Some(p) = explicit_db {
709 return Ok(normalize_path(p));
710 }
711
712 let candidates = self.candidate_db_paths(None);
713 for candidate in &candidates {
714 if candidate.exists() && candidate.is_file() {
715 return Ok(normalize_path(candidate));
716 }
717 }
718
719 Ok(normalize_path(
720 &self.canonical_root.join(".code-kb").join("artifact.db"),
721 ))
722 }
723}
724
725#[cfg(test)]
726mod tests {
727 #[test]
728 fn other_indexers_state_directories_are_hard_excluded() {
729 for dir in [".julie", ".miller", ".code-kb"] {
730 assert!(
731 super::is_hard_excluded(&format!("{dir}/state.lock")),
732 "{dir}"
733 );
734 }
735 assert!(!super::is_hard_excluded("src/miller.rs"));
736 }
737
738 use super::*;
739
740 #[test]
741 #[cfg(windows)]
742 fn test_normalize_path() {
743 let p = PathBuf::from(r"\\?\C:\source\code-kb\src\main.rs");
744 let norm = normalize_path(&p);
745 assert!(!norm.to_string_lossy().starts_with(r"\\?\"));
746 }
747
748 #[test]
749 fn test_find_workspace_root_ignores_ancestor_code_kb_without_index() {
750 let temp = crate::safe_tempdir();
751 let home = temp.path();
752 std::fs::create_dir_all(home.join(".code-kb")).unwrap();
753 std::fs::write(home.join(".code-kb").join("telemetry.db"), b"").unwrap();
754 let project = home.join("project");
755 std::fs::create_dir_all(&project).unwrap();
756 std::fs::write(project.join("Cargo.toml"), "[package]\n").unwrap();
757
758 let root = Workspace::find_workspace_root(&project).unwrap();
759
760 assert!(paths_equal(&root, &project), "{}", root.display());
761 }
762
763 #[test]
764 fn test_find_workspace_root_uses_ancestor_index() {
765 let temp = crate::safe_tempdir();
766 let repo = temp.path().join("repo");
767 std::fs::create_dir_all(repo.join(".code-kb")).unwrap();
768 std::fs::write(repo.join(".code-kb").join("artifact.db"), b"").unwrap();
769 let nested = repo.join("src").join("deep");
770 std::fs::create_dir_all(&nested).unwrap();
771
772 let root = Workspace::find_workspace_root(&nested).unwrap();
773
774 assert!(paths_equal(&root, &repo), "{}", root.display());
775 }
776
777 #[test]
778 fn test_to_forward_slash() {
779 let p = PathBuf::from(r"src\models\mod.rs");
780 assert_eq!(to_forward_slash(&p), "src/models/mod.rs");
781 }
782
783 #[test]
784 fn test_workspace_resolve_path() {
785 let ws = Workspace::new(PathBuf::from("C:/source/test-project"));
786 let (abs, rel) = ws.resolve_path(Path::new("src/lib.rs")).unwrap();
787 assert_eq!(rel, "src/lib.rs");
788 assert!(abs.to_string_lossy().contains("test-project"));
789
790 #[cfg(windows)]
791 {
792 let (_abs2, rel2) = ws
794 .resolve_path(Path::new("c:/source/test-project/src/lib.rs"))
795 .unwrap();
796 assert_eq!(rel2, "src/lib.rs");
797
798 let (_abs3, rel3) = ws
800 .resolve_path(Path::new("C:/SOURCE/test-project/src/lib.rs"))
801 .unwrap();
802 assert_eq!(rel3, "src/lib.rs");
803
804 let (_abs4, rel4) = ws
806 .resolve_path(Path::new("file:///C:/source/test-project/src/lib.rs"))
807 .unwrap();
808 assert_eq!(rel4, "src/lib.rs");
809
810 let (_abs5, rel5) = ws
812 .resolve_path(Path::new("file:///c:/source/test-project/src/lib.rs"))
813 .unwrap();
814 assert_eq!(rel5, "src/lib.rs");
815 }
816 }
817
818 #[test]
819 fn test_workspace_resolve_path_traversal_escape() {
820 let temp = crate::safe_tempdir();
821 let ws = Workspace::new(temp.path().to_path_buf());
822 let res = ws.resolve_path(Path::new("sub/../../outside.rs"));
823 assert!(
824 matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
825 "Expected PathOutsideWorkspace error, but got: {:?}",
826 res
827 );
828 }
829
830 #[test]
831 fn test_parse_file_uri() {
832 #[cfg(windows)]
833 let (uri, expected) = ("file:///C:/my%20folder/project", "C:/my folder/project");
834 #[cfg(not(windows))]
835 let (uri, expected) = ("file:///tmp/my%20folder/project", "/tmp/my folder/project");
836
837 let p1 = parse_file_uri(uri).unwrap();
838 assert_eq!(p1, normalize_path(Path::new(expected)));
839
840 let p2 = parse_file_uri("C:/direct/path").unwrap();
842 assert_eq!(p2, normalize_path(Path::new("C:/direct/path")));
843 }
844
845 #[test]
846 fn test_relativize_filter() {
847 let temp = crate::safe_tempdir();
848 let ws = Workspace::new(temp.path().to_path_buf());
849
850 assert_eq!(ws.relativize_filter("."), "");
852 assert_eq!(ws.relativize_filter("./"), "");
853 assert_eq!(ws.relativize_filter("src/models"), "src/models");
854 assert_eq!(ws.relativize_filter("./src/models/"), "src/models");
855 assert_eq!(
856 ws.relativize_filter(r"src\models\mod.rs"),
857 "src/models/mod.rs"
858 );
859
860 assert_eq!(
862 ws.relativize_filter("src/../src/models/mod.rs"),
863 "src/models/mod.rs"
864 );
865
866 let abs_file = temp.path().join("src").join("lib.rs");
868 std::fs::create_dir_all(abs_file.parent().unwrap()).unwrap();
869 std::fs::write(&abs_file, "").unwrap();
870
871 assert_eq!(
872 ws.relativize_filter(&abs_file.to_string_lossy()),
873 "src/lib.rs"
874 );
875
876 let uri = format!("file://{}", abs_file.to_string_lossy().replace('\\', "/"));
878 assert_eq!(ws.relativize_filter(&uri), "src/lib.rs");
879
880 #[cfg(windows)]
881 {
882 let upper_abs = abs_file.to_string_lossy().to_uppercase();
884 assert_eq!(ws.relativize_filter(&upper_abs), "src/lib.rs");
885
886 let uri_cased = format!(
888 "file:///{}",
889 abs_file.to_string_lossy().replace('\\', "/").to_lowercase()
890 );
891 assert_eq!(ws.relativize_filter(&uri_cased), "src/lib.rs");
892 }
893 }
894
895 #[test]
896 fn test_paths_equal() {
897 assert!(paths_equal(
898 Path::new("src/lib.rs"),
899 Path::new("src/lib.rs")
900 ));
901 assert!(!paths_equal(
902 Path::new("src/lib.rs"),
903 Path::new("src/main.rs")
904 ));
905
906 #[cfg(windows)]
907 {
908 assert!(paths_equal(
910 Path::new(r"C:\source\code-kb\src\lib.rs"),
911 Path::new(r"c:\source\code-kb\src\lib.rs")
912 ));
913 assert!(paths_equal(
914 Path::new(r"C:\source\code-kb\src\lib.rs"),
915 Path::new(r"c:\SOURCE\CODE-KB\SRC\LIB.RS")
916 ));
917 assert!(paths_equal(
919 Path::new(r"\\?\C:\source\code-kb\src\lib.rs"),
920 Path::new(r"C:\source\code-kb\src\lib.rs")
921 ));
922 assert!(paths_equal(
923 Path::new(r"\\?\c:\source\code-kb\src\lib.rs"),
924 Path::new(r"C:\source\code-kb\src\lib.rs")
925 ));
926 assert!(paths_equal(
928 Path::new(r"\\server\share\file"),
929 Path::new(r"\\SERVER\SHARE\file")
930 ));
931 assert!(paths_equal(
932 Path::new(r"\\server\share\file"),
933 Path::new(r"\\server\share\file")
934 ));
935 assert!(!paths_equal(
936 Path::new(r"\\server\share1\file"),
937 Path::new(r"\\server\share2\file")
938 ));
939 }
940 }
941
942 #[test]
943 fn test_strip_prefix_lossy() {
944 let base = Path::new("src");
945 assert_eq!(
946 strip_prefix_lossy(Path::new("src/lib.rs"), base),
947 Some(Path::new("lib.rs"))
948 );
949 assert_eq!(strip_prefix_lossy(Path::new("tests/foo.rs"), base), None);
950
951 #[cfg(windows)]
952 {
953 let base_win = Path::new(r"C:\source\code-kb");
954 assert_eq!(
956 strip_prefix_lossy(Path::new(r"C:\source\code-kb\src\lib.rs"), base_win),
957 Some(Path::new(r"src\lib.rs"))
958 );
959 assert_eq!(
961 strip_prefix_lossy(Path::new(r"c:\source\code-kb\src\lib.rs"), base_win),
962 Some(Path::new(r"src\lib.rs"))
963 );
964 assert_eq!(
965 strip_prefix_lossy(Path::new(r"c:\SOURCE\CODE-KB\src\lib.rs"), base_win),
966 Some(Path::new(r"src\lib.rs"))
967 );
968 assert_eq!(
970 strip_prefix_lossy(Path::new(r"\\?\C:\source\code-kb\src\lib.rs"), base_win),
971 Some(Path::new(r"src\lib.rs"))
972 );
973 assert_eq!(
974 strip_prefix_lossy(Path::new(r"\\?\c:\source\code-kb\src\lib.rs"), base_win),
975 Some(Path::new(r"src\lib.rs"))
976 );
977 assert_eq!(
979 strip_prefix_lossy(Path::new(r"C:\other\code-kb\src\lib.rs"), base_win),
980 None
981 );
982 assert_eq!(
983 strip_prefix_lossy(Path::new(r"D:\source\code-kb\src\lib.rs"), base_win),
984 None
985 );
986 }
987 }
988
989 #[test]
990 fn test_parse_file_uri_two_slash_and_percent() {
991 #[cfg(windows)]
992 {
993 let p1 = parse_file_uri("file://C:/my%20folder/lib.rs").unwrap();
994 assert_eq!(p1, normalize_path(Path::new("C:/my folder/lib.rs")));
995
996 let p2 = parse_file_uri("file://c:/my%20folder/lib.rs").unwrap();
997 assert_eq!(p2, normalize_path(Path::new("c:/my folder/lib.rs")));
998
999 let p3 = parse_file_uri("file:///C:/my%20folder/lib.rs").unwrap();
1000 assert_eq!(p3, normalize_path(Path::new("C:/my folder/lib.rs")));
1001 }
1002 #[cfg(not(windows))]
1003 {
1004 let p1 = parse_file_uri("file:///my%20folder/lib.rs").unwrap();
1005 assert_eq!(p1, normalize_path(Path::new("/my folder/lib.rs")));
1006 }
1007 }
1008
1009 #[test]
1010 fn test_workspace_verbatim_root_and_db_cleanup() {
1011 let temp = crate::safe_tempdir();
1012 let verbatim_path = format!(r"\\?\{}", temp.path().display());
1013 let ws = Workspace::new(PathBuf::from(&verbatim_path));
1014 assert!(!ws.root.to_string_lossy().starts_with(r"\\?\"));
1015 assert!(!ws.canonical_root.to_string_lossy().starts_with(r"\\?\"));
1016
1017 let explicit = PathBuf::from(format!(r"\\?\{}\test.db", temp.path().display()));
1018 let located = ws.locate_db(Some(&explicit)).unwrap();
1019 assert!(!located.to_string_lossy().starts_with(r"\\?\"));
1020 }
1021
1022 #[test]
1023 fn test_trim_trailing_slash_edge_cases() {
1024 assert_eq!(trim_trailing_slash(Path::new("/")), PathBuf::from("/"));
1025 assert_eq!(trim_trailing_slash(Path::new("///")), PathBuf::from("/"));
1026 assert_eq!(
1027 trim_trailing_slash(Path::new("/a/b/")),
1028 PathBuf::from("/a/b")
1029 );
1030 assert_eq!(
1031 trim_trailing_slash(Path::new("foo/bar/")),
1032 PathBuf::from("foo/bar")
1033 );
1034
1035 #[cfg(windows)]
1036 {
1037 assert_eq!(
1038 trim_trailing_slash(Path::new("C:\\")),
1039 PathBuf::from("C:\\")
1040 );
1041 assert_eq!(trim_trailing_slash(Path::new("C:/")), PathBuf::from("C:\\"));
1042 assert_eq!(
1043 trim_trailing_slash(Path::new("C://")),
1044 PathBuf::from("C:\\")
1045 );
1046 assert_eq!(
1047 trim_trailing_slash(Path::new("C:\\\\")),
1048 PathBuf::from("C:\\")
1049 );
1050 assert_eq!(
1051 trim_trailing_slash(Path::new("C:/foo/")),
1052 PathBuf::from("C:/foo")
1053 );
1054 }
1055 }
1056
1057 #[test]
1058 fn test_unicode_and_emoji_uri_safety() {
1059 let p1 = parse_file_uri("file:///a๐/x");
1061 assert!(p1.is_some());
1062
1063 let p2 = parse_file_uri("file:///c๐/x");
1064 assert!(p2.is_some());
1065
1066 let p3 = parse_file_uri("file:///localhost๐/x");
1067 assert!(p3.is_some());
1068
1069 let p4 = parse_file_uri("file://C:/๐๐/main.rs");
1070 assert!(p4.is_some());
1071 }
1072
1073 #[test]
1074 #[cfg(unix)]
1075 fn test_escaping_symlink_rejected() {
1076 let ws_dir = crate::safe_tempdir();
1077 let ext_dir = crate::safe_tempdir();
1078
1079 let ext_file = ext_dir.path().join("secret.txt");
1080 std::fs::write(&ext_file, "secret").unwrap();
1081
1082 let symlink_path = ws_dir.path().join("link.txt");
1083 std::os::unix::fs::symlink(&ext_file, &symlink_path).unwrap();
1084 let ws = Workspace::new(ws_dir.path().to_path_buf());
1085 let res = ws.resolve_path(&symlink_path);
1086 assert!(
1087 matches!(res, Err(WorkspaceError::PathOutsideWorkspace(..))),
1088 "Expected PathOutsideWorkspace, got: {res:?}"
1089 );
1090 }
1091}