1#![expect(
2 unused_results,
3 reason = "Path normalization intentionally ignores the boolean returned by pop after a validated component check."
4)]
5
6use anyhow::{Context, Result, anyhow, bail};
7use std::path::{Component, Path, PathBuf};
8use tracing::warn;
9
10pub fn normalize_path(path: &Path) -> PathBuf {
12 let mut normalized = PathBuf::new();
13 for component in path.components() {
14 match component {
15 Component::ParentDir => {
16 normalized.pop();
17 }
18 Component::CurDir => {}
19 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
20 Component::RootDir => normalized.push(component.as_os_str()),
21 Component::Normal(part) => normalized.push(part),
22 }
23 }
24 normalized
25}
26
27pub fn expand_tilde(path: &str) -> PathBuf {
35 if path == "~" {
36 return dirs::home_dir().unwrap_or_else(|| PathBuf::from(path));
37 }
38 if let Some(rest) = path.strip_prefix("~/")
39 && let Some(home) = dirs::home_dir()
40 {
41 return home.join(rest);
42 }
43 PathBuf::from(path)
44}
45
46pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
58 dunce::canonicalize(path)
59}
60
61pub async fn canonicalize_async(path: impl AsRef<Path> + Send) -> std::io::Result<PathBuf> {
66 let path = path.as_ref().to_path_buf();
67 match tokio::task::spawn_blocking(move || dunce::canonicalize(&path)).await {
68 Ok(inner) => inner,
69 Err(join_error) => Err(std::io::Error::other(join_error)),
70 }
71}
72
73pub fn canonicalize_workspace(workspace_root: &Path) -> PathBuf {
75 canonicalize(workspace_root).unwrap_or_else(|error| {
76 warn!(
77 path = %workspace_root.display(),
78 %error,
79 "Failed to canonicalize workspace root; falling back to provided path"
80 );
81 workspace_root.to_path_buf()
82 })
83}
84
85pub fn workspace_relative_display(workspace_root: &Path, path: &Path) -> String {
92 let candidate = if path.is_absolute() {
93 path.to_path_buf()
94 } else {
95 workspace_root.join(path)
96 };
97
98 if let Ok(canonical_workspace) = canonicalize(workspace_root) {
99 match canonicalize_for_display(&candidate) {
103 DisplayResolution::Resolved(canonical_candidate) => {
104 return canonical_candidate
105 .strip_prefix(&canonical_workspace)
106 .map(|relative| relative.to_string_lossy().into_owned())
107 .unwrap_or_else(|_| path.to_string_lossy().into_owned());
108 }
109 DisplayResolution::Unresolved => return path.to_string_lossy().into_owned(),
110 }
111 }
112
113 let normalized_candidate = normalize_path(&candidate);
117 let normalized_workspace = normalize_path(workspace_root);
118 if let Ok(relative) = normalized_candidate.strip_prefix(normalized_workspace) {
119 return relative.to_string_lossy().into_owned();
120 }
121 path.to_string_lossy().into_owned()
122}
123
124enum DisplayResolution {
125 Resolved(PathBuf),
126 Unresolved,
127}
128
129fn canonicalize_for_display(path: &Path) -> DisplayResolution {
130 if let Ok(canonical) = canonicalize(path) {
131 return DisplayResolution::Resolved(canonical);
132 }
133
134 let mut missing_tail = Vec::new();
135 let mut existing_prefix = path;
136 loop {
137 match std::fs::symlink_metadata(existing_prefix) {
138 Ok(_) => break,
139 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
140 let Some(file_name) = existing_prefix.file_name() else {
141 return DisplayResolution::Unresolved;
142 };
143 let Some(parent) = existing_prefix.parent() else {
144 return DisplayResolution::Unresolved;
145 };
146 missing_tail.push(PathBuf::from(file_name));
147 existing_prefix = parent;
148 }
149 Err(_) => return DisplayResolution::Unresolved,
150 }
151 }
152
153 let Ok(mut canonical) = canonicalize(existing_prefix) else {
154 return DisplayResolution::Unresolved;
157 };
158 for component in missing_tail.into_iter().rev() {
159 canonical.push(component);
160 }
161 DisplayResolution::Resolved(canonical)
162}
163
164pub fn resolve_workspace_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
166 let candidate = if user_path.is_absolute() {
167 user_path.to_path_buf()
168 } else {
169 workspace_root.join(user_path)
170 };
171
172 let canonical =
173 canonicalize(&candidate).with_context(|| format!("Failed to canonicalize path {}", candidate.display()))?;
174
175 let workspace_canonical = canonicalize(workspace_root)
176 .with_context(|| format!("Failed to canonicalize workspace root {}", workspace_root.display()))?;
177
178 if !canonical.starts_with(&workspace_canonical) {
179 return Err(anyhow!("Path {} escapes workspace root {}", canonical.display(), workspace_canonical.display()));
180 }
181
182 Ok(canonical)
183}
184
185pub fn secure_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
189 resolve_workspace_path(workspace_root, user_path)
191}
192
193pub fn ensure_path_within_workspace(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
203 let normalized_candidate = normalize_path(candidate);
204 let normalized_workspace = normalize_path(workspace_root);
205
206 if !normalized_candidate.starts_with(&normalized_workspace) {
207 bail!("Path '{}' escapes workspace '{}'", candidate.display(), workspace_root.display());
208 }
209
210 Ok(normalized_candidate)
211}
212
213pub async fn ensure_path_within_workspace_resolved(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
231 let normalized_root = normalize_path(workspace_root);
232 let normalized_candidate = normalize_path(candidate);
233
234 let canonical_root = match canonicalize_async(&normalized_root).await {
235 Ok(resolved) => resolved,
236 Err(error) => {
237 warn!(
238 path = %normalized_root.display(),
239 %error,
240 "Failed to canonicalize workspace root; falling back to provided path"
241 );
242 normalized_root.clone()
243 }
244 };
245
246 if normalized_root == normalized_candidate {
247 return Ok(normalized_candidate);
248 }
249
250 let relative = normalized_candidate
251 .strip_prefix(&normalized_root)
252 .map_err(|_error| anyhow!("path '{}' escapes the workspace root", candidate.display()))?
253 .to_path_buf();
254
255 let mut prefix = normalized_root.clone();
256 let mut components = relative.components().peekable();
257
258 while let Some(component) = components.next() {
259 prefix.push(component.as_os_str());
260
261 let metadata = match tokio::fs::symlink_metadata(&prefix).await {
262 Ok(metadata) => metadata,
263 Err(error) => {
264 if error.kind() == std::io::ErrorKind::NotFound {
265 break;
266 }
267 return Err(error).with_context(|| format!("failed to inspect path component '{}'", prefix.display()));
268 }
269 };
270
271 let resolved = canonicalize_async(&prefix)
272 .await
273 .with_context(|| format!("failed to canonicalize path component '{}'", prefix.display()))?;
274
275 if metadata.file_type().is_symlink() {
276 if !resolved.starts_with(&canonical_root) {
277 return Err(anyhow!(
278 "path '{}' escapes the workspace root via symlink '{}'",
279 candidate.display(),
280 prefix.display()
281 ));
282 }
283 } else {
284 if !resolved.starts_with(&canonical_root) {
285 return Err(anyhow!(
286 "path '{}' escapes the workspace root via component '{}'",
287 candidate.display(),
288 prefix.display()
289 ));
290 }
291
292 if metadata.is_file() && components.peek().is_some() {
293 return Err(anyhow!(
294 "path '{}' traverses through file component '{}'",
295 candidate.display(),
296 prefix.display()
297 ));
298 }
299 }
300 }
301
302 Ok(normalized_candidate)
303}
304
305pub fn normalize_ascii_identifier(value: &str) -> String {
307 let mut normalized = String::with_capacity(value.len());
308 for ch in value.chars() {
309 if ch.is_ascii_alphanumeric() {
310 normalized.push(ch.to_ascii_lowercase());
311 }
312 }
313 normalized
314}
315
316pub fn is_safe_relative_path(path: &str) -> bool {
318 let path = path.trim();
319 if path.is_empty() {
320 return false;
321 }
322
323 if path.contains("..") {
325 return false;
326 }
327
328 if path.starts_with('/') || path.contains(':') {
330 return false;
331 }
332
333 true
334}
335
336pub fn validate_path_safety(path: &str) -> Result<()> {
341 if path.is_empty() {
343 return Ok(());
344 }
345
346 if path.contains("..") {
349 bail!("Path traversal attempt detected ('..')");
350 }
351
352 if path.contains("~/../") || path.contains("/.../") {
354 bail!("Advanced path traversal detected");
355 }
356
357 if path.starts_with('/') {
359 static UNIX_CRITICAL: &[&str] = &["/etc", "/usr", "/bin", "/sbin", "/var", "/boot", "/root", "/dev"];
363 for prefix in UNIX_CRITICAL {
364 let is_var_temp_exception = *prefix == "/var"
365 && (path.starts_with("/var/folders/")
366 || path == "/var/folders"
367 || path.starts_with("/var/tmp/")
368 || path == "/var/tmp");
369
370 if !is_var_temp_exception && matches_critical_prefix(path, prefix) {
371 bail!("Access to system directory denied: {prefix}");
372 }
373 }
374 }
375
376 #[cfg(windows)]
378 {
379 let path_lower = path.to_lowercase();
380 static WIN_CRITICAL: &[&str] = &["c:\\windows", "c:\\program files", "c:\\system32"];
381 for prefix in WIN_CRITICAL {
382 if path_lower.starts_with(prefix) {
383 bail!("Access to Windows system directory denied");
384 }
385 }
386 }
387
388 static DANGEROUS_CHARS: &[u8] = b"$`|;&\n\r><\0";
391 for &c in path.as_bytes() {
392 if DANGEROUS_CHARS.contains(&c) {
393 bail!("Path contains dangerous shell characters");
394 }
395 }
396
397 Ok(())
398}
399
400fn matches_critical_prefix(path: &str, prefix: &str) -> bool {
401 path == prefix || path.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/'))
402}
403
404pub fn file_name_from_path(path: &str) -> String {
406 Path::new(path)
407 .file_name()
408 .and_then(|name| name.to_str())
409 .map(|s| s.to_string())
410 .unwrap_or_else(|| path.to_string())
411}
412
413pub async fn canonicalize_allow_missing(normalized: &Path) -> Result<PathBuf> {
431 if tokio::fs::try_exists(normalized).await.unwrap_or(false) {
433 return canonicalize_async(normalized)
434 .await
435 .map_err(|e| anyhow!("Failed to resolve canonical path for '{}': {}", normalized.display(), e));
436 }
437
438 let mut current = normalized.to_path_buf();
440 while let Some(parent) = current.parent() {
441 if tokio::fs::try_exists(parent).await.unwrap_or(false) {
442 let canonical_parent = canonicalize_async(parent)
444 .await
445 .map_err(|e| anyhow!("Failed to resolve canonical path for '{}': {}", parent.display(), e))?;
446
447 let remainder = normalized.strip_prefix(parent).unwrap_or_else(|_| Path::new(""));
449
450 return if remainder.as_os_str().is_empty() {
452 Ok(canonical_parent)
453 } else {
454 Ok(canonical_parent.join(remainder))
455 };
456 }
457 current = parent.to_path_buf();
458 }
459
460 Ok(normalized.to_path_buf())
462}
463
464pub trait WorkspacePaths: Send + Sync {
466 fn workspace_root(&self) -> &Path;
468
469 fn config_dir(&self) -> PathBuf;
471
472 fn cache_dir(&self) -> Option<PathBuf> {
474 None
475 }
476
477 fn telemetry_dir(&self) -> Option<PathBuf> {
479 None
480 }
481
482 fn scope_for_path(&self, path: &Path) -> PathScope {
491 if path.starts_with(self.workspace_root()) {
492 return PathScope::Workspace;
493 }
494
495 let config_dir = self.config_dir();
496 if path.starts_with(&config_dir) {
497 return PathScope::Config;
498 }
499
500 if let Some(cache_dir) = self.cache_dir()
501 && path.starts_with(&cache_dir)
502 {
503 return PathScope::Cache;
504 }
505
506 if let Some(telemetry_dir) = self.telemetry_dir()
507 && path.starts_with(&telemetry_dir)
508 {
509 return PathScope::Telemetry;
510 }
511
512 PathScope::Cache
513 }
514}
515
516pub trait PathResolver: WorkspacePaths {
518 fn resolve<P>(&self, relative: P) -> PathBuf
520 where
521 P: AsRef<Path>,
522 {
523 self.workspace_root().join(relative)
524 }
525
526 fn resolve_config<P>(&self, relative: P) -> PathBuf
528 where
529 P: AsRef<Path>,
530 {
531 self.config_dir().join(relative)
532 }
533}
534
535impl<T> PathResolver for T where T: WorkspacePaths + ?Sized {}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq)]
539pub enum PathScope {
540 Workspace,
541 Config,
542 Cache,
543 Telemetry,
544}
545
546impl PathScope {
547 pub fn description(self) -> &'static str {
549 match self {
550 Self::Workspace => "workspace",
551 Self::Config => "configuration",
552 Self::Cache => "cache",
553 Self::Telemetry => "telemetry",
554 }
555 }
556}
557
558pub trait PathExt {
574 fn normalize(&self) -> PathBuf;
576
577 fn canonicalize_or_self(&self) -> PathBuf;
579
580 fn file_name_str(&self) -> String;
586}
587
588impl PathExt for Path {
589 fn normalize(&self) -> PathBuf {
590 normalize_path(self)
591 }
592
593 fn canonicalize_or_self(&self) -> PathBuf {
594 canonicalize_workspace(self)
595 }
596
597 fn file_name_str(&self) -> String {
598 self.file_name()
599 .and_then(|name| name.to_str())
600 .map(|s| s.to_string())
601 .unwrap_or_else(|| self.to_string_lossy().into_owned())
602 }
603}
604
605pub trait StrPathExt {
616 fn expand_tilde(&self) -> PathBuf;
618
619 fn is_safe_path(&self) -> bool;
621
622 fn validate_safety(&self) -> Result<()>;
624
625 fn file_name_str(&self) -> String;
627}
628
629impl StrPathExt for str {
630 fn expand_tilde(&self) -> PathBuf {
631 expand_tilde(self)
632 }
633
634 fn is_safe_path(&self) -> bool {
635 is_safe_relative_path(self)
636 }
637
638 fn validate_safety(&self) -> Result<()> {
639 validate_path_safety(self)
640 }
641
642 fn file_name_str(&self) -> String {
643 file_name_from_path(self)
644 }
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650 use std::path::{Path, PathBuf};
651
652 struct StaticPaths {
653 root: PathBuf,
654 config: PathBuf,
655 }
656
657 impl WorkspacePaths for StaticPaths {
658 fn workspace_root(&self) -> &Path {
659 &self.root
660 }
661
662 fn config_dir(&self) -> PathBuf {
663 self.config.clone()
664 }
665
666 fn cache_dir(&self) -> Option<PathBuf> {
667 Some(self.root.join("cache"))
668 }
669 }
670
671 #[test]
672 fn resolves_relative_paths() {
673 let paths = StaticPaths {
674 root: PathBuf::from("/tmp/project"),
675 config: PathBuf::from("/tmp/project/config"),
676 };
677
678 assert_eq!(PathResolver::resolve(&paths, "subdir/file.txt"), PathBuf::from("/tmp/project/subdir/file.txt"));
679 assert_eq!(
680 PathResolver::resolve_config(&paths, "settings.toml"),
681 PathBuf::from("/tmp/project/config/settings.toml")
682 );
683 assert_eq!(paths.cache_dir(), Some(PathBuf::from("/tmp/project/cache")));
684 }
685
686 #[test]
687 fn workspace_relative_display_uses_workspace_relative_paths() {
688 let workspace = Path::new("/workspace");
689 let path = Path::new("/workspace/src/main.rs");
690
691 assert_eq!(workspace_relative_display(workspace, path), "src/main.rs");
692 }
693
694 #[test]
695 fn workspace_relative_display_preserves_external_paths() {
696 let workspace = Path::new("/workspace");
697 let path = Path::new("/tmp/external.txt");
698
699 assert_eq!(workspace_relative_display(workspace, path), "/tmp/external.txt");
700 }
701
702 #[test]
703 fn workspace_relative_display_handles_canonical_workspace_paths() {
704 let workspace = tempfile::tempdir().unwrap();
705 let workspace_path = canonicalize(workspace.path()).unwrap();
706 let path = workspace_path.join("docs").join("guide.md");
707
708 assert_eq!(workspace_relative_display(workspace.path(), &path), "docs/guide.md");
709 }
710
711 #[cfg(unix)]
712 #[test]
713 fn workspace_relative_display_resolves_workspace_aliases() {
714 use std::os::unix::fs::symlink;
715
716 let temp = tempfile::tempdir().unwrap();
717 let workspace = temp.path().join("workspace");
718 std::fs::create_dir_all(workspace.join("src")).unwrap();
719 let alias = temp.path().join("workspace-alias");
720 symlink(&workspace, &alias).unwrap();
721
722 let path = alias.join("src").join("main.rs");
723 assert_eq!(workspace_relative_display(&workspace, &path), "src/main.rs");
724 }
725
726 #[cfg(unix)]
727 #[test]
728 fn workspace_relative_display_preserves_symlink_escape() {
729 use std::os::unix::fs::symlink;
730
731 let temp = tempfile::tempdir().unwrap();
732 let workspace = temp.path().join("workspace");
733 let outside = temp.path().join("outside");
734 std::fs::create_dir_all(&workspace).unwrap();
735 std::fs::create_dir_all(&outside).unwrap();
736 std::fs::write(outside.join("secret.txt"), "secret").unwrap();
737 symlink(&outside, workspace.join("linked-outside")).unwrap();
738
739 let path = workspace.join("linked-outside").join("secret.txt");
740 assert_eq!(workspace_relative_display(&workspace, &path), path.to_string_lossy());
741 }
742
743 #[cfg(unix)]
744 #[test]
745 fn workspace_relative_display_fails_closed_for_dangling_symlink() {
746 use std::os::unix::fs::symlink;
747
748 let temp = tempfile::tempdir().unwrap();
749 let workspace = temp.path().join("workspace");
750 std::fs::create_dir_all(&workspace).unwrap();
751 let path = workspace.join("dangling-link");
752 symlink(temp.path().join("missing-target"), &path).unwrap();
753
754 assert_eq!(workspace_relative_display(&workspace, &path), path.to_string_lossy());
755 }
756
757 #[test]
758 fn ensures_path_within_workspace_accepts_nested_path() {
759 let workspace = Path::new("/tmp/project");
760 let candidate = Path::new("/tmp/project/src/../src/lib.rs");
761 let normalized = ensure_path_within_workspace(candidate, workspace).unwrap();
762 assert_eq!(normalized, PathBuf::from("/tmp/project/src/lib.rs"));
763 }
764
765 #[test]
766 fn ensures_path_within_workspace_rejects_escape() {
767 let workspace = Path::new("/tmp/project");
768 let candidate = Path::new("/tmp/project/../../etc/passwd");
769 assert!(ensure_path_within_workspace(candidate, workspace).is_err());
770 }
771
772 #[tokio::test]
773 async fn resolved_check_accepts_nested_existing_path() {
774 let workspace = tempfile::tempdir().unwrap();
775 let root = canonicalize(workspace.path()).unwrap();
776 let nested = root.join("src");
777 tokio::fs::create_dir_all(&nested).await.unwrap();
778 let file = nested.join("lib.rs");
779 tokio::fs::write(&file, b"test").await.unwrap();
780
781 let result = ensure_path_within_workspace_resolved(&file, &root).await;
782 assert_eq!(result.unwrap(), file);
783 }
784
785 #[tokio::test]
786 async fn resolved_check_accepts_missing_tail_components() {
787 let workspace = tempfile::tempdir().unwrap();
788 let root = canonicalize(workspace.path()).unwrap();
789 let missing = root.join("new_dir/new_file.txt");
790
791 let result = ensure_path_within_workspace_resolved(&missing, &root).await;
792 assert_eq!(result.unwrap(), missing);
793 }
794
795 #[tokio::test]
796 async fn resolved_check_rejects_lexical_escape() {
797 let workspace = tempfile::tempdir().unwrap();
798 let root = canonicalize(workspace.path()).unwrap();
799 let escape = root.join("../outside.txt");
800
801 assert!(ensure_path_within_workspace_resolved(&escape, &root).await.is_err());
802 }
803
804 #[cfg(unix)]
805 #[tokio::test]
806 async fn resolved_check_rejects_symlink_escape() {
807 let workspace = tempfile::tempdir().unwrap();
808 let outside = tempfile::tempdir().unwrap();
809 let root = canonicalize(workspace.path()).unwrap();
810 let outside_dir = canonicalize(outside.path()).unwrap();
811
812 let link = root.join("escape");
813 tokio::fs::symlink(&outside_dir, &link).await.unwrap();
814
815 let candidate = link.join("secret.txt");
816 assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
817 }
818
819 #[cfg(unix)]
820 #[tokio::test]
821 async fn resolved_check_accepts_symlink_within_workspace() {
822 let workspace = tempfile::tempdir().unwrap();
823 let root = canonicalize(workspace.path()).unwrap();
824 let target = root.join("real");
825 tokio::fs::create_dir_all(&target).await.unwrap();
826 let link = root.join("alias");
827 tokio::fs::symlink(&target, &link).await.unwrap();
828
829 let candidate = link.join("file.txt");
830 assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_ok());
831 }
832
833 #[tokio::test]
834 async fn resolved_check_rejects_traversal_through_file() {
835 let workspace = tempfile::tempdir().unwrap();
836 let root = canonicalize(workspace.path()).unwrap();
837 let file = root.join("data.txt");
838 tokio::fs::write(&file, b"test").await.unwrap();
839
840 let candidate = file.join("child.txt");
841 assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
842 }
843
844 #[tokio::test]
845 async fn test_canonicalize_existing_file() {
846 let temp_dir = std::env::temp_dir();
848 let test_file = temp_dir.join("vtcode_test_existing.txt");
849 tokio::fs::write(&test_file, b"test").await.unwrap();
850
851 let canonical = canonicalize_allow_missing(&test_file).await.unwrap();
852
853 assert!(canonical.is_absolute());
855 assert!(canonical.exists());
856
857 tokio::fs::remove_file(&test_file).await.ok();
859 }
860
861 #[tokio::test]
862 async fn test_canonicalize_missing_file() {
863 let temp_dir = std::env::temp_dir();
865 let missing_file = temp_dir.join("vtcode_test_missing_dir/missing_file.txt");
866
867 let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
868
869 assert!(canonical.is_absolute());
871 assert!(canonical.to_string_lossy().contains("missing_file.txt"));
872 }
873
874 #[tokio::test]
875 async fn test_canonicalize_deeply_missing_path() {
876 let temp_dir = std::env::temp_dir();
878 let deep_missing = temp_dir.join("vtcode_test_a/b/c/d/file.txt");
879
880 let canonical = canonicalize_allow_missing(&deep_missing).await.unwrap();
881
882 assert!(canonical.is_absolute());
884 assert!(canonical.to_string_lossy().contains("vtcode_test_a"));
885 }
886
887 #[tokio::test]
888 async fn test_canonicalize_missing_file_with_existing_parent() {
889 let temp_dir = std::env::temp_dir();
891 let test_dir = temp_dir.join("vtcode_test_parent");
892 tokio::fs::create_dir_all(&test_dir).await.unwrap();
893
894 let missing_file = test_dir.join("missing.txt");
895 let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
896
897 assert!(canonical.is_absolute());
899 assert!(canonical.to_string_lossy().ends_with("missing.txt"));
900
901 tokio::fs::remove_dir(&test_dir).await.ok();
903 }
904
905 #[test]
906 fn expand_tilde_passes_through_absolute_paths() {
907 let absolute = "/etc/hosts";
908 assert_eq!(expand_tilde(absolute), PathBuf::from(absolute));
909 }
910
911 #[test]
912 fn expand_tilde_passes_through_relative_paths() {
913 let relative = "src/main.rs";
914 assert_eq!(expand_tilde(relative), PathBuf::from(relative));
915 }
916
917 #[test]
918 fn expand_tilde_resolves_bare_tilde_to_home() {
919 if let Some(home) = dirs::home_dir() {
920 assert_eq!(expand_tilde("~"), home);
921 }
922 }
923
924 #[test]
925 fn expand_tilde_resolves_tilde_slash_prefix() {
926 if let Some(home) = dirs::home_dir() {
927 let resolved = expand_tilde("~/projects/vtcode");
928 assert_eq!(resolved, home.join("projects/vtcode"));
929 }
930 }
931}