Skip to main content

vtcode_commons/
paths.rs

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