use std::collections::BTreeMap;
use std::collections::HashMap;
use deno_semver::Version;
use deno_semver::package::PackageNv;
use serde_json::Value;
use thiserror::Error;
pub type JsonMap = serde_json::Map<String, Value>;
pub fn transform1_to_2(json: JsonMap) -> JsonMap {
let mut new_map = JsonMap::new();
new_map.insert("version".to_string(), "2".into());
new_map.insert("remote".to_string(), json.into());
new_map
}
pub fn transform2_to_3(mut json: JsonMap) -> JsonMap {
json.insert("version".into(), "3".into());
if let Some(Value::Object(mut npm_obj)) = json.remove("npm") {
let mut new_obj = JsonMap::new();
if let Some(packages) = npm_obj.remove("packages") {
new_obj.insert("npm".into(), packages);
}
if let Some(Value::Object(specifiers)) = npm_obj.remove("specifiers") {
let mut new_specifiers = JsonMap::new();
for (key, value) in specifiers {
if let Value::String(value) = value {
new_specifiers
.insert(format!("npm:{}", key), format!("npm:{}", value).into());
}
}
if !new_specifiers.is_empty() {
new_obj.insert("specifiers".into(), new_specifiers.into());
}
}
json.insert("packages".into(), new_obj.into());
}
json
}
#[derive(Debug, Error)]
pub enum TransformError {
#[error("Failed extracting npm name and version from dep '{id}'.")]
FailedExtractingV3NpmDepNv { id: String },
#[error("Failed getting npm package info: {source}")]
FailedGettingNpmPackageInfo {
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
}
fn extract_nv_from_id(value: &str) -> Option<(&str, &str)> {
if value.is_empty() {
return None;
}
let at_index = value[1..].find('@')? + 1;
let name = &value[..at_index];
let version = &value[at_index + 1..];
Some((name, version))
}
fn split_pkg_req(value: &str) -> Option<(&str, Option<&str>)> {
if value.len() < 5 {
return None;
}
let Some(at_index) = value[5..].find('@').map(|i| i + 5) else {
return Some((value, None));
};
let name = &value[..at_index];
let version = &value[at_index + 1..];
Some((name, Some(version)))
}
pub fn transform3_to_4(mut json: JsonMap) -> Result<JsonMap, TransformError> {
json.insert("version".into(), "4".into());
if let Some(Value::Object(mut packages)) = json.remove("packages") {
if let Some((npm_key, Value::Object(mut npm))) =
packages.remove_entry("npm")
{
let mut pkg_had_multiple_versions: HashMap<String, bool> =
HashMap::with_capacity(npm.len());
for id in npm.keys() {
let Some((name, _)) = extract_nv_from_id(id) else {
continue; };
pkg_had_multiple_versions
.entry(name.to_string())
.and_modify(|v| *v = true)
.or_default();
}
for value in npm.values_mut() {
let Value::Object(value) = value else {
continue;
};
let Some(Value::Object(deps)) = value.remove("dependencies") else {
continue;
};
let mut new_deps = Vec::with_capacity(deps.len());
for (key, id) in deps {
let Value::String(id) = id else {
continue;
};
let Some((name, version)) = extract_nv_from_id(&id) else {
return Err(TransformError::FailedExtractingV3NpmDepNv {
id: id.to_string(),
});
};
if name == key {
let has_single_version = pkg_had_multiple_versions
.get(name)
.map(|had_multiple| !had_multiple)
.unwrap_or(false);
if has_single_version {
new_deps.push(Value::String(name.to_string()));
} else {
new_deps.push(Value::String(format!("{}@{}", name, version)));
}
} else {
new_deps
.push(Value::String(format!("{}@npm:{}@{}", key, name, version)));
}
}
value.insert("dependencies".into(), new_deps.into());
}
json.insert(npm_key, npm.into());
}
if let Some((jsr_key, Value::Object(mut jsr))) =
packages.remove_entry("jsr")
{
let mut pkg_had_multiple_specifiers: HashMap<&str, bool> = HashMap::new();
if let Some(Value::Object(specifiers)) = packages.get("specifiers") {
pkg_had_multiple_specifiers.reserve(specifiers.len());
for req in specifiers.keys() {
let Some((name, _)) = split_pkg_req(req) else {
continue; };
pkg_had_multiple_specifiers
.entry(name)
.and_modify(|v| *v = true)
.or_default();
}
}
for pkg in jsr.values_mut() {
let Some(Value::Array(deps)) = pkg.get_mut("dependencies") else {
continue;
};
for dep in deps.iter_mut() {
let Value::String(dep) = dep else {
continue;
};
let Some((name, _)) = split_pkg_req(dep) else {
continue;
};
if let Some(false) = pkg_had_multiple_specifiers.get(name) {
*dep = name.to_string();
}
}
}
json.insert(jsr_key, jsr.into());
}
if let Some(Value::Object(specifiers)) = packages.get_mut("specifiers") {
for value in specifiers.values_mut() {
let Value::String(value) = value else {
continue;
};
let Some((_, Some(id_stripped))) = split_pkg_req(value) else {
continue;
};
*value = id_stripped.to_string();
}
}
for (key, value) in packages {
json.insert(key, value);
}
}
Ok(json)
}
#[derive(Debug, Error)]
#[error(
"Expected a different number of results from npm package info provider."
)]
pub struct MissingNpmPackageInfo;
#[derive(Debug, PartialEq, Eq)]
struct IdParts {
key: String,
package_name: String,
version: String,
}
fn split_id(
id: &str,
version_by_dep_name: &HashMap<String, String>,
) -> Option<IdParts> {
let (left, right) = match extract_nv_from_id(id) {
Some((name, version)) => (name, version),
None => match version_by_dep_name.get(id) {
Some(version) => (id, &**version),
None => {
return None;
}
},
};
let (key, package_name, version) = match right.strip_prefix("npm:") {
Some(right) => {
match extract_nv_from_id(right) {
Some((package_name, version)) => (left, package_name, version),
None => {
return None;
}
}
}
None => (left, left, right),
};
let version = version.split_once('_').map(|(v, _)| v).unwrap_or(version);
Some(IdParts {
key: key.into(),
package_name: package_name.into(),
version: version.into(),
})
}
pub async fn transform4_to_5(
mut json: JsonMap,
info_provider: &dyn NpmPackageInfoProvider,
) -> Result<JsonMap, TransformError> {
json.insert("version".into(), "5".into());
if let Some(Value::Object(mut npm)) = json.remove("npm") {
let mut npm_packages = Vec::new();
let mut keys = Vec::new();
let mut has_multiple_versions = HashMap::new();
let mut version_by_dep_name = HashMap::new();
for (key, _) in &npm {
let Some(id_parts) = split_id(key, &version_by_dep_name) else {
continue;
};
let Ok(version) = Version::parse_standard(&id_parts.version) else {
continue;
};
has_multiple_versions
.entry(id_parts.package_name.to_string())
.and_modify(|v| *v = true)
.or_default();
version_by_dep_name.insert(
id_parts.package_name.to_string(),
id_parts.version.to_string(),
);
npm_packages.push(PackageNv {
name: id_parts.package_name.as_str().into(),
version,
});
keys.push(key.clone());
}
let results = info_provider
.get_npm_package_info(&npm_packages)
.await
.map_err(|source| TransformError::FailedGettingNpmPackageInfo {
source,
})?;
if results.len() != keys.len() {
return Err(TransformError::FailedGettingNpmPackageInfo {
source: Box::new(MissingNpmPackageInfo),
});
}
for (key, result) in keys.iter().zip(results) {
let Some(Value::Object(value)) = npm.get_mut(key) else {
continue;
};
let mut existing_deps = BTreeMap::new();
if let Some(Value::Array(deps)) = value.remove("dependencies") {
existing_deps.extend(deps.iter().filter_map(|v| {
let id = v.as_str().map(|s| s.to_string())?;
let id_parts = split_id(&id, &version_by_dep_name)?;
Some((id_parts.key, id))
}));
}
let mut new_optional_deps = Vec::new();
if !result.optional_dependencies.is_empty() {
for (key, _) in result.optional_dependencies {
if let Some(id) = existing_deps.remove(&*key) {
new_optional_deps.push(id);
}
}
value.insert("optionalDependencies".into(), new_optional_deps.into());
}
let mut new_optional_peer_deps = Vec::new();
if !result.optional_peers.is_empty() {
for (key, value) in result.optional_peers {
new_optional_peer_deps.push(format!("{}@{}", key, value));
}
value.insert("optionalPeers".into(), new_optional_peer_deps.into());
}
if existing_deps.is_empty() {
value.remove("dependencies");
} else {
value.insert(
"dependencies".into(),
existing_deps
.into_values()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.into(),
);
}
if !result.cpu.is_empty() {
value.insert("cpu".into(), result.cpu.into());
}
if !result.os.is_empty() {
value.insert("os".into(), result.os.into());
}
if let Some(tarball_url) = result.tarball_url {
value.insert("tarball".into(), tarball_url.into());
}
if result.deprecated {
value.insert("deprecated".into(), true.into());
}
if result.scripts {
value.insert("scripts".into(), true.into());
}
if result.bin {
value.insert("bin".into(), true.into());
}
}
json.insert("npm".into(), npm.into());
}
Ok(json)
}
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Lockfile5NpmInfo {
pub tarball_url: Option<String>,
pub optional_dependencies: BTreeMap<String, String>,
pub optional_peers: BTreeMap<String, String>,
pub cpu: Vec<String>,
pub os: Vec<String>,
pub deprecated: bool,
pub scripts: bool,
pub bin: bool,
}
#[async_trait::async_trait(?Send)]
pub trait NpmPackageInfoProvider {
async fn get_npm_package_info(
&self,
values: &[PackageNv],
) -> Result<Vec<Lockfile5NpmInfo>, Box<dyn std::error::Error + Send + Sync>>;
}
#[cfg(test)]
mod test {
use std::future::Future;
use async_executor::LocalExecutor;
use pretty_assertions::assert_eq;
use serde_json::json;
use super::*;
#[test]
fn test_transforms_1_to_2() {
let data: JsonMap = serde_json::from_value(json!({
"https://github.com/": "asdf",
"https://github.com/mod.ts": "asdf2",
}))
.unwrap();
let result = transform1_to_2(data);
assert_eq!(
result,
serde_json::from_value(json!({
"version": "2",
"remote": {
"https://github.com/": "asdf",
"https://github.com/mod.ts": "asdf2",
}
}))
.unwrap()
);
}
#[test]
fn test_transforms_2_to_3() {
let data: JsonMap = serde_json::from_value(json!({
"version": "2",
"remote": {
"https://github.com/": "asdf",
"https://github.com/mod.ts": "asdf2",
},
"npm": {
"specifiers": {
"nanoid": "nanoid@3.3.4",
},
"packages": {
"nanoid@3.3.4": {
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
"dependencies": {}
},
"picocolors@1.0.0": {
"integrity": "sha512-foobar",
"dependencies": {}
}
}
}
})).unwrap();
let result = transform2_to_3(data);
assert_eq!(result, serde_json::from_value(json!({
"version": "3",
"remote": {
"https://github.com/": "asdf",
"https://github.com/mod.ts": "asdf2",
},
"packages": {
"specifiers": {
"npm:nanoid": "npm:nanoid@3.3.4",
},
"npm": {
"nanoid@3.3.4": {
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
"dependencies": {}
},
"picocolors@1.0.0": {
"integrity": "sha512-foobar",
"dependencies": {}
}
}
}
})).unwrap());
}
#[test]
fn test_transforms_3_to_4_basic() {
let data: JsonMap = serde_json::from_value(json!({
"version": "3",
"remote": {
"https://github.com/": "asdf",
"https://github.com/mod.ts": "asdf2",
},
"packages": {
"specifiers": {
"npm:package-a": "npm:package-a@3.3.4",
},
"npm": {
"package-a@3.3.4": {
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
"dependencies": {
"package-b": "package-b@1.0.0",
"package-c": "package-c@1.0.0",
"other": "package-d@1.0.0",
}
},
"package-b@1.0.0": {
"integrity": "sha512-foobar",
"dependencies": {}
},
"package-c@1.0.0": {
"integrity": "sha512-foobar",
"dependencies": {}
},
"package-c@2.0.0": {
"integrity": "sha512-foobar",
"dependencies": {
"package-e": "package-e@1.0.0_package-d@1.0.0",
}
},
"package-d@1.0.0": {
"integrity": "sha512-foobar",
"dependencies": {}
},
"package-e@1.0.0_package-d@1.0.0": {
"integrity": "sha512-foobar",
"dependencies": {
"package-d": "package-d@1.0.0",
}
}
}
}
})).unwrap();
let result = transform3_to_4(data).unwrap();
assert_eq!(result, serde_json::from_value(json!({
"version": "4",
"remote": {
"https://github.com/": "asdf",
"https://github.com/mod.ts": "asdf2",
},
"npm": {
"package-a@3.3.4": {
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
"dependencies": [
"package-b",
"package-c@1.0.0",
"other@npm:package-d@1.0.0",
]
},
"package-b@1.0.0": {
"integrity": "sha512-foobar",
"dependencies": []
},
"package-c@1.0.0": {
"integrity": "sha512-foobar",
"dependencies": []
},
"package-c@2.0.0": {
"integrity": "sha512-foobar",
"dependencies": [
"package-e"
]
},
"package-d@1.0.0": {
"integrity": "sha512-foobar",
"dependencies": []
},
"package-e@1.0.0_package-d@1.0.0": {
"integrity": "sha512-foobar",
"dependencies": [
"package-d"
]
}
},
"specifiers": {
"npm:package-a": "3.3.4",
}
})).unwrap());
}
fn run_async<T: Send + Sync>(f: impl Future<Output = T>) -> T {
let executor = LocalExecutor::new();
let handle = executor.run(f);
futures_lite::future::block_on(handle)
}
struct TestNpmPackageInfoProvider {
packages: HashMap<PackageNv, Lockfile5NpmInfo>,
}
#[async_trait::async_trait(?Send)]
impl NpmPackageInfoProvider for TestNpmPackageInfoProvider {
async fn get_npm_package_info(
&self,
values: &[PackageNv],
) -> Result<Vec<Lockfile5NpmInfo>, Box<dyn std::error::Error + Send + Sync>>
{
Ok(
values
.iter()
.map(|v| {
self
.packages
.get(v)
.unwrap_or_else(|| panic!("no info for {v}"))
.clone()
})
.collect(),
)
}
}
fn nv(name_and_version: &str) -> PackageNv {
PackageNv::from_str(name_and_version).unwrap()
}
fn default_info(name_and_version: &str) -> (PackageNv, Lockfile5NpmInfo) {
(nv(name_and_version), Lockfile5NpmInfo::default())
}
#[test]
fn test_transforms_4_to_5() {
let result = run_async(async move {
let packages = [
(
nv("package-a@3.3.4"),
Lockfile5NpmInfo {
optional_dependencies: [
("package-b", "1.0.0"),
("othername", "package-c@1.0.0"),
("package-d", "1.0.0"),
]
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
..Default::default()
},
),
default_info("package-b@1.0.0"),
(
nv("package-c@1.0.0"),
Lockfile5NpmInfo {
bin: true,
..Default::default()
},
),
(
nv("package-d@1.0.0"),
Lockfile5NpmInfo {
scripts: true,
..Default::default()
},
),
(
nv("package-d@2.0.0"),
Lockfile5NpmInfo {
optional_peers: [("package-z", "1.0.0")]
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
..Default::default()
},
),
(
nv("package-e@1.0.0"),
Lockfile5NpmInfo {
deprecated: true,
..Default::default()
},
),
];
let data = serde_json::from_value(json!({
"version": "4",
"npm": {
"package-a@3.3.4": {
"integrity": "sha512-foobar",
"dependencies": [
"package-b",
"othername@npm:package-c@1.0.0",
"package-d@1.0.0",
"package-e",
]
},
"package-b@1.0.0": {
"integrity": "sha512-foobar",
"dependencies": []
},
"package-c@1.0.0": {
"integrity": "sha512-foobar",
"dependencies": ["package-d@2.0.0"]
},
"package-d@1.0.0": {
"integrity": "sha512-foobar",
"dependencies": []
},
"package-d@2.0.0": {
"integrity": "sha512-foobar",
"dependencies": []
},
"package-e@1.0.0": {
"integrity": "sha512-foobar",
"dependencies": []
},
}
}))
.unwrap();
transform4_to_5(
data,
&TestNpmPackageInfoProvider {
packages: HashMap::from_iter(packages),
},
)
.await
.unwrap()
});
assert_eq!(
result,
serde_json::from_value(json!({
"version": "5",
"npm": {
"package-a@3.3.4": {
"integrity": "sha512-foobar",
"dependencies": [
"package-e",
],
"optionalDependencies": ["othername@npm:package-c@1.0.0", "package-b", "package-d@1.0.0"]
},
"package-b@1.0.0": {
"integrity": "sha512-foobar",
},
"package-c@1.0.0": {
"integrity": "sha512-foobar",
"dependencies": ["package-d@2.0.0"],
"bin": true,
},
"package-d@1.0.0": {
"integrity": "sha512-foobar",
"scripts": true,
},
"package-d@2.0.0": {
"integrity": "sha512-foobar",
"optionalPeers": ["package-z@1.0.0"],
},
"package-e@1.0.0": {
"integrity": "sha512-foobar",
"deprecated": true,
},
}
}))
.unwrap()
);
}
fn parts(key: &str, package_name: &str, version: &str) -> Option<IdParts> {
Some(IdParts {
key: key.to_string(),
package_name: package_name.to_string(),
version: version.to_string(),
})
}
#[test]
fn test_split_id() {
let mut version_by_dep_name = HashMap::default();
let ids = [
("package-a@1.0.0", parts("package-a", "package-a", "1.0.0")),
(
"othername@npm:package-b@1.0.0",
parts("othername", "package-b", "1.0.0"),
),
(
"package-c@1.0.0_package-d@1.0.0",
parts("package-c", "package-c", "1.0.0"),
),
("package-d@1.0.0", parts("package-d", "package-d", "1.0.0")),
(
"othername@npm:package-b@1.0.0_package-d@1.0.0_package-e@1.0.0",
parts("othername", "package-b", "1.0.0"),
),
];
for (id, expected) in ids {
let id_parts = split_id(id, &version_by_dep_name);
assert_eq!(id_parts, expected);
if let Some(id_parts) = id_parts {
version_by_dep_name.insert(id_parts.package_name, id_parts.version);
} else {
panic!("failed to split {id}");
}
}
}
}