1use crate::core::traits::LockfileParser;
2use crate::core::types::{DependencyRef, DependencySource, LockfileEntry};
3use crate::error::{Error, Result};
4use serde::Deserialize;
5use serde_json::{Map, Value};
6use std::collections::BTreeMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Default, Clone, Copy)]
12pub struct BunLockfileParser;
13
14impl LockfileParser for BunLockfileParser {
15 fn parse(&self, lockfile_path: &Path) -> Result<Vec<LockfileEntry>> {
16 if lockfile_path
18 .file_name()
19 .and_then(|n| n.to_str())
20 .is_some_and(|n| n == "bun.lockb")
21 {
22 return Err(Error::LockfileParseFailed {
23 path: lockfile_path.to_path_buf(),
24 message: "Binary Bun lockfile format (bun.lockb) is currently unsupported. Only the text-based JSONC format (bun.lock) is supported.".to_string(),
25 });
26 }
27
28 let contents = fs::read_to_string(lockfile_path).map_err(|source| Error::Io {
29 source,
30 path: Some(lockfile_path.to_path_buf()),
31 operation: "reading bun.lock".to_string(),
32 })?;
33
34 let json_value =
36 jsonc_parser::parse_to_value(&contents, &jsonc_parser::ParseOptions::default())
37 .map_err(|err| Error::LockfileParseFailed {
38 path: lockfile_path.to_path_buf(),
39 message: format!("Failed to parse bun.lock as JSONC: {err:?}"),
40 })?
41 .ok_or_else(|| Error::LockfileParseFailed {
42 path: lockfile_path.to_path_buf(),
43 message: "Empty or invalid JSONC content".to_string(),
44 })?;
45
46 let value: Value = convert_jsonc_to_serde_value(json_value);
48
49 let lockfile: BunLockfile =
50 serde_json::from_value(value).map_err(|err| Error::LockfileParseFailed {
51 path: lockfile_path.to_path_buf(),
52 message: format!("Failed to deserialize bun.lock: {err}"),
53 })?;
54
55 let version = lockfile.lockfile_version.unwrap_or(0);
56 if version > 1 {
57 return Err(Error::LockfileParseFailed {
58 path: lockfile_path.to_path_buf(),
59 message: format!(
60 "Unsupported bun lockfileVersion {version} – supported versions are 0 and 1"
61 ),
62 });
63 }
64
65 let mut entries = Vec::new();
66
67 for (workspace_path, workspace) in &lockfile.workspaces {
68 entries.push(entry_from_workspace(workspace_path, workspace));
69 }
70
71 for (package_name, raw_value) in lockfile.packages {
72 let entry =
73 entry_from_package(lockfile_path, &package_name, raw_value).map_err(|message| {
74 Error::LockfileParseFailed {
75 path: lockfile_path.to_path_buf(),
76 message: format!("{package_name}: {message}"),
77 }
78 })?;
79 entries.push(entry);
80 }
81
82 Ok(entries)
83 }
84
85 fn supports_lockfile(&self, path: &Path) -> bool {
86 matches!(path.file_name().and_then(|n| n.to_str()), Some("bun.lock"))
88 }
89
90 fn lockfile_name(&self) -> &'static str {
91 "bun.lock"
92 }
93}
94
95#[derive(Debug, Deserialize, Default)]
96#[serde(rename_all = "camelCase")]
97struct BunLockfile {
98 #[serde(default)]
99 lockfile_version: Option<u32>,
100 #[serde(default)]
101 workspaces: BTreeMap<String, BunWorkspace>,
102 #[serde(default)]
103 packages: BTreeMap<String, Value>,
104}
105
106#[derive(Debug, Deserialize, Default)]
107#[serde(rename_all = "camelCase")]
108struct BunWorkspace {
109 #[serde(default)]
110 name: Option<String>,
111 #[serde(default)]
112 version: Option<String>,
113 #[serde(default)]
114 dependencies: BTreeMap<String, String>,
115 #[serde(default, rename = "devDependencies")]
116 dev_dependencies: BTreeMap<String, String>,
117 #[serde(default, rename = "optionalDependencies")]
118 optional_dependencies: BTreeMap<String, String>,
119}
120
121#[derive(Debug, Deserialize, Default)]
122#[serde(rename_all = "camelCase")]
123struct BunPackageMetadata {
124 #[serde(default)]
125 dependencies: BTreeMap<String, String>,
126 #[serde(default, rename = "devDependencies")]
127 dev_dependencies: BTreeMap<String, String>,
128 #[serde(default, rename = "optionalDependencies")]
129 optional_dependencies: BTreeMap<String, String>,
130 #[serde(default, rename = "peerDependencies")]
131 peer_dependencies: BTreeMap<String, String>,
132 #[serde(default)]
133 integrity: Option<String>,
134 #[serde(default)]
135 checksum: Option<String>,
136}
137
138#[derive(Debug, Default, Deserialize)]
139#[serde(rename_all = "camelCase")]
140struct BunPackageObject {
141 #[serde(default)]
142 name: Option<String>,
143 #[serde(default)]
144 version: Option<String>,
145 #[serde(default)]
146 resolution: Option<String>,
147 #[serde(default)]
148 locator: Option<String>,
149 #[serde(default)]
150 checksum: Option<String>,
151 #[serde(default)]
152 integrity: Option<String>,
153 #[serde(default)]
154 dependencies: BTreeMap<String, String>,
155 #[serde(default, rename = "devDependencies")]
156 dev_dependencies: BTreeMap<String, String>,
157 #[serde(default, rename = "optionalDependencies")]
158 optional_dependencies: BTreeMap<String, String>,
159 #[serde(default, rename = "peerDependencies")]
160 peer_dependencies: BTreeMap<String, String>,
161}
162
163fn entry_from_workspace(path_key: &str, workspace: &BunWorkspace) -> LockfileEntry {
164 let name = workspace
165 .name
166 .as_deref()
167 .filter(|value| !value.is_empty())
168 .map_or_else(|| workspace_name_from_path(path_key), ToString::to_string);
169
170 let mut dependencies = Vec::new();
171 push_dependencies(&mut dependencies, &workspace.dependencies);
172 push_dependencies(&mut dependencies, &workspace.dev_dependencies);
173 push_dependencies(&mut dependencies, &workspace.optional_dependencies);
174
175 LockfileEntry {
176 name,
177 version: workspace
178 .version
179 .clone()
180 .unwrap_or_else(|| "0.0.0".to_string()),
181 source: DependencySource::Workspace(workspace_path(path_key)),
182 checksum: None,
183 dependencies,
184 is_workspace_member: true,
185 }
186}
187
188fn workspace_name_from_path(path_key: &str) -> String {
189 if path_key.is_empty() {
190 "workspace".to_string()
191 } else {
192 path_key.to_string()
193 }
194}
195
196fn entry_from_package(lockfile_path: &Path, name: &str, raw_value: Value) -> Result<LockfileEntry> {
197 match raw_value {
198 Value::Array(items) => parse_array_package(lockfile_path, name, &items),
199 Value::Object(map) => parse_object_package(lockfile_path, name, map),
200 other => Err(Error::LockfileParseFailed {
201 path: lockfile_path.to_path_buf(),
202 message: format!(
203 "Unsupported package entry for {name}. Expected array or object, found {other}"
204 ),
205 }),
206 }
207}
208
209fn parse_array_package(lockfile_path: &Path, name: &str, items: &[Value]) -> Result<LockfileEntry> {
210 if items.is_empty() {
211 return Err(Error::LockfileParseFailed {
212 path: lockfile_path.to_path_buf(),
213 message: format!("Package tuple for {name} is empty"),
214 });
215 }
216
217 let locator =
218 items
219 .first()
220 .and_then(Value::as_str)
221 .ok_or_else(|| Error::LockfileParseFailed {
222 path: lockfile_path.to_path_buf(),
223 message: format!("Package {name} missing locator entry"),
224 })?;
225
226 let mut checksum_override = None;
227 let metadata_val = items
228 .get(2)
229 .cloned()
230 .unwrap_or(Value::Object(Map::default()));
231
232 let metadata: BunPackageMetadata = match metadata_val {
233 Value::Object(_) => {
234 serde_json::from_value(metadata_val).map_err(|err| Error::LockfileParseFailed {
235 path: lockfile_path.to_path_buf(),
236 message: format!("{name}: invalid metadata object: {err}"),
237 })?
238 }
239 Value::String(s) => {
240 checksum_override = Some(s);
244 BunPackageMetadata::default()
245 }
246 _ => BunPackageMetadata::default(),
247 };
248
249 let checksum = items
250 .get(3)
251 .and_then(Value::as_str)
252 .map(ToString::to_string)
253 .or_else(|| metadata.integrity.clone())
254 .or_else(|| metadata.checksum.clone())
255 .or(checksum_override);
256
257 build_package_entry(lockfile_path, name, locator, checksum, &metadata)
258}
259
260fn parse_object_package(
261 lockfile_path: &Path,
262 name: &str,
263 map: Map<String, Value>,
264) -> Result<LockfileEntry> {
265 let package: BunPackageObject =
266 serde_json::from_value(Value::Object(map)).map_err(|err| Error::LockfileParseFailed {
267 path: lockfile_path.to_path_buf(),
268 message: format!("{name}: failed to parse object entry: {err}"),
269 })?;
270
271 let BunPackageObject {
272 name: _object_name,
273 version,
274 resolution,
275 locator,
276 checksum: raw_checksum,
277 integrity,
278 dependencies,
279 dev_dependencies,
280 optional_dependencies,
281 peer_dependencies,
282 } = package;
283
284 let locator = locator
285 .or(resolution)
286 .unwrap_or_else(|| format!("{name}@{}", version.unwrap_or_default()));
287
288 let checksum = raw_checksum.clone().or_else(|| integrity.clone());
289
290 let metadata = BunPackageMetadata {
291 dependencies,
292 dev_dependencies,
293 optional_dependencies,
294 peer_dependencies,
295 integrity,
296 checksum: raw_checksum,
297 };
298
299 build_package_entry(lockfile_path, name, &locator, checksum, &metadata)
300}
301
302fn build_package_entry(
303 lockfile_path: &Path,
304 package_name: &str,
305 locator: &str,
306 checksum: Option<String>,
307 metadata: &BunPackageMetadata,
308) -> Result<LockfileEntry> {
309 let locator_info = parse_locator(lockfile_path, locator, package_name)?;
310
311 let mut dependencies = Vec::new();
312 push_dependencies(&mut dependencies, &metadata.dependencies);
313 push_dependencies(&mut dependencies, &metadata.dev_dependencies);
314 push_dependencies(&mut dependencies, &metadata.optional_dependencies);
315 push_dependencies(&mut dependencies, &metadata.peer_dependencies);
316
317 Ok(LockfileEntry {
318 name: package_name.to_string(),
319 version: locator_info.version,
320 source: locator_info.source,
321 checksum,
322 dependencies,
323 is_workspace_member: false,
324 })
325}
326
327fn push_dependencies(target: &mut Vec<DependencyRef>, deps: &BTreeMap<String, String>) {
328 for (name, version_req) in deps {
329 target.push(DependencyRef {
330 name: name.clone(),
331 version_req: version_req.clone(),
332 });
333 }
334}
335
336struct LocatorDetails {
337 version: String,
338 source: DependencySource,
339}
340
341fn parse_locator(
342 lockfile_path: &Path,
343 locator: &str,
344 package_name: &str,
345) -> Result<LocatorDetails> {
346 let trimmed = locator.trim();
347 if trimmed.is_empty() {
348 return Err(Error::LockfileParseFailed {
349 path: lockfile_path.to_path_buf(),
350 message: format!("Package {package_name} has empty locator"),
351 });
352 }
353
354 if let Some(rest) = trimmed.strip_prefix("workspace:") {
355 return Ok(LocatorDetails {
356 version: "0.0.0".to_string(),
357 source: DependencySource::Workspace(workspace_path(rest)),
358 });
359 }
360
361 let spec_part = match trimmed.rfind('@') {
362 Some(idx) if idx + 1 < trimmed.len() => &trimmed[idx + 1..],
363 _ => trimmed,
364 };
365
366 let (protocol, remainder) = if let Some(colon_idx) = spec_part.find(':') {
367 (&spec_part[..colon_idx], &spec_part[colon_idx + 1..])
368 } else {
369 ("npm", spec_part)
370 };
371
372 let remainder = remainder.trim();
373 let source = match protocol {
374 "npm" | "registry" => DependencySource::Registry(format!("npm:{package_name}@{remainder}")),
375 "github" => DependencySource::Git(format!("https://github.com/{remainder}")),
376 "git" | "git+https" | "git+ssh" => DependencySource::Git(format!("{protocol}:{remainder}")),
377 "file" => DependencySource::Path(PathBuf::from(remainder)),
378 "workspace" => DependencySource::Workspace(workspace_path(remainder)),
379 other => DependencySource::Registry(format!("{other}:{remainder}")),
380 };
381
382 let version = match protocol {
383 "github" => remainder.split('#').nth(1).unwrap_or(remainder).to_string(),
384 "file" | "workspace" => "0.0.0".to_string(),
385 _ => remainder.to_string(),
386 };
387
388 Ok(LocatorDetails { version, source })
389}
390
391fn workspace_path(path: &str) -> PathBuf {
392 if path.is_empty() {
393 PathBuf::from(".")
394 } else {
395 PathBuf::from(path)
396 }
397}
398
399fn convert_jsonc_to_serde_value(jsonc_value: jsonc_parser::JsonValue) -> Value {
401 match jsonc_value {
402 jsonc_parser::JsonValue::Null => Value::Null,
403 jsonc_parser::JsonValue::Boolean(b) => Value::Bool(b),
404 jsonc_parser::JsonValue::Number(n) => {
405 if let Ok(i) = n.parse::<i64>() {
407 Value::Number(i.into())
408 } else if let Ok(f) = n.parse::<f64>() {
409 serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number)
410 } else {
411 Value::Null
412 }
413 }
414 jsonc_parser::JsonValue::String(s) => Value::String(s.to_string()),
415 jsonc_parser::JsonValue::Array(arr) => {
416 Value::Array(arr.into_iter().map(convert_jsonc_to_serde_value).collect())
417 }
418 jsonc_parser::JsonValue::Object(obj) => {
419 let mut map = serde_json::Map::new();
420 for (key, value) in obj {
422 map.insert(key, convert_jsonc_to_serde_value(value));
423 }
424 Value::Object(map)
425 }
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use std::io::Write;
433 use std::path::Path;
434 use tempfile::NamedTempFile;
435
436 fn write_lock(contents: &str) -> NamedTempFile {
437 let mut file = NamedTempFile::new().expect("temp file");
438 file.write_all(contents.as_bytes()).expect("write lockfile");
439 file
440 }
441
442 #[test]
443 fn parses_basic_bun_lock() {
444 let lock = r#"
445 // Bun lockfile example
446 {
447 "lockfileVersion": 1,
448 "workspaces": {
449 "": {"name": "root", "version": "1.0.0", "dependencies": {"left-pad": "^1.0.0"}},
450 "docs": {"name": "docs", "version": "0.1.0"}
451 },
452 "packages": {
453 "left-pad": ["left-pad@npm:1.3.0", "", {"dependencies": {"repeat-string": "^1.0.0"}}, "sha512-left"],
454 "repeat-string": ["repeat-string@1.0.0", "", {}, "sha512-repeat"],
455 "@scope/pkg": ["@scope/pkg@github:user/repo#abcdef", "", {}, "sha512-scope"]
456 }
457 }
458 "#;
459
460 let file = write_lock(lock);
461 let parser = BunLockfileParser;
462 let entries = parser.parse(file.path()).expect("parse bun lock");
463
464 assert_eq!(entries.len(), 5);
465
466 let workspace = entries
467 .iter()
468 .find(|entry| entry.is_workspace_member && entry.name == "root")
469 .expect("root workspace");
470 assert_eq!(workspace.version, "1.0.0");
471 assert_eq!(workspace.dependencies.len(), 1);
472
473 let left_pad = entries
474 .iter()
475 .find(|entry| entry.name == "left-pad")
476 .expect("left-pad entry");
477 assert_eq!(left_pad.version, "1.3.0");
478 assert_eq!(left_pad.dependencies.len(), 1);
479 assert_eq!(left_pad.checksum.as_deref(), Some("sha512-left"));
480
481 let scoped = entries
482 .iter()
483 .find(|entry| entry.name == "@scope/pkg")
484 .expect("scoped package");
485 assert_eq!(scoped.version, "abcdef");
486 assert!(matches!(scoped.source, DependencySource::Git(_)));
487 }
488
489 #[test]
490 fn rejects_future_version() {
491 let lock = r#"{"lockfileVersion": 99, "packages": {}}"#;
492 let file = write_lock(lock);
493 let parser = BunLockfileParser;
494 let err = parser
495 .parse(file.path())
496 .expect_err("reject future version");
497 match err {
498 Error::LockfileParseFailed { message, .. } => {
499 assert!(message.contains("Unsupported"));
500 }
501 other => panic!("unexpected error: {other:?}"),
502 }
503 }
504
505 #[test]
506 fn supports_expected_filenames() {
507 let parser = BunLockfileParser;
508 assert!(parser.supports_lockfile(Path::new("/tmp/bun.lock")));
509 assert!(
510 !parser.supports_lockfile(Path::new("./bun.lockb")),
511 "Binary bun.lockb should not be supported"
512 );
513 assert!(!parser.supports_lockfile(Path::new("package-lock.json")));
514 }
515
516 #[test]
517 fn rejects_binary_lockb_format() {
518 use tempfile::TempDir;
519
520 let lock = r#"{"lockfileVersion": 1, "packages": {}}"#;
521 let dir = TempDir::new().expect("temp dir");
522 let lockb_path = dir.path().join("bun.lockb");
523
524 std::fs::write(&lockb_path, lock.as_bytes()).expect("write lockfile");
525
526 let parser = BunLockfileParser;
527 let err = parser
528 .parse(&lockb_path)
529 .expect_err("should reject bun.lockb");
530 match err {
531 Error::LockfileParseFailed { message, .. } => {
532 assert!(message.contains("Binary Bun lockfile format"));
533 assert!(message.contains("unsupported"));
534 }
535 other => panic!("unexpected error: {other:?}"),
536 }
537 }
538
539 #[test]
540 fn parses_metadata_string_in_tuple() {
541 let lock = r#"
542 {
543 "lockfileVersion": 1,
544 "workspaces": {},
545 "packages": {
546 "@emmetio/css-parser": ["npm:@emmetio/css-parser@0.0.1", null, "ramya-rao-a-css-parser-370c480"]
547 }
548 }
549 "#;
550
551 let file = write_lock(lock);
552 let parser = BunLockfileParser;
553 let entries = parser.parse(file.path()).expect("parse bun lock");
554
555 let pkg = entries
556 .iter()
557 .find(|entry| entry.name == "@emmetio/css-parser")
558 .expect("package parsed");
559 assert_eq!(pkg.version, "0.0.1");
560 assert_eq!(
561 pkg.checksum.as_deref(),
562 Some("ramya-rao-a-css-parser-370c480")
563 );
564 }
565}