Skip to main content

affected_core/resolvers/
bun.rs

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        // Bun uses the same package.json workspaces as npm, but is identified by bun.lock/bunfig.toml
40        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        // Also need workspaces configured
49        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        // Build dependency edges
99        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    // Try pnpm-workspace.yaml first (Bun supports it)
158    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    // Fall back to package.json workspaces
188    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        // bun.lock marker
228        std::fs::write(dir.join("bun.lock"), "").unwrap();
229
230        // Root package.json with workspaces
231        std::fs::write(
232            dir.join("package.json"),
233            r#"{"name": "root", "workspaces": ["packages/*"]}"#,
234        )
235        .unwrap();
236
237        // Package A depends on B
238        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        // Package B (no deps)
246        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        // npm workspace without bun marker
289        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        // pkg-a depends on pkg-b
314        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}