Skip to main content

vtcode_commons/paths/
scopes.rs

1use super::canonicalize::canonicalize_workspace;
2use super::normalize::{expand_tilde, file_name_from_path, normalize_path};
3use super::safety::{is_safe_relative_path, validate_path_safety};
4use anyhow::Result;
5use std::path::{Path, PathBuf};
6
7/// Provides the root directories an application uses to store data.
8pub trait WorkspacePaths: Send + Sync {
9    /// Absolute path to the application's workspace root.
10    fn workspace_root(&self) -> &Path;
11
12    /// Returns the directory where configuration files should be stored.
13    fn config_dir(&self) -> PathBuf;
14
15    /// Returns an optional cache directory for transient data.
16    fn cache_dir(&self) -> Option<PathBuf> {
17        None
18    }
19
20    /// Returns an optional directory for telemetry or log artifacts.
21    fn telemetry_dir(&self) -> Option<PathBuf> {
22        None
23    }
24
25    /// Determine the [`PathScope`] for a given path based on workspace directories.
26    ///
27    /// Returns the most specific scope matching the path:
28    /// - `Workspace` if under `workspace_root()`
29    /// - `Config` if under `config_dir()`
30    /// - `Cache` if under `cache_dir()`
31    /// - `Telemetry` if under `telemetry_dir()`
32    /// - Falls back to `Cache` if no match
33    fn scope_for_path(&self, path: &Path) -> PathScope {
34        if path.starts_with(self.workspace_root()) {
35            return PathScope::Workspace;
36        }
37
38        let config_dir = self.config_dir();
39        if path.starts_with(&config_dir) {
40            return PathScope::Config;
41        }
42
43        if let Some(cache_dir) = self.cache_dir()
44            && path.starts_with(&cache_dir)
45        {
46            return PathScope::Cache;
47        }
48
49        if let Some(telemetry_dir) = self.telemetry_dir()
50            && path.starts_with(&telemetry_dir)
51        {
52            return PathScope::Telemetry;
53        }
54
55        PathScope::Cache
56    }
57}
58
59/// Helper trait that adds path resolution helpers on top of [`WorkspacePaths`].
60pub trait PathResolver: WorkspacePaths {
61    /// Resolve a path relative to the workspace root.
62    fn resolve<P>(&self, relative: P) -> PathBuf
63    where
64        P: AsRef<Path>,
65    {
66        self.workspace_root().join(relative)
67    }
68
69    /// Resolve a path within the configuration directory.
70    fn resolve_config<P>(&self, relative: P) -> PathBuf
71    where
72        P: AsRef<Path>,
73    {
74        self.config_dir().join(relative)
75    }
76}
77
78impl<T> PathResolver for T where T: WorkspacePaths + ?Sized {}
79
80/// Enumeration describing the conceptual scope of a file path.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum PathScope {
83    Workspace,
84    Config,
85    Cache,
86    Telemetry,
87}
88
89impl PathScope {
90    /// Returns a human-readable description used in error messages.
91    pub fn description(self) -> &'static str {
92        match self {
93            Self::Workspace => "workspace",
94            Self::Config => "configuration",
95            Self::Cache => "cache",
96            Self::Telemetry => "telemetry",
97        }
98    }
99}
100
101// ============================================================================
102// Extension Traits (Pattern 3: Extension Traits)
103// ============================================================================
104
105/// Extension trait that adds path normalization and safety methods to `Path`.
106///
107/// Delegates to the free functions in the `paths` module tree, providing a more
108/// ergonomic call-site syntax:
109///
110/// ```rust
111/// use vtcode_commons::paths::PathExt;
112/// use std::path::Path;
113///
114/// let normalized = Path::new("/tmp/project/src/../src/lib.rs").normalize();
115/// ```
116pub trait PathExt {
117    /// Normalize a path by resolving `.` and `..` components lexically.
118    fn normalize(&self) -> PathBuf;
119
120    /// Canonicalize with fallback to the original path if canonicalization fails.
121    fn canonicalize_or_self(&self) -> PathBuf;
122
123    /// Extract the filename from a path as a `String`, with fallback to the
124    /// full path when no filename component exists.
125    ///
126    /// Unlike [`Path::file_name`] which returns `Option<&OsStr>`, this method
127    /// always returns a `String` and falls back gracefully.
128    fn file_name_str(&self) -> String;
129}
130
131impl PathExt for Path {
132    fn normalize(&self) -> PathBuf {
133        normalize_path(self)
134    }
135
136    fn canonicalize_or_self(&self) -> PathBuf {
137        canonicalize_workspace(self)
138    }
139
140    fn file_name_str(&self) -> String {
141        self.file_name()
142            .and_then(|name| name.to_str())
143            .map(|s| s.to_string())
144            .unwrap_or_else(|| self.to_string_lossy().into_owned())
145    }
146}
147
148/// Extension trait that adds path-related methods to `str`.
149///
150/// Provides ergonomic access to tilde expansion and path safety checks:
151///
152/// ```rust
153/// use vtcode_commons::paths::StrPathExt;
154///
155/// let expanded = "~/projects/vtcode".expand_tilde();
156/// assert!(StrPathExt::is_safe_path("src/main.rs"));
157/// ```
158pub trait StrPathExt {
159    /// Expand a leading `~` or `~/` to the user's home directory.
160    fn expand_tilde(&self) -> PathBuf;
161
162    /// Check if this path string is a safe relative path (no traversal, no absolute).
163    fn is_safe_path(&self) -> bool;
164
165    /// Validate that this path is safe to use (no traversal, no dangerous characters).
166    fn validate_safety(&self) -> Result<()>;
167
168    /// Extract the filename from this path string.
169    fn file_name_str(&self) -> String;
170}
171
172impl StrPathExt for str {
173    fn expand_tilde(&self) -> PathBuf {
174        expand_tilde(self)
175    }
176
177    fn is_safe_path(&self) -> bool {
178        is_safe_relative_path(self)
179    }
180
181    fn validate_safety(&self) -> Result<()> {
182        validate_path_safety(self)
183    }
184
185    fn file_name_str(&self) -> String {
186        file_name_from_path(self)
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    struct StaticPaths {
195        root: PathBuf,
196        config: PathBuf,
197    }
198
199    impl WorkspacePaths for StaticPaths {
200        fn workspace_root(&self) -> &Path {
201            &self.root
202        }
203
204        fn config_dir(&self) -> PathBuf {
205            self.config.clone()
206        }
207
208        fn cache_dir(&self) -> Option<PathBuf> {
209            Some(self.root.join("cache"))
210        }
211    }
212
213    #[test]
214    fn resolves_relative_paths() {
215        let paths = StaticPaths {
216            root: PathBuf::from("/tmp/project"),
217            config: PathBuf::from("/tmp/project/config"),
218        };
219
220        assert_eq!(PathResolver::resolve(&paths, "subdir/file.txt"), PathBuf::from("/tmp/project/subdir/file.txt"));
221        assert_eq!(
222            PathResolver::resolve_config(&paths, "settings.toml"),
223            PathBuf::from("/tmp/project/config/settings.toml")
224        );
225        assert_eq!(paths.cache_dir(), Some(PathBuf::from("/tmp/project/cache")));
226    }
227}