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