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