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#[derive(Debug, Clone)]
19pub struct Resolver {
20 workspaces: HashMap<String, PathBuf>,
21}
22
23impl Resolver {
24 pub fn new(workspace_roots: Vec<PathBuf>) -> anyhow::Result<Self> {
27 let mut workspaces = HashMap::new();
28 for workspace_root in workspace_roots {
29 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 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 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, });
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
122fn 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}