1use anyhow::{Context, Result};
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use crate::resolvers::{file_to_package, Resolver};
7use crate::types::{Ecosystem, Package, PackageId, ProjectGraph};
8
9pub struct BunResolver;
10impl super::sealed::Sealed for BunResolver {}
11
12#[derive(Deserialize)]
13struct RootPackageJson {
14 workspaces: Option<WorkspacesField>,
15}
16
17#[derive(Deserialize)]
18#[serde(untagged)]
19enum WorkspacesField {
20 Array(Vec<String>),
21 Object { packages: Vec<String> },
22}
23
24#[derive(Deserialize)]
25struct PackageJson {
26 name: Option<String>,
27 version: Option<String>,
28 dependencies: Option<HashMap<String, String>>,
29 #[serde(rename = "devDependencies")]
30 dev_dependencies: Option<HashMap<String, String>>,
31}
32
33impl Resolver for BunResolver {
34 fn ecosystem(&self) -> Ecosystem {
35 Ecosystem::Bun
36 }
37
38 fn detect(&self, root: &Path) -> bool {
39 let has_bun_marker = root.join("bun.lock").exists()
41 || root.join("bun.lockb").exists()
42 || root.join("bunfig.toml").exists();
43
44 if !has_bun_marker {
45 return false;
46 }
47
48 if root.join("pnpm-workspace.yaml").exists() {
50 return true;
51 }
52 let pkg = root.join("package.json");
53 if !pkg.exists() {
54 return false;
55 }
56 std::fs::read_to_string(&pkg)
57 .map(|c| c.contains("\"workspaces\""))
58 .unwrap_or(false)
59 }
60
61 fn resolve(&self, root: &Path) -> Result<ProjectGraph> {
62 let workspace_globs = find_workspace_globs(root)?;
63 let pkg_dirs = expand_globs(root, &workspace_globs)?;
64
65 let mut packages = HashMap::new();
66 let mut name_to_id = HashMap::new();
67
68 for dir in &pkg_dirs {
69 let pkg_json_path = dir.join("package.json");
70 if !pkg_json_path.exists() {
71 continue;
72 }
73
74 let content = std::fs::read_to_string(&pkg_json_path)
75 .with_context(|| format!("Failed to read {}", pkg_json_path.display()))?;
76 let pkg: PackageJson = serde_json::from_str(&content)
77 .with_context(|| format!("Failed to parse {}", pkg_json_path.display()))?;
78
79 let name = match &pkg.name {
80 Some(n) => n.clone(),
81 None => continue,
82 };
83
84 let pkg_id = PackageId(name.clone());
85 name_to_id.insert(name.clone(), pkg_id.clone());
86 packages.insert(
87 pkg_id.clone(),
88 Package {
89 id: pkg_id,
90 name: name.clone(),
91 version: pkg.version.clone(),
92 path: dir.clone(),
93 manifest_path: pkg_json_path,
94 },
95 );
96 }
97
98 let mut edges = Vec::new();
100 let workspace_names: std::collections::HashSet<&str> =
101 name_to_id.keys().map(|s| s.as_str()).collect();
102
103 for dir in &pkg_dirs {
104 let pkg_json_path = dir.join("package.json");
105 if !pkg_json_path.exists() {
106 continue;
107 }
108
109 let content = std::fs::read_to_string(&pkg_json_path)?;
110 let pkg: PackageJson = serde_json::from_str(&content)?;
111
112 let from_name = match &pkg.name {
113 Some(n) => n.clone(),
114 None => continue,
115 };
116
117 let all_deps: Vec<&str> = pkg
118 .dependencies
119 .iter()
120 .flat_map(|d| d.keys())
121 .chain(pkg.dev_dependencies.iter().flat_map(|d| d.keys()))
122 .map(|s| s.as_str())
123 .collect();
124
125 for dep_name in all_deps {
126 if workspace_names.contains(dep_name) {
127 edges.push((
128 PackageId(from_name.clone()),
129 PackageId(dep_name.to_string()),
130 ));
131 }
132 }
133 }
134
135 Ok(ProjectGraph {
136 packages,
137 edges,
138 root: root.to_path_buf(),
139 })
140 }
141
142 fn package_for_file(&self, graph: &ProjectGraph, file: &Path) -> Option<PackageId> {
143 file_to_package(graph, file)
144 }
145
146 fn test_command(&self, package_id: &PackageId) -> Vec<String> {
147 vec![
148 "bun".into(),
149 "test".into(),
150 "--filter".into(),
151 package_id.0.clone(),
152 ]
153 }
154}
155
156fn find_workspace_globs(root: &Path) -> Result<Vec<String>> {
157 let pnpm_path = root.join("pnpm-workspace.yaml");
159 if pnpm_path.exists() {
160 let content = std::fs::read_to_string(&pnpm_path)?;
161 let mut globs = Vec::new();
162 let mut in_packages = false;
163 for line in content.lines() {
164 let trimmed = line.trim();
165 if trimmed == "packages:" {
166 in_packages = true;
167 continue;
168 }
169 if in_packages {
170 if trimmed.starts_with("- ") {
171 let glob = trimmed
172 .trim_start_matches("- ")
173 .trim_matches('\'')
174 .trim_matches('"')
175 .to_string();
176 globs.push(glob);
177 } else if !trimmed.is_empty() {
178 break;
179 }
180 }
181 }
182 if !globs.is_empty() {
183 return Ok(globs);
184 }
185 }
186
187 let pkg_path = root.join("package.json");
189 let content = std::fs::read_to_string(&pkg_path).context("No package.json found")?;
190 let root_pkg: RootPackageJson =
191 serde_json::from_str(&content).context("Failed to parse root package.json")?;
192
193 match root_pkg.workspaces {
194 Some(WorkspacesField::Array(globs)) => Ok(globs),
195 Some(WorkspacesField::Object { packages }) => Ok(packages),
196 None => anyhow::bail!("No workspaces field found in package.json"),
197 }
198}
199
200fn expand_globs(root: &Path, globs: &[String]) -> Result<Vec<PathBuf>> {
201 let mut dirs = Vec::new();
202
203 for pattern in globs {
204 let full_pattern = root.join(pattern).join("package.json");
205 let pattern_str = full_pattern.to_str().unwrap_or("");
206
207 match glob::glob(pattern_str) {
208 Ok(paths) => {
209 for entry in paths.filter_map(|p| p.ok()) {
210 if let Some(parent) = entry.parent() {
211 dirs.push(parent.to_path_buf());
212 }
213 }
214 }
215 Err(_) => continue,
216 }
217 }
218
219 Ok(dirs)
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 fn create_bun_workspace(dir: &std::path::Path) {
227 std::fs::write(dir.join("bun.lock"), "").unwrap();
229
230 std::fs::write(
232 dir.join("package.json"),
233 r#"{"name": "root", "workspaces": ["packages/*"]}"#,
234 )
235 .unwrap();
236
237 std::fs::create_dir_all(dir.join("packages/pkg-a")).unwrap();
239 std::fs::write(
240 dir.join("packages/pkg-a/package.json"),
241 r#"{"name": "@app/pkg-a", "version": "1.0.0", "dependencies": {"@app/pkg-b": "workspace:*"}}"#,
242 )
243 .unwrap();
244
245 std::fs::create_dir_all(dir.join("packages/pkg-b")).unwrap();
247 std::fs::write(
248 dir.join("packages/pkg-b/package.json"),
249 r#"{"name": "@app/pkg-b", "version": "1.0.0"}"#,
250 )
251 .unwrap();
252 }
253
254 #[test]
255 fn test_detect_bun_lock() {
256 let dir = tempfile::tempdir().unwrap();
257 create_bun_workspace(dir.path());
258 assert!(BunResolver.detect(dir.path()));
259 }
260
261 #[test]
262 fn test_detect_bun_lockb() {
263 let dir = tempfile::tempdir().unwrap();
264 std::fs::write(dir.path().join("bun.lockb"), "").unwrap();
265 std::fs::write(
266 dir.path().join("package.json"),
267 r#"{"name": "root", "workspaces": ["packages/*"]}"#,
268 )
269 .unwrap();
270 assert!(BunResolver.detect(dir.path()));
271 }
272
273 #[test]
274 fn test_detect_bunfig() {
275 let dir = tempfile::tempdir().unwrap();
276 std::fs::write(dir.path().join("bunfig.toml"), "").unwrap();
277 std::fs::write(
278 dir.path().join("package.json"),
279 r#"{"name": "root", "workspaces": ["packages/*"]}"#,
280 )
281 .unwrap();
282 assert!(BunResolver.detect(dir.path()));
283 }
284
285 #[test]
286 fn test_detect_no_bun() {
287 let dir = tempfile::tempdir().unwrap();
288 std::fs::write(
290 dir.path().join("package.json"),
291 r#"{"name": "root", "workspaces": ["packages/*"]}"#,
292 )
293 .unwrap();
294 assert!(!BunResolver.detect(dir.path()));
295 }
296
297 #[test]
298 fn test_detect_empty_dir() {
299 let dir = tempfile::tempdir().unwrap();
300 assert!(!BunResolver.detect(dir.path()));
301 }
302
303 #[test]
304 fn test_resolve_bun_workspace() {
305 let dir = tempfile::tempdir().unwrap();
306 create_bun_workspace(dir.path());
307
308 let graph = BunResolver.resolve(dir.path()).unwrap();
309 assert_eq!(graph.packages.len(), 2);
310 assert!(graph.packages.contains_key(&PackageId("@app/pkg-a".into())));
311 assert!(graph.packages.contains_key(&PackageId("@app/pkg-b".into())));
312
313 assert!(graph.edges.contains(&(
315 PackageId("@app/pkg-a".into()),
316 PackageId("@app/pkg-b".into()),
317 )));
318 }
319
320 #[test]
321 fn test_test_command() {
322 let cmd = BunResolver.test_command(&PackageId("my-pkg".into()));
323 assert_eq!(cmd, vec!["bun", "test", "--filter", "my-pkg"]);
324 }
325
326 #[test]
327 fn test_dev_dependencies_create_edges() {
328 let dir = tempfile::tempdir().unwrap();
329 std::fs::write(dir.path().join("bun.lock"), "").unwrap();
330 std::fs::write(
331 dir.path().join("package.json"),
332 r#"{"name": "root", "workspaces": ["packages/*"]}"#,
333 )
334 .unwrap();
335
336 std::fs::create_dir_all(dir.path().join("packages/lib")).unwrap();
337 std::fs::write(
338 dir.path().join("packages/lib/package.json"),
339 r#"{"name": "lib", "version": "1.0.0"}"#,
340 )
341 .unwrap();
342
343 std::fs::create_dir_all(dir.path().join("packages/app")).unwrap();
344 std::fs::write(
345 dir.path().join("packages/app/package.json"),
346 r#"{"name": "app", "version": "1.0.0", "devDependencies": {"lib": "workspace:*"}}"#,
347 )
348 .unwrap();
349
350 let graph = BunResolver.resolve(dir.path()).unwrap();
351 assert!(graph
352 .edges
353 .contains(&(PackageId("app".into()), PackageId("lib".into()))));
354 }
355}