diff --git a/crates/turborepo-lib/src/commands/prune.rs b/crates/turborepo-lib/src/commands/prune.rs index ad05a5d9c0d77..01ad384ac305d 100644 --- a/crates/turborepo-lib/src/commands/prune.rs +++ b/crates/turborepo-lib/src/commands/prune.rs @@ -47,8 +47,6 @@ pub enum Error { MissingWorkspace(PackageName), #[error("Cannot prune without parsed lockfile.")] MissingLockfile, - #[error("`prune` is not supported for Bun.")] - BunUnsupported, #[error("Unable to read config: {0}")] Config(#[from] crate::config::Error), } @@ -106,13 +104,6 @@ pub async fn prune( let prune = Prune::new(base, scope, docker, output_dir, use_gitignore, telemetry).await?; - if matches!( - prune.package_graph.package_manager(), - turborepo_repository::package_manager::PackageManager::Bun - ) { - return Err(Error::BunUnsupported); - } - println!( "Generating pruned monorepo for {} in {}", base.color_config.apply(BOLD.apply_to(scope.join(", "))), diff --git a/crates/turborepo-lockfiles/src/bun/de.rs b/crates/turborepo-lockfiles/src/bun/de.rs index b4544033ad3d9..c2006b3231180 100644 --- a/crates/turborepo-lockfiles/src/bun/de.rs +++ b/crates/turborepo-lockfiles/src/bun/de.rs @@ -3,6 +3,7 @@ use std::collections::VecDeque; use serde::Deserialize; use super::{PackageEntry, PackageInfo}; +use crate::bun::RootInfo; // Comment explaining entry schemas taken from bun.lock.zig // first index is resolution for each type of package // npm -> [ @@ -32,6 +33,8 @@ impl<'de> Deserialize<'de> for PackageEntry { Info(Box), } let mut vals = VecDeque::::deserialize(deserializer)?; + + // First value is always the package key let key = vals .pop_front() .ok_or_else(|| de::Error::custom("expected package entry to not be empty"))?; @@ -44,22 +47,53 @@ impl<'de> Deserialize<'de> for PackageEntry { Vals::Str(_) => None, Vals::Info(package_info) => Some(*package_info), }; - // For workspace packages deps are second element, rest have them as third - // element - let info = vals - .pop_front() - .and_then(val_to_info) - .or_else(|| vals.pop_front().and_then(val_to_info)); + + let mut registry = None; + let mut info = None; + + // Special case: root packages have a unique second value, so we handle it here + if key.ends_with("@root:") { + let root = vals.pop_front().and_then(|val| { + serde_json::from_value::(match val { + Vals::Info(info) => { + serde_json::to_value(info.other).expect("failed to convert info to value") + } + _ => return None, + }) + .ok() + }); + return Ok(Self { + ident: key, + info, + registry, + checksum: None, + root, + }); + } + + // The second value can be either registry (string) or info (object) + if let Some(val) = vals.pop_front() { + match val { + Vals::Str(reg) => registry = Some(reg), + Vals::Info(package_info) => info = Some(*package_info), + } + }; + + // Info will be next if we haven't already found it + if info.is_none() { + info = vals.pop_front().and_then(val_to_info); + } + + // Checksum is last let checksum = vals.pop_front().and_then(|val| match val { Vals::Str(sha) => Some(sha), Vals::Info(_) => None, }); + Ok(Self { ident: key, info, - // The rest are only necessary for serializing a lockfile and aren't needed until adding - // `prune` support - registry: None, + registry, checksum, root: None, }) @@ -114,7 +148,7 @@ mod test { PackageEntry, PackageEntry { ident: "is-odd@3.0.1".into(), - registry: None, + registry: Some("".into()), info: Some(PackageInfo { dependencies: Some(("is-number".into(), "^6.0.0".into())) .into_iter() @@ -142,10 +176,26 @@ mod test { root: None, } ); + + fixture!( + root_pkg, + PackageEntry, + PackageEntry { + ident: "some-package@root:".into(), + root: Some(RootInfo { + bin: Some("bin".into()), + bin_dir: Some("binDir".into()), + }), + info: None, + registry: None, + checksum: None, + } + ); #[test_case(json!({"name": "bun-test", "devDependencies": {"turbo": "^2.3.3"}}), basic_workspace() ; "basic")] #[test_case(json!({"name": "docs", "version": "0.1.0"}), workspace_with_version() ; "with version")] #[test_case(json!(["is-odd@3.0.1", "", {"dependencies": {"is-number": "^6.0.0"}}, "sha"]), registry_pkg() ; "registry package")] #[test_case(json!(["docs", {"dependencies": {"is-odd": "3.0.1"}}]), workspace_pkg() ; "workspace package")] + #[test_case(json!(["some-package@root:", {"bin": "bin", "binDir": "binDir"}]), root_pkg() ; "root package")] fn test_deserialization Deserialize<'a> + PartialEq + std::fmt::Debug>( input: serde_json::Value, expected: &T, diff --git a/crates/turborepo-lockfiles/src/bun/mod.rs b/crates/turborepo-lockfiles/src/bun/mod.rs index ae4ec6e367153..69bf0f74c6a3a 100644 --- a/crates/turborepo-lockfiles/src/bun/mod.rs +++ b/crates/turborepo-lockfiles/src/bun/mod.rs @@ -1,10 +1,14 @@ -use std::{any::Any, collections::HashMap, str::FromStr}; +use std::{ + any::Any, + collections::{HashMap, HashSet}, + str::FromStr, +}; use biome_json_formatter::context::JsonFormatOptions; use biome_json_parser::JsonParserOptions; use id::PossibleKeyIter; use itertools::Itertools as _; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use turborepo_errors::ParseDiagnostic; @@ -12,6 +16,7 @@ use crate::Lockfile; mod de; mod id; +mod ser; type Map = std::collections::BTreeMap; @@ -23,8 +28,6 @@ pub enum Error { Format(#[from] biome_formatter::FormatError), #[error("Failed to strip commas: {0}")] Print(#[from] biome_formatter::PrintError), - #[error("Turborepo cannot serialize Bun lockfiles.")] - NotImplemented, #[error("{ident} had two entries with differing checksums: {sha1}, {sha2}")] MismatchedShas { ident: String, @@ -39,30 +42,36 @@ pub struct BunLockfile { key_to_entry: HashMap, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct BunLockfileData { #[allow(unused)] lockfile_version: i32, workspaces: Map, packages: Map, - #[serde(default)] + #[serde(default, skip_serializing_if = "Map::is_empty")] patched_dependencies: Map, } -#[derive(Debug, Deserialize, PartialEq, Default)] +#[derive(Debug, Deserialize, PartialEq, Default, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct WorkspaceEntry { name: String, + #[serde(skip_serializing_if = "Option::is_none")] version: Option, + #[serde(skip_serializing_if = "Option::is_none")] dependencies: Option>, + #[serde(skip_serializing_if = "Option::is_none")] dev_dependencies: Option>, + #[serde(skip_serializing_if = "Option::is_none")] optional_dependencies: Option>, + #[serde(skip_serializing_if = "Option::is_none")] peer_dependencies: Option>, + #[serde(skip_serializing_if = "Option::is_none")] optional_peers: Option>, } -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] struct PackageEntry { ident: String, registry: Option, @@ -73,22 +82,22 @@ struct PackageEntry { root: Option, } -#[derive(Debug, Deserialize, Default, PartialEq)] +#[derive(Debug, Deserialize, Default, PartialEq, Clone, Serialize)] struct PackageInfo { - #[serde(default)] + #[serde(default, skip_serializing_if = "Map::is_empty")] dependencies: Map, - #[serde(default)] + #[serde(default, skip_serializing_if = "Map::is_empty")] dev_dependencies: Map, - #[serde(default)] + #[serde(default, skip_serializing_if = "Map::is_empty")] optional_dependencies: Map, - #[serde(default)] + #[serde(default, skip_serializing_if = "Map::is_empty")] peer_dependencies: Map, // We do not care about the rest here // the values here should be generic #[serde(flatten)] other: Map, } -#[derive(Debug, Deserialize, PartialEq)] +#[derive(Debug, Deserialize, PartialEq, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct RootInfo { bin: Option, @@ -165,17 +174,17 @@ impl Lockfile for BunLockfile { Ok(Some(deps)) } - #[allow(unused)] fn subgraph( &self, workspace_packages: &[String], packages: &[String], - ) -> Result, super::Error> { - Err(crate::Error::Bun(Error::NotImplemented)) + ) -> Result, crate::Error> { + let subgraph = self.subgraph(workspace_packages, packages)?; + Ok(Box::new(subgraph)) } fn encode(&self) -> Result, crate::Error> { - Err(crate::Error::Bun(Error::NotImplemented)) + Ok(serde_json::to_vec(&self.data)?) } fn global_change(&self, other: &dyn Lockfile) -> bool { @@ -209,6 +218,69 @@ impl BunLockfile { PossibleKeyIter::new(key).find_map(|k| self.data.packages.get_key_value(k))?; Some((key, entry)) } + + pub fn lockfile(self) -> Result { + Ok(self.data) + } + + fn subgraph( + &self, + workspace_packages: &[String], + packages: &[String], + ) -> Result { + let new_workspaces: Map<_, _> = self + .data + .workspaces + .iter() + .filter_map(|(key, entry)| { + // Ensure the root workspace package is included, which is always indexed by "" + if key.is_empty() || workspace_packages.contains(key) { + Some((key.clone(), entry.clone())) + } else { + None + } + }) + .collect(); + + // Filter out packages that are not in the subgraph. Note that _multiple_ + // entries can correspond to the same ident. + let idents: HashSet<_> = packages.iter().collect(); + let new_packages: Map<_, _> = self + .data + .packages + .iter() + .filter_map(|(key, entry)| { + if idents.contains(&entry.ident) { + Some((key.clone(), entry.clone())) + } else { + None + } + }) + .collect(); + + let new_patched_dependencies = self + .data + .patched_dependencies + .iter() + .filter_map(|(ident, patch)| { + if idents.contains(ident) { + Some((ident.clone(), patch.clone())) + } else { + None + } + }) + .collect(); + + Ok(Self { + data: BunLockfileData { + lockfile_version: self.data.lockfile_version, + workspaces: new_workspaces, + packages: new_packages, + patched_dependencies: new_patched_dependencies, + }, + key_to_entry: self.key_to_entry.clone(), + }) + } } impl FromStr for BunLockfile { @@ -304,6 +376,113 @@ mod test { assert_eq!(result.key, expected); } + #[test] + fn test_subgraph() { + let lockfile = BunLockfile::from_str(BASIC_LOCKFILE).unwrap(); + let subgraph = lockfile + .subgraph(&["apps/docs".into()], &["is-odd@3.0.1".into()]) + .unwrap(); + let subgraph_data = subgraph.lockfile().unwrap(); + + assert_eq!( + subgraph_data + .packages + .iter() + .map(|(key, pkg)| (key.as_str(), pkg.ident.as_str())) + .collect::>(), + vec![("is-odd", "is-odd@3.0.1")] + ); + assert_eq!( + subgraph_data.workspaces.keys().collect::>(), + vec!["", "apps/docs"] + ); + } + + // There are multiple aliases that resolve to the same ident, here we test that + // we output them all + #[test] + fn test_deduplicated_idents() { + // chalk@2.4.2 + let lockfile = BunLockfile::from_str(BASIC_LOCKFILE).unwrap(); + let subgraph = lockfile + .subgraph(&["apps/docs".into()], &["chalk@2.4.2".into()]) + .unwrap(); + let subgraph_data = subgraph.lockfile().unwrap(); + + assert_eq!( + subgraph_data + .packages + .iter() + .map(|(key, pkg)| (key.as_str(), pkg.ident.as_str())) + .collect::>(), + vec![ + ("@turbo/gen/chalk", "chalk@2.4.2"), + ("@turbo/workspaces/chalk", "chalk@2.4.2"), + ("log-symbols/chalk", "chalk@2.4.2") + ] + ); + assert_eq!( + subgraph_data.workspaces.keys().collect::>(), + vec!["", "apps/docs"] + ); + } + + #[test] + fn test_patch_subgraph() { + let lockfile = BunLockfile::from_str(PATCH_LOCKFILE).unwrap(); + let subgraph_a = lockfile + .subgraph(&["apps/a".into()], &["is-odd@3.0.1".into()]) + .unwrap(); + let subgraph_a_data = subgraph_a.lockfile().unwrap(); + + assert_eq!( + subgraph_a_data + .packages + .iter() + .map(|(key, pkg)| (key.as_str(), pkg.ident.as_str())) + .collect::>(), + vec![("is-odd", "is-odd@3.0.1")] + ); + assert_eq!( + subgraph_a_data.workspaces.keys().collect::>(), + vec!["", "apps/a"] + ); + assert_eq!( + subgraph_a_data + .patched_dependencies + .iter() + .map(|(key, patch)| (key.as_str(), patch.as_str())) + .collect::>(), + vec![] + ); + + let subgraph_b = lockfile + .subgraph(&["apps/b".into()], &["is-odd@3.0.0".into()]) + .unwrap(); + let subgraph_b_data = subgraph_b.lockfile().unwrap(); + + assert_eq!( + subgraph_b_data + .packages + .iter() + .map(|(key, pkg)| (key.as_str(), pkg.ident.as_str())) + .collect::>(), + vec![("b/is-odd", "is-odd@3.0.0")] + ); + assert_eq!( + subgraph_b_data.workspaces.keys().collect::>(), + vec!["", "apps/b"] + ); + assert_eq!( + subgraph_b_data + .patched_dependencies + .iter() + .map(|(key, patch)| (key.as_str(), patch.as_str())) + .collect::>(), + vec![("is-odd@3.0.0", "patches/is-odd@3.0.0.patch")] + ); + } + const TURBO_GEN_DEPS: &[&str] = [ "@turbo/gen/chalk", "@turbo/gen/minimatch", diff --git a/crates/turborepo-lockfiles/src/bun/ser.rs b/crates/turborepo-lockfiles/src/bun/ser.rs new file mode 100644 index 0000000000000..3cc3669c1950b --- /dev/null +++ b/crates/turborepo-lockfiles/src/bun/ser.rs @@ -0,0 +1,156 @@ +use serde::{ser::SerializeTuple, Serialize}; + +use super::PackageEntry; + +// Comment explaining entry schemas taken from bun.lock.zig +// first index is resolution for each type of package +// npm -> [ +// "name@version", +// registry (TODO: remove if default), +// INFO, +// integrity +// ] +// symlink -> [ "name@link:path", INFO ] +// folder -> [ "name@file:path", INFO ] +// workspace -> [ "name@workspace:path", INFO ] +// tarball -> [ "name@tarball", INFO ] +// root -> [ "name@root:", { bin, binDir } ] +// git -> [ "name@git+repo", INFO, .bun-tag string (TODO: remove this) ] +// github -> [ "name@github:user/repo", INFO, .bun-tag string (TODO: remove +// this) ] +impl Serialize for PackageEntry { + fn serialize(&self, serializer: S) -> Result { + let mut tuple = serializer.serialize_tuple(4)?; + + // First value is always the package key + tuple.serialize_element(&self.ident)?; + + // For root packages, only thing left to serialize is the root info + if let Some(root) = &self.root { + tuple.serialize_element(root)?; + return tuple.end(); + } + + // npm packages have a registry + if let Some(registry) = &self.registry { + tuple.serialize_element(registry)?; + } + + // All packages have info in the next slot + if let Some(info) = &self.info { + tuple.serialize_element(info)?; + }; + + // npm packages, git, and github have a checksum/integrity + if let Some(checksum) = &self.checksum { + tuple.serialize_element(checksum)?; + } + + tuple.end() + } +} + +#[cfg(test)] +mod test { + use std::sync::OnceLock; + + use serde_json::json; + use test_case::test_case; + + use super::*; + use crate::bun::{PackageInfo, RootInfo, WorkspaceEntry}; + + macro_rules! fixture { + ($name:ident, $kind:ty, $cons:expr) => { + fn $name() -> &'static $kind { + static ONCE: OnceLock<$kind> = OnceLock::new(); + ONCE.get_or_init(|| $cons) + } + }; + } + + fixture!( + basic_workspace, + WorkspaceEntry, + WorkspaceEntry { + name: "bun-test".into(), + dev_dependencies: Some( + Some(("turbo".to_string(), "^2.3.3".to_string())) + .into_iter() + .collect() + ), + ..Default::default() + } + ); + + fixture!( + workspace_with_version, + WorkspaceEntry, + WorkspaceEntry { + name: "docs".into(), + version: Some("0.1.0".into()), + ..Default::default() + } + ); + + fixture!( + registry_pkg, + PackageEntry, + PackageEntry { + ident: "is-odd@3.0.1".into(), + registry: Some("".into()), + info: Some(PackageInfo { + dependencies: Some(("is-number".into(), "^6.0.0".into())) + .into_iter() + .collect(), + ..Default::default() + }), + checksum: Some("sha".into()), + root: None, + } + ); + + fixture!( + workspace_pkg, + PackageEntry, + PackageEntry { + ident: "docs".into(), + info: Some(PackageInfo { + dependencies: Some(("is-odd".into(), "3.0.1".into())) + .into_iter() + .collect(), + ..Default::default() + }), + registry: None, + checksum: None, + root: None, + } + ); + + fixture!( + root_pkg, + PackageEntry, + PackageEntry { + ident: "some-package@root:".into(), + root: Some(RootInfo { + bin: Some("bin".into()), + bin_dir: Some("binDir".into()), + }), + info: None, + registry: None, + checksum: None, + } + ); + #[test_case(json!({"name": "bun-test", "devDependencies": {"turbo": "^2.3.3"}}), basic_workspace() ; "basic")] + #[test_case(json!({"name": "docs", "version": "0.1.0"}), workspace_with_version() ; "with version")] + #[test_case(json!(["is-odd@3.0.1", "", {"dependencies": {"is-number": "^6.0.0"}}, "sha"]), registry_pkg() ; "registry package")] + #[test_case(json!(["docs", {"dependencies": {"is-odd": "3.0.1"}}]), workspace_pkg() ; "workspace package")] + #[test_case(json!(["some-package@root:", {"bin": "bin", "binDir": "binDir"}]), root_pkg() ; "root package")] + fn test_serialization( + expected: serde_json::Value, + input: &T, + ) { + let actual = serde_json::to_value(input).unwrap(); + assert_eq!(actual, expected); + } +}