cuenv_workspaces/parsers/javascript/
bun.rs

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/// Parser for Bun `bun.lock` files (text/JSONC). Binary `bun.lockb` is rejected.
11#[derive(Debug, Default, Clone, Copy)]
12pub struct BunLockfileParser;
13
14impl LockfileParser for BunLockfileParser {
15    #[allow(clippy::too_many_lines)] // Lockfile parsing requires linear handling of format variants
16    fn parse(&self, lockfile_path: &Path) -> Result<Vec<LockfileEntry>> {
17        // Check if this is the binary bun.lockb format
18        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        // Parse JSONC using jsonc-parser and convert to serde_json::Value
36        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        // Convert jsonc_parser::JsonValue to serde_json::Value
48        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        // Only support the text-based JSONC format, not the binary bun.lockb
88        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            // Some lockfiles use a terse tuple form where the third slot is a
242            // checksum string instead of an object. Treat it as a checksum and
243            // otherwise fall back to default metadata.
244            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// Convert jsonc_parser::JsonValue to serde_json::Value
399#[allow(clippy::option_if_let_else)] // Result-based parsing with fallbacks - imperative is clearer
400fn 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            // Try to parse as i64 first, then f64
406            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            // Iterate over owned entries without cloning
421            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}