Skip to main content

vtcode_commons/
paths.rs

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
10/// Normalize a path by resolving `.` and `..` components lexically.
11pub 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
27/// Expand a leading `~` or `~/` to the user's home directory. The function is
28/// intentionally forgiving: paths that don't start with `~` are returned as-is,
29/// and when the home directory cannot be determined the original path is
30/// preserved so callers can surface a downstream error rather than panicking.
31///
32/// This is the canonical implementation used by the tool registry and the
33/// sandbox runtime; both call sites previously carried near-identical copies.
34pub 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
46/// Canonicalize a filesystem path without the Windows `\\?\` verbatim prefix.
47///
48/// `std::fs::canonicalize` returns verbatim (`\\?\`) paths on Windows, which
49/// break path comparisons, round-tripping through user config, and some APIs
50/// that reject verbatim paths. `dunce::canonicalize` strips the prefix when it
51/// is safe to do so and is a perfect drop-in on non-Windows platforms.
52///
53/// This is the single canonical entry point for path canonicalization in the
54/// workspace. All call sites should use this (or `canonicalize_workspace`) rather
55/// than `std::fs::canonicalize` / `Path::canonicalize`, which are banned by
56/// `clippy.toml`'s `disallowed-methods`.
57pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
58    dunce::canonicalize(path)
59}
60
61/// Async canonicalize via `dunce`, using `spawn_blocking` to avoid blocking the
62/// runtime. Equivalent to `tokio::fs::canonicalize` but without the Windows
63/// `\\?\` verbatim prefix. Use this in async contexts instead of the sync
64/// [`canonicalize`] when the path may require I/O.
65pub 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
73/// Canonicalize a path with fallback to the original path if canonicalization fails.
74pub 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
85/// Resolve a path relative to a workspace root and ensure it stays within it.
86pub fn resolve_workspace_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
87    let candidate = if user_path.is_absolute() {
88        user_path.to_path_buf()
89    } else {
90        workspace_root.join(user_path)
91    };
92
93    let canonical =
94        canonicalize(&candidate).with_context(|| format!("Failed to canonicalize path {}", candidate.display()))?;
95
96    let workspace_canonical = canonicalize(workspace_root)
97        .with_context(|| format!("Failed to canonicalize workspace root {}", workspace_root.display()))?;
98
99    if !canonical.starts_with(&workspace_canonical) {
100        return Err(anyhow!("Path {} escapes workspace root {}", canonical.display(), workspace_canonical.display()));
101    }
102
103    Ok(canonical)
104}
105
106/// Return a canonicalised absolute path that is guaranteed to reside inside the
107/// provided `workspace_root`.  If the path is outside the workspace an error is
108/// returned.
109pub fn secure_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
110    // Resolve relative paths against the workspace root.
111    resolve_workspace_path(workspace_root, user_path)
112}
113
114/// Ensure a candidate path is inside the workspace root after lexical
115/// normalization.
116///
117/// This is the cheap, filesystem-free tier of workspace containment: it
118/// resolves `.`/`..` components lexically but does not follow symlinks. Use
119/// [`ensure_path_within_workspace_resolved`] when the candidate may traverse
120/// symlinks that point outside the workspace.
121///
122/// Returns the normalized candidate path on success.
123pub fn ensure_path_within_workspace(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
124    let normalized_candidate = normalize_path(candidate);
125    let normalized_workspace = normalize_path(workspace_root);
126
127    if !normalized_candidate.starts_with(&normalized_workspace) {
128        bail!("Path '{}' escapes workspace '{}'", candidate.display(), workspace_root.display());
129    }
130
131    Ok(normalized_candidate)
132}
133
134/// Ensure a candidate path is inside the workspace root, resolving symlinks
135/// component by component.
136///
137/// This is the strict, filesystem-aware tier of workspace containment. On top
138/// of the lexical check performed by [`ensure_path_within_workspace`], it
139/// walks each component of the candidate below the workspace root and:
140///
141/// - canonicalizes every existing component and verifies the resolved path
142///   still starts with the canonical workspace root (catches symlinks that
143///   point outside the workspace);
144/// - tolerates nonexistent tail components (paths about to be created);
145/// - rejects traversal through a file component (e.g. `file.txt/child`).
146///
147/// The candidate must already be lexically inside `workspace_root` (both
148/// sides are normalized before comparison).
149///
150/// Returns the normalized candidate path on success.
151pub async fn ensure_path_within_workspace_resolved(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
152    let normalized_root = normalize_path(workspace_root);
153    let normalized_candidate = normalize_path(candidate);
154
155    let canonical_root = match canonicalize_async(&normalized_root).await {
156        Ok(resolved) => resolved,
157        Err(error) => {
158            warn!(
159                path = %normalized_root.display(),
160                %error,
161                "Failed to canonicalize workspace root; falling back to provided path"
162            );
163            normalized_root.clone()
164        }
165    };
166
167    if normalized_root == normalized_candidate {
168        return Ok(normalized_candidate);
169    }
170
171    let relative = normalized_candidate
172        .strip_prefix(&normalized_root)
173        .map_err(|_error| anyhow!("path '{}' escapes the workspace root", candidate.display()))?
174        .to_path_buf();
175
176    let mut prefix = normalized_root.clone();
177    let mut components = relative.components().peekable();
178
179    while let Some(component) = components.next() {
180        prefix.push(component.as_os_str());
181
182        let metadata = match tokio::fs::symlink_metadata(&prefix).await {
183            Ok(metadata) => metadata,
184            Err(error) => {
185                if error.kind() == std::io::ErrorKind::NotFound {
186                    break;
187                }
188                return Err(error).with_context(|| format!("failed to inspect path component '{}'", prefix.display()));
189            }
190        };
191
192        let resolved = canonicalize_async(&prefix)
193            .await
194            .with_context(|| format!("failed to canonicalize path component '{}'", prefix.display()))?;
195
196        if metadata.file_type().is_symlink() {
197            if !resolved.starts_with(&canonical_root) {
198                return Err(anyhow!(
199                    "path '{}' escapes the workspace root via symlink '{}'",
200                    candidate.display(),
201                    prefix.display()
202                ));
203            }
204        } else {
205            if !resolved.starts_with(&canonical_root) {
206                return Err(anyhow!(
207                    "path '{}' escapes the workspace root via component '{}'",
208                    candidate.display(),
209                    prefix.display()
210                ));
211            }
212
213            if metadata.is_file() && components.peek().is_some() {
214                return Err(anyhow!(
215                    "path '{}' traverses through file component '{}'",
216                    candidate.display(),
217                    prefix.display()
218                ));
219            }
220        }
221    }
222
223    Ok(normalized_candidate)
224}
225
226/// Normalize identifiers to ASCII alphanumerics with lowercase output.
227pub fn normalize_ascii_identifier(value: &str) -> String {
228    let mut normalized = String::with_capacity(value.len());
229    for ch in value.chars() {
230        if ch.is_ascii_alphanumeric() {
231            normalized.push(ch.to_ascii_lowercase());
232        }
233    }
234    normalized
235}
236
237/// Check if a path string is a safe relative path (no traversal, no absolute).
238pub fn is_safe_relative_path(path: &str) -> bool {
239    let path = path.trim();
240    if path.is_empty() {
241        return false;
242    }
243
244    // Check for path traversal attempts
245    if path.contains("..") {
246        return false;
247    }
248
249    // Block absolute paths for security
250    if path.starts_with('/') || path.contains(':') {
251        return false;
252    }
253
254    true
255}
256
257/// Validates that a path is safe to use.
258/// Preventing traversal, absolute system paths, and dangerous characters.
259///
260/// Optimization: Uses early returns and byte-level checks for common patterns
261pub fn validate_path_safety(path: &str) -> Result<()> {
262    // Optimization: Fast path for empty or very short paths
263    if path.is_empty() {
264        return Ok(());
265    }
266
267    // Reject path traversal attempts
268    // Optimization: Use contains on bytes for simple patterns
269    if path.contains("..") {
270        bail!("Path traversal attempt detected ('..')");
271    }
272
273    // Additional traversal patterns
274    if path.contains("~/../") || path.contains("/.../") {
275        bail!("Advanced path traversal detected");
276    }
277
278    // Optimization: Only check Unix critical paths if path starts with '/'
279    if path.starts_with('/') {
280        // Reject absolute paths outside workspace
281        // Note: We can't strictly block all absolute paths as the agent might need to access
282        // explicitly allowed directories, but we can block obvious system critical paths.
283        static UNIX_CRITICAL: &[&str] = &["/etc", "/usr", "/bin", "/sbin", "/var", "/boot", "/root", "/dev"];
284        for prefix in UNIX_CRITICAL {
285            let is_var_temp_exception = *prefix == "/var"
286                && (path.starts_with("/var/folders/")
287                    || path == "/var/folders"
288                    || path.starts_with("/var/tmp/")
289                    || path == "/var/tmp");
290
291            if !is_var_temp_exception && matches_critical_prefix(path, prefix) {
292                bail!("Access to system directory denied: {prefix}");
293            }
294        }
295    }
296
297    // Windows critical paths
298    #[cfg(windows)]
299    {
300        let path_lower = path.to_lowercase();
301        static WIN_CRITICAL: &[&str] = &["c:\\windows", "c:\\program files", "c:\\system32"];
302        for prefix in WIN_CRITICAL {
303            if path_lower.starts_with(prefix) {
304                bail!("Access to Windows system directory denied");
305            }
306        }
307    }
308
309    // Reject dangerous shell characters in paths (including null byte)
310    // Optimization: Check bytes directly for faster character detection
311    static DANGEROUS_CHARS: &[u8] = b"$`|;&\n\r><\0";
312    for &c in path.as_bytes() {
313        if DANGEROUS_CHARS.contains(&c) {
314            bail!("Path contains dangerous shell characters");
315        }
316    }
317
318    Ok(())
319}
320
321fn matches_critical_prefix(path: &str, prefix: &str) -> bool {
322    path == prefix || path.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/'))
323}
324
325/// Extract the filename from a path, with fallback to the full path.
326pub fn file_name_from_path(path: &str) -> String {
327    Path::new(path)
328        .file_name()
329        .and_then(|name| name.to_str())
330        .map(|s| s.to_string())
331        .unwrap_or_else(|| path.to_string())
332}
333
334/// Canonicalize a path, walking up to find the nearest existing ancestor for new files.
335///
336/// This function handles paths to files that may not yet exist by finding the
337/// nearest existing parent directory, canonicalizing that, and then appending
338/// the remaining path components.
339///
340/// # Security
341/// This function is critical for security. It prevents symlink escapes by:
342/// 1. Finding the nearest existing ancestor directory
343/// 2. Canonicalizing that directory (resolves symlinks)
344/// 3. Appending the remaining path components
345///
346/// # Arguments
347/// * `normalized` - A normalized path (output from `normalize_path`)
348///
349/// # Returns
350/// The canonical path, or the normalized path if no parent exists
351pub async fn canonicalize_allow_missing(normalized: &Path) -> Result<PathBuf> {
352    // If the path exists, canonicalize it directly
353    if tokio::fs::try_exists(normalized).await.unwrap_or(false) {
354        return canonicalize_async(normalized)
355            .await
356            .map_err(|e| anyhow!("Failed to resolve canonical path for '{}': {}", normalized.display(), e));
357    }
358
359    // Walk up the directory tree to find the nearest existing ancestor
360    let mut current = normalized.to_path_buf();
361    while let Some(parent) = current.parent() {
362        if tokio::fs::try_exists(parent).await.unwrap_or(false) {
363            // Canonicalize the existing parent
364            let canonical_parent = canonicalize_async(parent)
365                .await
366                .map_err(|e| anyhow!("Failed to resolve canonical path for '{}': {}", parent.display(), e))?;
367
368            // Get the remaining path components
369            let remainder = normalized.strip_prefix(parent).unwrap_or_else(|_| Path::new(""));
370
371            // Return the canonical parent + remaining components
372            return if remainder.as_os_str().is_empty() {
373                Ok(canonical_parent)
374            } else {
375                Ok(canonical_parent.join(remainder))
376            };
377        }
378        current = parent.to_path_buf();
379    }
380
381    // No existing parent found, return normalized path as-is
382    Ok(normalized.to_path_buf())
383}
384
385/// Provides the root directories an application uses to store data.
386pub trait WorkspacePaths: Send + Sync {
387    /// Absolute path to the application's workspace root.
388    fn workspace_root(&self) -> &Path;
389
390    /// Returns the directory where configuration files should be stored.
391    fn config_dir(&self) -> PathBuf;
392
393    /// Returns an optional cache directory for transient data.
394    fn cache_dir(&self) -> Option<PathBuf> {
395        None
396    }
397
398    /// Returns an optional directory for telemetry or log artifacts.
399    fn telemetry_dir(&self) -> Option<PathBuf> {
400        None
401    }
402
403    /// Determine the [`PathScope`] for a given path based on workspace directories.
404    ///
405    /// Returns the most specific scope matching the path:
406    /// - `Workspace` if under `workspace_root()`
407    /// - `Config` if under `config_dir()`
408    /// - `Cache` if under `cache_dir()`
409    /// - `Telemetry` if under `telemetry_dir()`
410    /// - Falls back to `Cache` if no match
411    fn scope_for_path(&self, path: &Path) -> PathScope {
412        if path.starts_with(self.workspace_root()) {
413            return PathScope::Workspace;
414        }
415
416        let config_dir = self.config_dir();
417        if path.starts_with(&config_dir) {
418            return PathScope::Config;
419        }
420
421        if let Some(cache_dir) = self.cache_dir()
422            && path.starts_with(&cache_dir)
423        {
424            return PathScope::Cache;
425        }
426
427        if let Some(telemetry_dir) = self.telemetry_dir()
428            && path.starts_with(&telemetry_dir)
429        {
430            return PathScope::Telemetry;
431        }
432
433        PathScope::Cache
434    }
435}
436
437/// Helper trait that adds path resolution helpers on top of [`WorkspacePaths`].
438pub trait PathResolver: WorkspacePaths {
439    /// Resolve a path relative to the workspace root.
440    fn resolve<P>(&self, relative: P) -> PathBuf
441    where
442        P: AsRef<Path>,
443    {
444        self.workspace_root().join(relative)
445    }
446
447    /// Resolve a path within the configuration directory.
448    fn resolve_config<P>(&self, relative: P) -> PathBuf
449    where
450        P: AsRef<Path>,
451    {
452        self.config_dir().join(relative)
453    }
454}
455
456impl<T> PathResolver for T where T: WorkspacePaths + ?Sized {}
457
458/// Enumeration describing the conceptual scope of a file path.
459#[derive(Debug, Clone, Copy, PartialEq, Eq)]
460pub enum PathScope {
461    Workspace,
462    Config,
463    Cache,
464    Telemetry,
465}
466
467impl PathScope {
468    /// Returns a human-readable description used in error messages.
469    pub fn description(self) -> &'static str {
470        match self {
471            Self::Workspace => "workspace",
472            Self::Config => "configuration",
473            Self::Cache => "cache",
474            Self::Telemetry => "telemetry",
475        }
476    }
477}
478
479// ============================================================================
480// Extension Traits (Pattern 3: Extension Traits)
481// ============================================================================
482
483/// Extension trait that adds path normalization and safety methods to `Path`.
484///
485/// Delegates to the existing free functions in this module, providing a more
486/// ergonomic call-site syntax:
487///
488/// ```rust
489/// use vtcode_commons::paths::PathExt;
490/// use std::path::Path;
491///
492/// let normalized = Path::new("/tmp/project/src/../src/lib.rs").normalize();
493/// ```
494pub trait PathExt {
495    /// Normalize a path by resolving `.` and `..` components lexically.
496    fn normalize(&self) -> PathBuf;
497
498    /// Canonicalize with fallback to the original path if canonicalization fails.
499    fn canonicalize_or_self(&self) -> PathBuf;
500
501    /// Extract the filename from a path as a `String`, with fallback to the
502    /// full path when no filename component exists.
503    ///
504    /// Unlike [`Path::file_name`] which returns `Option<&OsStr>`, this method
505    /// always returns a `String` and falls back gracefully.
506    fn file_name_str(&self) -> String;
507}
508
509impl PathExt for Path {
510    fn normalize(&self) -> PathBuf {
511        normalize_path(self)
512    }
513
514    fn canonicalize_or_self(&self) -> PathBuf {
515        canonicalize_workspace(self)
516    }
517
518    fn file_name_str(&self) -> String {
519        self.file_name()
520            .and_then(|name| name.to_str())
521            .map(|s| s.to_string())
522            .unwrap_or_else(|| self.to_string_lossy().into_owned())
523    }
524}
525
526/// Extension trait that adds path-related methods to `str`.
527///
528/// Provides ergonomic access to tilde expansion and path safety checks:
529///
530/// ```rust
531/// use vtcode_commons::paths::StrPathExt;
532///
533/// let expanded = "~/projects/vtcode".expand_tilde();
534/// assert!(StrPathExt::is_safe_path("src/main.rs"));
535/// ```
536pub trait StrPathExt {
537    /// Expand a leading `~` or `~/` to the user's home directory.
538    fn expand_tilde(&self) -> PathBuf;
539
540    /// Check if this path string is a safe relative path (no traversal, no absolute).
541    fn is_safe_path(&self) -> bool;
542
543    /// Validate that this path is safe to use (no traversal, no dangerous characters).
544    fn validate_safety(&self) -> Result<()>;
545
546    /// Extract the filename from this path string.
547    fn file_name_str(&self) -> String;
548}
549
550impl StrPathExt for str {
551    fn expand_tilde(&self) -> PathBuf {
552        expand_tilde(self)
553    }
554
555    fn is_safe_path(&self) -> bool {
556        is_safe_relative_path(self)
557    }
558
559    fn validate_safety(&self) -> Result<()> {
560        validate_path_safety(self)
561    }
562
563    fn file_name_str(&self) -> String {
564        file_name_from_path(self)
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use std::path::{Path, PathBuf};
572
573    struct StaticPaths {
574        root: PathBuf,
575        config: PathBuf,
576    }
577
578    impl WorkspacePaths for StaticPaths {
579        fn workspace_root(&self) -> &Path {
580            &self.root
581        }
582
583        fn config_dir(&self) -> PathBuf {
584            self.config.clone()
585        }
586
587        fn cache_dir(&self) -> Option<PathBuf> {
588            Some(self.root.join("cache"))
589        }
590    }
591
592    #[test]
593    fn resolves_relative_paths() {
594        let paths = StaticPaths {
595            root: PathBuf::from("/tmp/project"),
596            config: PathBuf::from("/tmp/project/config"),
597        };
598
599        assert_eq!(PathResolver::resolve(&paths, "subdir/file.txt"), PathBuf::from("/tmp/project/subdir/file.txt"));
600        assert_eq!(
601            PathResolver::resolve_config(&paths, "settings.toml"),
602            PathBuf::from("/tmp/project/config/settings.toml")
603        );
604        assert_eq!(paths.cache_dir(), Some(PathBuf::from("/tmp/project/cache")));
605    }
606
607    #[test]
608    fn ensures_path_within_workspace_accepts_nested_path() {
609        let workspace = Path::new("/tmp/project");
610        let candidate = Path::new("/tmp/project/src/../src/lib.rs");
611        let normalized = ensure_path_within_workspace(candidate, workspace).unwrap();
612        assert_eq!(normalized, PathBuf::from("/tmp/project/src/lib.rs"));
613    }
614
615    #[test]
616    fn ensures_path_within_workspace_rejects_escape() {
617        let workspace = Path::new("/tmp/project");
618        let candidate = Path::new("/tmp/project/../../etc/passwd");
619        assert!(ensure_path_within_workspace(candidate, workspace).is_err());
620    }
621
622    #[tokio::test]
623    async fn resolved_check_accepts_nested_existing_path() {
624        let workspace = tempfile::tempdir().unwrap();
625        let root = canonicalize(workspace.path()).unwrap();
626        let nested = root.join("src");
627        tokio::fs::create_dir_all(&nested).await.unwrap();
628        let file = nested.join("lib.rs");
629        tokio::fs::write(&file, b"test").await.unwrap();
630
631        let result = ensure_path_within_workspace_resolved(&file, &root).await;
632        assert_eq!(result.unwrap(), file);
633    }
634
635    #[tokio::test]
636    async fn resolved_check_accepts_missing_tail_components() {
637        let workspace = tempfile::tempdir().unwrap();
638        let root = canonicalize(workspace.path()).unwrap();
639        let missing = root.join("new_dir/new_file.txt");
640
641        let result = ensure_path_within_workspace_resolved(&missing, &root).await;
642        assert_eq!(result.unwrap(), missing);
643    }
644
645    #[tokio::test]
646    async fn resolved_check_rejects_lexical_escape() {
647        let workspace = tempfile::tempdir().unwrap();
648        let root = canonicalize(workspace.path()).unwrap();
649        let escape = root.join("../outside.txt");
650
651        assert!(ensure_path_within_workspace_resolved(&escape, &root).await.is_err());
652    }
653
654    #[cfg(unix)]
655    #[tokio::test]
656    async fn resolved_check_rejects_symlink_escape() {
657        let workspace = tempfile::tempdir().unwrap();
658        let outside = tempfile::tempdir().unwrap();
659        let root = canonicalize(workspace.path()).unwrap();
660        let outside_dir = canonicalize(outside.path()).unwrap();
661
662        let link = root.join("escape");
663        tokio::fs::symlink(&outside_dir, &link).await.unwrap();
664
665        let candidate = link.join("secret.txt");
666        assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
667    }
668
669    #[cfg(unix)]
670    #[tokio::test]
671    async fn resolved_check_accepts_symlink_within_workspace() {
672        let workspace = tempfile::tempdir().unwrap();
673        let root = canonicalize(workspace.path()).unwrap();
674        let target = root.join("real");
675        tokio::fs::create_dir_all(&target).await.unwrap();
676        let link = root.join("alias");
677        tokio::fs::symlink(&target, &link).await.unwrap();
678
679        let candidate = link.join("file.txt");
680        assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_ok());
681    }
682
683    #[tokio::test]
684    async fn resolved_check_rejects_traversal_through_file() {
685        let workspace = tempfile::tempdir().unwrap();
686        let root = canonicalize(workspace.path()).unwrap();
687        let file = root.join("data.txt");
688        tokio::fs::write(&file, b"test").await.unwrap();
689
690        let candidate = file.join("child.txt");
691        assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
692    }
693
694    #[tokio::test]
695    async fn test_canonicalize_existing_file() {
696        // Create a temporary directory and file
697        let temp_dir = std::env::temp_dir();
698        let test_file = temp_dir.join("vtcode_test_existing.txt");
699        tokio::fs::write(&test_file, b"test").await.unwrap();
700
701        let canonical = canonicalize_allow_missing(&test_file).await.unwrap();
702
703        // Should get the canonical path
704        assert!(canonical.is_absolute());
705        assert!(canonical.exists());
706
707        // Cleanup
708        tokio::fs::remove_file(&test_file).await.ok();
709    }
710
711    #[tokio::test]
712    async fn test_canonicalize_missing_file() {
713        // Use a path that doesn't exist but has an existing parent
714        let temp_dir = std::env::temp_dir();
715        let missing_file = temp_dir.join("vtcode_test_missing_dir/missing_file.txt");
716
717        let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
718
719        // Should get canonical parent + missing components
720        assert!(canonical.is_absolute());
721        assert!(canonical.to_string_lossy().contains("missing_file.txt"));
722    }
723
724    #[tokio::test]
725    async fn test_canonicalize_deeply_missing_path() {
726        // Use a path with multiple missing parent directories
727        let temp_dir = std::env::temp_dir();
728        let deep_missing = temp_dir.join("vtcode_test_a/b/c/d/file.txt");
729
730        let canonical = canonicalize_allow_missing(&deep_missing).await.unwrap();
731
732        // Should get canonical temp_dir + missing components
733        assert!(canonical.is_absolute());
734        assert!(canonical.to_string_lossy().contains("vtcode_test_a"));
735    }
736
737    #[tokio::test]
738    async fn test_canonicalize_missing_file_with_existing_parent() {
739        // Create a parent directory
740        let temp_dir = std::env::temp_dir();
741        let test_dir = temp_dir.join("vtcode_test_parent");
742        tokio::fs::create_dir_all(&test_dir).await.unwrap();
743
744        let missing_file = test_dir.join("missing.txt");
745        let canonical = canonicalize_allow_missing(&missing_file).await.unwrap();
746
747        // Should get canonical parent + missing filename
748        assert!(canonical.is_absolute());
749        assert!(canonical.to_string_lossy().ends_with("missing.txt"));
750
751        // Cleanup
752        tokio::fs::remove_dir(&test_dir).await.ok();
753    }
754
755    #[test]
756    fn expand_tilde_passes_through_absolute_paths() {
757        let absolute = "/etc/hosts";
758        assert_eq!(expand_tilde(absolute), PathBuf::from(absolute));
759    }
760
761    #[test]
762    fn expand_tilde_passes_through_relative_paths() {
763        let relative = "src/main.rs";
764        assert_eq!(expand_tilde(relative), PathBuf::from(relative));
765    }
766
767    #[test]
768    fn expand_tilde_resolves_bare_tilde_to_home() {
769        if let Some(home) = dirs::home_dir() {
770            assert_eq!(expand_tilde("~"), home);
771        }
772    }
773
774    #[test]
775    fn expand_tilde_resolves_tilde_slash_prefix() {
776        if let Some(home) = dirs::home_dir() {
777            let resolved = expand_tilde("~/projects/vtcode");
778            assert_eq!(resolved, home.join("projects/vtcode"));
779        }
780    }
781}