use serde_json::{Value, json};
use crate::{
forge::request::{FileChange, FileUpdateType},
packages::manifests::ManifestFile,
result::Result,
updater::{manager::UpdaterPackage, traits::PackageUpdater},
};
pub struct PackageLock {}
impl Default for PackageLock {
fn default() -> Self {
PackageLock::new()
}
}
impl PackageLock {
pub fn new() -> Self {
Self {}
}
fn update_lock_file(
&self,
manifest: &ManifestFile,
package: &UpdaterPackage,
workspace_packages: &[UpdaterPackage],
) -> Result<Option<FileChange>> {
let mut lock_doc = self.load_doc(&manifest.content)?;
lock_doc["version"] = json!(package.next_version.semver.to_string());
if let Some(packages) = lock_doc.get_mut("packages")
&& let Some(packages_obj) = packages.as_object_mut()
{
for (key, package_info) in packages_obj {
if key.is_empty() {
package_info["version"] =
json!(package.next_version.semver.to_string());
if let Some(deps) = package_info.get_mut("dependencies")
&& let Some(deps_obj) = deps.as_object_mut()
{
for ws_package in workspace_packages.iter() {
if let Some((_, dep_info)) =
deps_obj.iter_mut().find(|(name, _)| {
name.to_string() == ws_package.package_name
})
{
*dep_info = json!(format!(
"{}",
ws_package.next_version.semver.to_string()
));
}
}
}
if let Some(deps) = package_info.get_mut("devDependencies")
&& let Some(deps_obj) = deps.as_object_mut()
{
for ws_package in workspace_packages.iter() {
if let Some((_, dep_info)) =
deps_obj.iter_mut().find(|(name, _)| {
name.to_string() == ws_package.package_name
})
{
*dep_info = json!(format!(
"{}",
ws_package.next_version.semver.to_string()
));
}
}
}
continue;
}
if let Some(package_name) = key.strip_prefix("node_modules/")
&& let Some(ws_pkg) = workspace_packages
.iter()
.find(|p| p.package_name == package_name)
{
package_info["version"] =
json!(ws_pkg.next_version.semver.to_string());
}
}
}
let formatted_json = serde_json::to_string_pretty(&lock_doc)?;
Ok(Some(FileChange {
path: manifest.path.to_string_lossy().to_string(),
content: formatted_json,
update_type: FileUpdateType::Replace,
}))
}
fn load_doc(&self, content: &str) -> Result<Value> {
let doc = serde_json::from_str(content)?;
Ok(doc)
}
}
impl PackageUpdater for PackageLock {
fn update(
&self,
package: &UpdaterPackage,
workspace_packages: &[UpdaterPackage],
) -> Result<Option<Vec<FileChange>>> {
let mut file_changes = vec![];
for manifest in package.manifest_files.iter() {
if manifest.basename != "package-lock.json" {
continue;
}
if let Some(change) =
self.update_lock_file(manifest, package, workspace_packages)?
{
file_changes.push(change);
}
}
if file_changes.is_empty() {
return Ok(None);
}
Ok(Some(file_changes))
}
}
#[cfg(test)]
mod tests {
use std::{path::Path, rc::Rc};
use crate::{
config::release_type::ReleaseType, forge::request::Tag,
packages::manifests::ManifestFile, updater::dispatch::Updater,
};
use super::*;
#[test]
fn updates_version_field() {
let package_lock = PackageLock::new();
let content =
r#"{"name":"my-package","version":"1.0.0","packages":{}}"#;
let manifest = ManifestFile {
path: Path::new("package-lock.json").to_path_buf(),
basename: "package-lock.json".to_string(),
content: content.to_string(),
};
let package = UpdaterPackage {
package_name: "my-package".to_string(),
manifest_files: vec![manifest.clone()],
next_version: Tag {
name: "v2.0.0".into(),
semver: semver::Version::parse("2.0.0").unwrap(),
sha: "abc".into(),
..Tag::default()
},
updater: Rc::new(Updater::new(ReleaseType::Node)),
};
let result = package_lock.update(&package, &[]).unwrap();
let updated = result.unwrap()[0].content.clone();
assert!(updated.contains("\"version\": \"2.0.0\""));
}
#[test]
fn updates_root_package_entry_version() {
let package_lock = PackageLock::new();
let content = r#"{
"name": "my-package",
"version": "1.0.0",
"packages": {
"": {
"name": "my-package",
"version": "1.0.0"
}
}
}"#;
let manifest = ManifestFile {
path: Path::new("package-lock.json").to_path_buf(),
basename: "package-lock.json".to_string(),
content: content.to_string(),
};
let package = UpdaterPackage {
package_name: "my-package".to_string(),
manifest_files: vec![manifest.clone()],
next_version: Tag {
name: "v2.0.0".into(),
semver: semver::Version::parse("2.0.0").unwrap(),
sha: "abc".into(),
..Tag::default()
},
updater: Rc::new(Updater::new(ReleaseType::Node)),
};
let result = package_lock.update(&package, &[]).unwrap();
let updated = result.unwrap()[0].content.clone();
assert!(updated.contains("\"version\": \"2.0.0\""));
assert_eq!(updated.matches("\"version\": \"2.0.0\"").count(), 2);
}
#[test]
fn updates_workspace_dependencies_in_lock_file() {
let package_lock = PackageLock::new();
let content = r#"{
"name": "package-a",
"version": "1.0.0",
"packages": {
"": {
"name": "package-a",
"version": "1.0.0",
"dependencies": {
"package-b": "1.0.0"
}
}
}
}"#;
let manifest = ManifestFile {
path: Path::new("package-lock.json").to_path_buf(),
basename: "package-lock.json".to_string(),
content: content.to_string(),
};
let package_a = UpdaterPackage {
package_name: "package-a".to_string(),
manifest_files: vec![manifest.clone()],
next_version: Tag {
name: "v2.0.0".into(),
semver: semver::Version::parse("2.0.0").unwrap(),
sha: "abc".into(),
..Tag::default()
},
updater: Rc::new(Updater::new(ReleaseType::Node)),
};
let package_b = UpdaterPackage {
package_name: "package-b".to_string(),
manifest_files: vec![],
next_version: Tag {
name: "v3.0.0".into(),
semver: semver::Version::parse("3.0.0").unwrap(),
sha: "def".into(),
..Tag::default()
},
updater: Rc::new(Updater::new(ReleaseType::Node)),
};
let result = package_lock
.update(&package_a, &[package_a.clone(), package_b])
.unwrap();
let updated = result.unwrap()[0].content.clone();
assert!(updated.contains("\"package-b\": \"3.0.0\""));
}
#[test]
fn updates_workspace_dev_dependencies_in_lock_file() {
let package_lock = PackageLock::new();
let content = r#"{
"name": "package-a",
"version": "1.0.0",
"packages": {
"": {
"name": "package-a",
"version": "1.0.0",
"devDependencies": {
"package-b": "1.0.0"
}
}
}
}"#;
let manifest = ManifestFile {
path: Path::new("package-lock.json").to_path_buf(),
basename: "package-lock.json".to_string(),
content: content.to_string(),
};
let package_a = UpdaterPackage {
package_name: "package-a".to_string(),
manifest_files: vec![manifest.clone()],
next_version: Tag {
name: "v2.0.0".into(),
semver: semver::Version::parse("2.0.0").unwrap(),
sha: "abc".into(),
..Tag::default()
},
updater: Rc::new(Updater::new(ReleaseType::Node)),
};
let package_b = UpdaterPackage {
package_name: "package-b".to_string(),
manifest_files: vec![],
next_version: Tag {
name: "v3.0.0".into(),
semver: semver::Version::parse("3.0.0").unwrap(),
sha: "def".into(),
..Tag::default()
},
updater: Rc::new(Updater::new(ReleaseType::Node)),
};
let result = package_lock
.update(&package_a, &[package_a.clone(), package_b])
.unwrap();
let updated = result.unwrap()[0].content.clone();
assert!(updated.contains("\"package-b\": \"3.0.0\""));
}
#[test]
fn updates_node_modules_entries_for_workspace_packages() {
let package_lock = PackageLock::new();
let content = r#"{
"name": "package-a",
"version": "1.0.0",
"packages": {
"": {
"name": "package-a",
"version": "1.0.0"
},
"node_modules/package-b": {
"version": "1.0.0"
}
}
}"#;
let manifest = ManifestFile {
path: Path::new("package-lock.json").to_path_buf(),
basename: "package-lock.json".to_string(),
content: content.to_string(),
};
let package_a = UpdaterPackage {
package_name: "package-a".to_string(),
manifest_files: vec![manifest.clone()],
next_version: Tag {
name: "v2.0.0".into(),
semver: semver::Version::parse("2.0.0").unwrap(),
sha: "abc".into(),
..Tag::default()
},
updater: Rc::new(Updater::new(ReleaseType::Node)),
};
let package_b = UpdaterPackage {
package_name: "package-b".to_string(),
manifest_files: vec![],
next_version: Tag {
name: "v3.0.0".into(),
semver: semver::Version::parse("3.0.0").unwrap(),
sha: "def".into(),
..Tag::default()
},
updater: Rc::new(Updater::new(ReleaseType::Node)),
};
let result = package_lock
.update(&package_a, &[package_a.clone(), package_b])
.unwrap();
let updated = result.unwrap()[0].content.clone();
let parsed: Value = serde_json::from_str(&updated).unwrap();
assert_eq!(
parsed["packages"]["node_modules/package-b"]["version"],
"3.0.0"
);
}
#[test]
fn handles_non_workspace_lock_files() {
let package_lock = PackageLock::new();
let content = r#"{
"name": "my-package",
"version": "1.0.0",
"packages": {
"": {
"name": "my-package",
"version": "1.0.0"
}
}
}"#;
let manifest = ManifestFile {
path: Path::new("package-lock.json").to_path_buf(),
basename: "package-lock.json".to_string(),
content: content.to_string(),
};
let package = UpdaterPackage {
package_name: "my-package".to_string(),
manifest_files: vec![manifest.clone()],
next_version: Tag {
name: "v2.0.0".into(),
semver: semver::Version::parse("2.0.0").unwrap(),
sha: "abc".into(),
..Tag::default()
},
updater: Rc::new(Updater::new(ReleaseType::Node)),
};
let result = package_lock.update(&package, &[]).unwrap();
let updated = result.unwrap()[0].content.clone();
assert!(updated.contains("\"version\": \"2.0.0\""));
}
#[test]
fn process_package_handles_multiple_lock_files() {
let package_lock = PackageLock::new();
let manifest1 = ManifestFile {
path: Path::new("packages/a/package-lock.json").to_path_buf(),
basename: "package-lock.json".to_string(),
content: r#"{"name":"package-a","version":"1.0.0","packages":{}}"#
.to_string(),
};
let manifest2 = ManifestFile {
path: Path::new("packages/b/package-lock.json").to_path_buf(),
basename: "package-lock.json".to_string(),
content: r#"{"name":"package-b","version":"1.0.0","packages":{}}"#
.to_string(),
};
let package = UpdaterPackage {
package_name: "test".to_string(),
manifest_files: vec![manifest1, manifest2],
next_version: Tag {
name: "v2.0.0".into(),
semver: semver::Version::parse("2.0.0").unwrap(),
sha: "abc".into(),
..Tag::default()
},
updater: Rc::new(Updater::new(ReleaseType::Node)),
};
let result = package_lock.update(&package, &[]).unwrap();
let changes = result.unwrap();
assert_eq!(changes.len(), 2);
assert!(changes.iter().all(|c| c.content.contains("2.0.0")));
}
#[test]
fn process_package_returns_none_when_no_lock_files() {
let package_lock = PackageLock::new();
let manifest = ManifestFile {
path: Path::new("package.json").to_path_buf(),
basename: "package.json".to_string(),
content: r#"{"name":"my-package","version":"1.0.0"}"#.to_string(),
};
let package = UpdaterPackage {
package_name: "test".to_string(),
manifest_files: vec![manifest],
next_version: Tag {
name: "v2.0.0".into(),
semver: semver::Version::parse("2.0.0").unwrap(),
sha: "abc".into(),
..Tag::default()
},
updater: Rc::new(Updater::new(ReleaseType::Node)),
};
let result = package_lock.update(&package, &[]).unwrap();
assert!(result.is_none());
}
#[test]
fn preserves_other_fields_in_lock_file() {
let package_lock = PackageLock::new();
let content = r#"{
"name": "my-package",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "my-package",
"version": "1.0.0"
}
}
}"#;
let manifest = ManifestFile {
path: Path::new("package-lock.json").to_path_buf(),
basename: "package-lock.json".to_string(),
content: content.to_string(),
};
let package = UpdaterPackage {
package_name: "my-package".to_string(),
manifest_files: vec![manifest.clone()],
next_version: Tag {
name: "v2.0.0".into(),
semver: semver::Version::parse("2.0.0").unwrap(),
sha: "abc".into(),
..Tag::default()
},
updater: Rc::new(Updater::new(ReleaseType::Node)),
};
let result = package_lock.update(&package, &[]).unwrap();
let updated = result.unwrap()[0].content.clone();
assert!(updated.contains("\"version\": \"2.0.0\""));
assert!(updated.contains("\"lockfileVersion\": 2"));
assert!(updated.contains("\"requires\": true"));
}
}