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 #[allow(clippy::too_many_lines)] fn parse(&self, lockfile_path: &Path) -> Result<Vec<LockfileEntry>> {
17 if lockfile_path
19 .file_name()
20 .and_then(|n| n.to_str())
21 .is_some_and(|n| n == "bun.lockb")
22 {
23 return Err(Error::LockfileParseFailed {
24 path: lockfile_path.to_path_buf(),
25 message: "Binary Bun lockfile format (bun.lockb) is currently unsupported. Only the text-based JSONC format (bun.lock) is supported.".to_string(),
26 });
27 }
28
29 let contents = fs::read_to_string(lockfile_path).map_err(|source| Error::Io {
30 source,
31 path: Some(lockfile_path.to_path_buf()),
32 operation: "reading bun.lock".to_string(),
33 })?;
34
35 let json_value =
37 jsonc_parser::parse_to_value(&contents, &jsonc_parser::ParseOptions::default())
38 .map_err(|err| Error::LockfileParseFailed {
39 path: lockfile_path.to_path_buf(),
40 message: format!("Failed to parse bun.lock as JSONC: {err:?}"),
41 })?
42 .ok_or_else(|| Error::LockfileParseFailed {
43 path: lockfile_path.to_path_buf(),
44 message: "Empty or invalid JSONC content".to_string(),
45 })?;
46
47 let value: Value = convert_jsonc_to_serde_value(json_value);
49
50 let lockfile: BunLockfile =
51 serde_json::from_value(value).map_err(|err| Error::LockfileParseFailed {
52 path: lockfile_path.to_path_buf(),
53 message: format!("Failed to deserialize bun.lock: {err}"),
54 })?;
55
56 let version = lockfile.lockfile_version.unwrap_or(0);
57 if version > 1 {
58 return Err(Error::LockfileParseFailed {
59 path: lockfile_path.to_path_buf(),
60 message: format!(
61 "Unsupported bun lockfileVersion {version} – supported versions are 0 and 1"
62 ),
63 });
64 }
65
66 let mut entries = Vec::new();
67
68 for (workspace_path, workspace) in &lockfile.workspaces {
69 entries.push(entry_from_workspace(workspace_path, workspace));
70 }
71
72 for (package_name, raw_value) in lockfile.packages {
73 let entry =
74 entry_from_package(lockfile_path, &package_name, raw_value).map_err(|message| {
75 Error::LockfileParseFailed {
76 path: lockfile_path.to_path_buf(),
77 message: format!("{package_name}: {message}"),
78 }
79 })?;
80 entries.push(entry);
81 }
82
83 Ok(entries)
84 }
85
86 fn supports_lockfile(&self, path: &Path) -> bool {
87 matches!(path.file_name().and_then(|n| n.to_str()), Some("bun.lock"))
89 }
90
91 fn lockfile_name(&self) -> &'static str {
92 "bun.lock"
93 }
94}
95
96#[derive(Debug, Deserialize, Default)]
97#[serde(rename_all = "camelCase")]
98struct BunLockfile {
99 #[serde(default)]
100 lockfile_version: Option<u32>,
101 #[serde(default)]
102 workspaces: BTreeMap<String, BunWorkspace>,
103 #[serde(default)]
104 packages: BTreeMap<String, Value>,
105}
106
107#[derive(Debug, Deserialize, Default)]
108#[serde(rename_all = "camelCase")]
109struct BunWorkspace {
110 #[serde(default)]
111 name: Option<String>,
112 #[serde(default)]
113 version: Option<String>,
114 #[serde(default)]
115 dependencies: BTreeMap<String, String>,
116 #[serde(default, rename = "devDependencies")]
117 dev_dependencies: BTreeMap<String, String>,
118 #[serde(default, rename = "optionalDependencies")]
119 optional_dependencies: BTreeMap<String, String>,
120}
121
122#[derive(Debug, Deserialize, Default)]
123#[serde(rename_all = "camelCase")]
124struct BunPackageMetadata {
125 #[serde(default)]
126 dependencies: BTreeMap<String, String>,
127 #[serde(default, rename = "devDependencies")]
128 dev_dependencies: BTreeMap<String, String>,
129 #[serde(default, rename = "optionalDependencies")]
130 optional_dependencies: BTreeMap<String, String>,
131 #[serde(default, rename = "peerDependencies")]
132 peer_dependencies: BTreeMap<String, String>,
133 #[serde(default)]
134 integrity: Option<String>,
135 #[serde(default)]
136 checksum: Option<String>,
137}
138
139#[derive(Debug, Default, Deserialize)]
140#[serde(rename_all = "camelCase")]
141struct BunPackageObject {
142 #[serde(default)]
143 name: Option<String>,
144 #[serde(default)]
145 version: Option<String>,
146 #[serde(default)]
147 resolution: Option<String>,
148 #[serde(default)]
149 locator: Option<String>,
150 #[serde(default)]
151 checksum: Option<String>,
152 #[serde(default)]
153 integrity: Option<String>,
154 #[serde(default)]
155 dependencies: BTreeMap<String, String>,
156 #[serde(default, rename = "devDependencies")]
157 dev_dependencies: BTreeMap<String, String>,
158 #[serde(default, rename = "optionalDependencies")]
159 optional_dependencies: BTreeMap<String, String>,
160 #[serde(default, rename = "peerDependencies")]
161 peer_dependencies: BTreeMap<String, String>,
162}
163
164fn entry_from_workspace(path_key: &str, workspace: &BunWorkspace) -> LockfileEntry {
165 let name = workspace
166 .name
167 .as_deref()
168 .filter(|value| !value.is_empty())
169 .map_or_else(|| workspace_name_from_path(path_key), ToString::to_string);
170
171 let mut dependencies = Vec::new();
172 push_dependencies(&mut dependencies, &workspace.dependencies);
173 push_dependencies(&mut dependencies, &workspace.dev_dependencies);
174 push_dependencies(&mut dependencies, &workspace.optional_dependencies);
175
176 LockfileEntry {
177 name,
178 version: workspace
179 .version
180 .clone()
181 .unwrap_or_else(|| "0.0.0".to_string()),
182 source: DependencySource::Workspace(workspace_path(path_key)),
183 checksum: None,
184 dependencies,
185 is_workspace_member: true,
186 }
187}
188
189fn workspace_name_from_path(path_key: &str) -> String {
190 if path_key.is_empty() {
191 "workspace".to_string()
192 } else {
193 path_key.to_string()
194 }
195}
196
197fn entry_from_package(lockfile_path: &Path, name: &str, raw_value: Value) -> Result<LockfileEntry> {
198 match raw_value {
199 Value::Array(items) => parse_array_package(lockfile_path, name, &items),
200 Value::Object(map) => parse_object_package(lockfile_path, name, map),
201 other => Err(Error::LockfileParseFailed {
202 path: lockfile_path.to_path_buf(),
203 message: format!(
204 "Unsupported package entry for {name}. Expected array or object, found {other}"
205 ),
206 }),
207 }
208}
209
210fn parse_array_package(lockfile_path: &Path, name: &str, items: &[Value]) -> Result<LockfileEntry> {
211 if items.is_empty() {
212 return Err(Error::LockfileParseFailed {
213 path: lockfile_path.to_path_buf(),
214 message: format!("Package tuple for {name} is empty"),
215 });
216 }
217
218 let locator =
219 items
220 .first()
221 .and_then(Value::as_str)
222 .ok_or_else(|| Error::LockfileParseFailed {
223 path: lockfile_path.to_path_buf(),
224 message: format!("Package {name} missing locator entry"),
225 })?;
226
227 let mut checksum_override = None;
228 let metadata_val = items
229 .get(2)
230 .cloned()
231 .unwrap_or_else(|| Value::Object(Map::default()));
232
233 let metadata: BunPackageMetadata = match metadata_val {
234 Value::Object(_) => {
235 serde_json::from_value(metadata_val).map_err(|err| Error::LockfileParseFailed {
236 path: lockfile_path.to_path_buf(),
237 message: format!("{name}: invalid metadata object: {err}"),
238 })?
239 }
240 Value::String(s) => {
241 checksum_override = Some(s);
245 BunPackageMetadata::default()
246 }
247 _ => BunPackageMetadata::default(),
248 };
249
250 let checksum = items
251 .get(3)
252 .and_then(Value::as_str)
253 .map(ToString::to_string)
254 .or_else(|| metadata.integrity.clone())
255 .or_else(|| metadata.checksum.clone())
256 .or(checksum_override);
257
258 build_package_entry(lockfile_path, name, locator, checksum, &metadata)
259}
260
261fn parse_object_package(
262 lockfile_path: &Path,
263 name: &str,
264 map: Map<String, Value>,
265) -> Result<LockfileEntry> {
266 let package: BunPackageObject =
267 serde_json::from_value(Value::Object(map)).map_err(|err| Error::LockfileParseFailed {
268 path: lockfile_path.to_path_buf(),
269 message: format!("{name}: failed to parse object entry: {err}"),
270 })?;
271
272 let BunPackageObject {
273 name: _object_name,
274 version,
275 resolution,
276 locator,
277 checksum: raw_checksum,
278 integrity,
279 dependencies,
280 dev_dependencies,
281 optional_dependencies,
282 peer_dependencies,
283 } = package;
284
285 let locator = locator
286 .or(resolution)
287 .unwrap_or_else(|| format!("{name}@{}", version.unwrap_or_default()));
288
289 let checksum = raw_checksum.clone().or_else(|| integrity.clone());
290
291 let metadata = BunPackageMetadata {
292 dependencies,
293 dev_dependencies,
294 optional_dependencies,
295 peer_dependencies,
296 integrity,
297 checksum: raw_checksum,
298 };
299
300 build_package_entry(lockfile_path, name, &locator, checksum, &metadata)
301}
302
303fn build_package_entry(
304 lockfile_path: &Path,
305 package_name: &str,
306 locator: &str,
307 checksum: Option<String>,
308 metadata: &BunPackageMetadata,
309) -> Result<LockfileEntry> {
310 let locator_info = parse_locator(lockfile_path, locator, package_name)?;
311
312 let mut dependencies = Vec::new();
313 push_dependencies(&mut dependencies, &metadata.dependencies);
314 push_dependencies(&mut dependencies, &metadata.dev_dependencies);
315 push_dependencies(&mut dependencies, &metadata.optional_dependencies);
316 push_dependencies(&mut dependencies, &metadata.peer_dependencies);
317
318 Ok(LockfileEntry {
319 name: package_name.to_string(),
320 version: locator_info.version,
321 source: locator_info.source,
322 checksum,
323 dependencies,
324 is_workspace_member: false,
325 })
326}
327
328fn push_dependencies(target: &mut Vec<DependencyRef>, deps: &BTreeMap<String, String>) {
329 for (name, version_req) in deps {
330 target.push(DependencyRef {
331 name: name.clone(),
332 version_req: version_req.clone(),
333 });
334 }
335}
336
337struct LocatorDetails {
338 version: String,
339 source: DependencySource,
340}
341
342fn parse_locator(
343 lockfile_path: &Path,
344 locator: &str,
345 package_name: &str,
346) -> Result<LocatorDetails> {
347 let trimmed = locator.trim();
348 if trimmed.is_empty() {
349 return Err(Error::LockfileParseFailed {
350 path: lockfile_path.to_path_buf(),
351 message: format!("Package {package_name} has empty locator"),
352 });
353 }
354
355 if let Some(rest) = trimmed.strip_prefix("workspace:") {
356 return Ok(LocatorDetails {
357 version: "0.0.0".to_string(),
358 source: DependencySource::Workspace(workspace_path(rest)),
359 });
360 }
361
362 let spec_part = match trimmed.rfind('@') {
363 Some(idx) if idx + 1 < trimmed.len() => &trimmed[idx + 1..],
364 _ => trimmed,
365 };
366
367 let (protocol, remainder) = spec_part.find(':').map_or(("npm", spec_part), |colon_idx| {
368 (&spec_part[..colon_idx], &spec_part[colon_idx + 1..])
369 });
370
371 let remainder = remainder.trim();
372 let source = match protocol {
373 "npm" | "registry" => DependencySource::Registry(format!("npm:{package_name}@{remainder}")),
374 "github" => DependencySource::Git(format!("https://github.com/{remainder}")),
375 "git" | "git+https" | "git+ssh" => DependencySource::Git(format!("{protocol}:{remainder}")),
376 "file" => DependencySource::Path(PathBuf::from(remainder)),
377 "workspace" => DependencySource::Workspace(workspace_path(remainder)),
378 other => DependencySource::Registry(format!("{other}:{remainder}")),
379 };
380
381 let version = match protocol {
382 "github" => remainder.split('#').nth(1).unwrap_or(remainder).to_string(),
383 "file" | "workspace" => "0.0.0".to_string(),
384 _ => remainder.to_string(),
385 };
386
387 Ok(LocatorDetails { version, source })
388}
389
390fn workspace_path(path: &str) -> PathBuf {
391 if path.is_empty() {
392 PathBuf::from(".")
393 } else {
394 PathBuf::from(path)
395 }
396}
397
398#[allow(clippy::option_if_let_else)] fn 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}