Skip to main content

tycode_core/file/
resolver.rs

1use std::{
2    collections::HashMap,
3    ffi::OsString,
4    path::{Component, Path, PathBuf},
5};
6
7use anyhow::bail;
8
9#[derive(Debug, Clone)]
10pub struct ResolvedPath {
11    pub workspace: String,
12    pub virtual_path: PathBuf,
13    pub real_path: PathBuf,
14}
15
16/// Responsible for mapping to and from the virtual file system we present to
17/// AI agents where each workspace is in a root file system.
18#[derive(Debug, Clone)]
19pub struct Resolver {
20    workspaces: HashMap<String, PathBuf>,
21}
22
23impl Resolver {
24    /// Create a new PathResolver with the given workspace roots.
25    /// Non-existent directories are skipped with a warning (handles VSCode multi-workspace deletion).
26    pub fn new(workspace_roots: Vec<PathBuf>) -> anyhow::Result<Self> {
27        let mut workspaces = HashMap::new();
28        for workspace_root in workspace_roots {
29            // VSCode multi-workspace scenarios can have folder references persist after deletion on disk
30            if !workspace_root.exists() {
31                tracing::warn!(
32                    "Workspace root does not exist, skipping: {}",
33                    workspace_root.display()
34                );
35                continue;
36            }
37
38            let workspace_root = workspace_root.canonicalize()?;
39            let Some(name) = workspace_root.file_name() else {
40                bail!("Cannot get workspace name for {workspace_root:?}");
41            };
42
43            let name = os_to_string(name.to_os_string())?;
44            workspaces.insert(name, workspace_root);
45        }
46        Ok(Self { workspaces })
47    }
48
49    /// Resolves a path in the virtual file system to the real path on disk
50    pub fn resolve_path(&self, path_str: &str) -> anyhow::Result<ResolvedPath> {
51        let virtual_path = PathBuf::from(path_str);
52        let root = root(&virtual_path)?;
53        let relative = remaining(&virtual_path);
54
55        if let Some(workspace) = self.workspaces.get(&root) {
56            let virtual_path = PathBuf::from("/").join(&root).join(&relative);
57            let real_path = workspace.join(relative);
58            return Ok(ResolvedPath {
59                workspace: root,
60                virtual_path,
61                real_path,
62            });
63        }
64
65        if self.workspaces.len() == 1 {
66            let (ws_name, ws_path) = self.workspaces.iter().next().unwrap();
67            let trimmed = path_str.trim_start_matches('/').trim_start_matches("./");
68            let full_relative = PathBuf::from(trimmed);
69            let virtual_path = PathBuf::from("/").join(ws_name).join(&full_relative);
70            let real_path = ws_path.join(&full_relative);
71            return Ok(ResolvedPath {
72                workspace: ws_name.clone(),
73                virtual_path,
74                real_path,
75            });
76        }
77
78        bail!(
79            "No root directory: {root} (known: {:?}). Be sure to use absolute paths!",
80            self.workspaces.keys()
81        );
82    }
83
84    /// Converts a real on disk path to the virtual file system path
85    pub fn canonicalize(&self, path: &Path) -> anyhow::Result<ResolvedPath> {
86        let real_path = path.canonicalize()?;
87        for (name, root) in &self.workspaces {
88            let Ok(path) = real_path.strip_prefix(root) else {
89                continue;
90            };
91            return Ok(ResolvedPath {
92                workspace: name.clone(),
93                virtual_path: PathBuf::from("/").join(name).join(path),
94                real_path,
95            });
96        }
97        bail!("No workspace found containing {path:?}")
98    }
99
100    pub fn root(&self, workspace: &str) -> Option<PathBuf> {
101        self.workspaces.get(workspace).cloned()
102    }
103
104    pub fn roots(&self) -> Vec<String> {
105        self.workspaces.keys().cloned().collect()
106    }
107}
108
109fn root(path: &Path) -> anyhow::Result<String> {
110    let root = path.components().find_map(|c| match c {
111        Component::Normal(name) => Some(name),
112        _ => None, // skip Prefix, RootDir, CurDir, ParentDir
113    });
114
115    let Some(root) = root else {
116        bail!("No root directory in {path:?}");
117    };
118
119    os_to_string(root.to_os_string())
120}
121
122/// Return the path with the first component stripped off
123fn remaining(path: &Path) -> PathBuf {
124    let mut comps = path.components();
125    let first = comps.next();
126
127    if matches!(first, Some(Component::RootDir) | Some(Component::CurDir)) {
128        comps.next();
129    }
130
131    let mut out = PathBuf::new();
132    for c in comps {
133        out.push(c);
134    }
135
136    out
137}
138
139fn os_to_string(str: OsString) -> anyhow::Result<String> {
140    let Some(str) = str.to_str() else {
141        bail!("Workspace name is not utf8: {str:?}");
142    };
143    Ok(str.to_string())
144}
145
146#[cfg(test)]
147mod tests {
148    use std::{
149        env, fs,
150        path::{Path, PathBuf},
151    };
152
153    use crate::file::resolver::{remaining, root, Resolver};
154
155    #[test]
156    fn test_root() -> anyhow::Result<()> {
157        assert!(root(&PathBuf::from(".")).is_err());
158        assert!(root(&PathBuf::from("/")).is_err());
159        assert_eq!("foo", root(&PathBuf::from("foo"))?);
160        assert_eq!("foo", root(&PathBuf::from("foo/"))?);
161        assert_eq!("foo", root(&PathBuf::from("foo/bar"))?);
162        assert_eq!("foo", root(&PathBuf::from("foo/bar/"))?);
163        assert_eq!("foo", root(&PathBuf::from("/foo"))?);
164        assert_eq!("foo", root(&PathBuf::from("/foo/"))?);
165        assert_eq!("foo", root(&PathBuf::from("/foo/bar"))?);
166        assert_eq!("foo", root(&PathBuf::from("/foo/bar/"))?);
167        Ok(())
168    }
169
170    #[test]
171    fn test_remaining() {
172        assert_eq!(remaining(Path::new("/")), Path::new(""));
173        assert_eq!(remaining(Path::new("/foo")), Path::new(""));
174        assert_eq!(remaining(Path::new("/foo/")), Path::new(""));
175        assert_eq!(remaining(Path::new("/foo/bar")), Path::new("bar"));
176        assert_eq!(remaining(Path::new("/foo/bar/")), Path::new("bar"));
177        assert_eq!(remaining(Path::new("/foo/bar/dog")), Path::new("bar/dog"));
178
179        assert_eq!(remaining(Path::new("")), Path::new(""));
180        assert_eq!(remaining(Path::new("foo")), Path::new(""));
181        assert_eq!(remaining(Path::new("foo/")), Path::new(""));
182        assert_eq!(remaining(Path::new("foo/bar")), Path::new("bar"));
183        assert_eq!(remaining(Path::new("foo/bar/")), Path::new("bar"));
184        assert_eq!(remaining(Path::new("foo/bar/dog")), Path::new("bar/dog"));
185
186        assert_eq!(remaining(Path::new("./")), Path::new(""));
187        assert_eq!(remaining(Path::new("./foo")), Path::new(""));
188        assert_eq!(remaining(Path::new("./foo/")), Path::new(""));
189        assert_eq!(remaining(Path::new("./foo/bar")), Path::new("bar"));
190        assert_eq!(remaining(Path::new("./foo/bar/")), Path::new("bar"));
191        assert_eq!(remaining(Path::new("./foo/bar/dog")), Path::new("bar/dog"));
192    }
193
194    #[test]
195    fn test_single_workspace() -> anyhow::Result<()> {
196        let workspace_root =
197            env::var("CARGO_MANIFEST_DIR").expect("missing CARGO_MANIFEST_DIR env variable");
198        let workspace_root = PathBuf::from(workspace_root);
199        let resolver = Resolver::new(vec![workspace_root])?;
200
201        let roots = resolver.roots();
202        assert_eq!(1, roots.len());
203        assert_eq!("tycode-core", roots[0]);
204
205        for root in ["tycode-core", "/tycode-core", "/tycode-core/"] {
206            let resolved = resolver.resolve_path(root)?;
207            assert_eq!("tycode-core", resolved.workspace);
208            assert_eq!(PathBuf::from("/tycode-core/"), resolved.virtual_path);
209            assert_ne!(PathBuf::from("/tycode-core/"), resolved.real_path);
210        }
211
212        for root in [
213            "tycode-core/foo",
214            "tycode-core/foo/",
215            "/tycode-core/foo",
216            "/tycode-core/foo/",
217        ] {
218            let resolved = resolver.resolve_path(root)?;
219            assert_eq!("tycode-core", resolved.workspace);
220            assert_eq!(PathBuf::from("/tycode-core/foo"), resolved.virtual_path);
221        }
222
223        Ok(())
224    }
225
226    #[test]
227    fn test_canonicalize() -> anyhow::Result<()> {
228        let workspace_root =
229            env::var("CARGO_MANIFEST_DIR").expect("missing CARGO_MANIFEST_DIR env variable");
230        let workspace_root = PathBuf::from(workspace_root);
231        let resolver = Resolver::new(vec![workspace_root.clone()])?;
232
233        let resolved = resolver.canonicalize(&workspace_root)?;
234        for directory in fs::read_dir(resolved.real_path)? {
235            let path = directory?.path();
236            let resolved = resolver.canonicalize(&path)?;
237            assert_eq!("tycode-core", resolved.workspace);
238            assert_eq!(path, resolved.real_path);
239            assert_ne!(path, resolved.virtual_path);
240        }
241
242        Ok(())
243    }
244
245    #[test]
246    fn test_single_workspace_auto_resolve() -> anyhow::Result<()> {
247        let workspace_root =
248            env::var("CARGO_MANIFEST_DIR").expect("missing CARGO_MANIFEST_DIR env variable");
249        let workspace_root = PathBuf::from(workspace_root);
250        let resolver = Resolver::new(vec![workspace_root])?;
251
252        for path in ["src/lib.rs", "/src/lib.rs", "src/file/resolver.rs"] {
253            let resolved = resolver.resolve_path(path)?;
254            assert_eq!("tycode-core", resolved.workspace);
255            assert!(resolved.virtual_path.starts_with("/tycode-core/"));
256        }
257
258        Ok(())
259    }
260
261    #[test]
262    fn test_multi_workspace_no_auto_resolve() -> anyhow::Result<()> {
263        let temp = tempfile::tempdir()?;
264        let ws1 = temp.path().join("workspace1");
265        let ws2 = temp.path().join("workspace2");
266        fs::create_dir(&ws1)?;
267        fs::create_dir(&ws2)?;
268
269        let resolver = Resolver::new(vec![ws1, ws2])?;
270
271        let result = resolver.resolve_path("src/lib.rs");
272        assert!(result.is_err());
273        assert!(result
274            .unwrap_err()
275            .to_string()
276            .contains("No root directory"));
277
278        Ok(())
279    }
280
281    #[test]
282    fn test_curdir_workspace_path() -> anyhow::Result<()> {
283        let temp = tempfile::tempdir()?;
284        let ws = temp.path().join("myworkspace");
285        fs::create_dir(&ws)?;
286
287        let resolver = Resolver::new(vec![ws])?;
288
289        let resolved = resolver.resolve_path("./myworkspace/src")?;
290        assert_eq!("myworkspace", resolved.workspace);
291        assert_eq!(PathBuf::from("/myworkspace/src"), resolved.virtual_path);
292
293        let resolved = resolver.resolve_path("./src/lib.rs")?;
294        assert_eq!("myworkspace", resolved.workspace);
295        assert_eq!(
296            PathBuf::from("/myworkspace/src/lib.rs"),
297            resolved.virtual_path
298        );
299
300        Ok(())
301    }
302}