diff --git a/Cargo.lock b/Cargo.lock index 5ea1b1cd..d8c8068b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -557,6 +557,12 @@ dependencies = [ "writeable", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -1417,9 +1423,9 @@ dependencies = [ [[package]] name = "napi" -version = "2.16.9" +version = "2.16.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1277600d452e570cc83cf5f4e8efb389cc21e5cbefadcfba7239f4551e2e3e99" +checksum = "04409e8c2d61995696e44d2181b79b68c1dd41f7e24a17cde60bbd9f54ddddef" dependencies = [ "bitflags", "ctor", @@ -1433,9 +1439,9 @@ dependencies = [ [[package]] name = "napi-derive" -version = "2.16.11" +version = "2.16.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "150d87c4440b9f4815cb454918db498b5aae9a57aa743d20783fe75381181d01" +checksum = "17435f7a00bfdab20b0c27d9c56f58f6499e418252253081bfff448099da31d1" dependencies = [ "cfg-if", "convert_case", @@ -1447,9 +1453,9 @@ dependencies = [ [[package]] name = "napi-derive-backend" -version = "1.0.73" +version = "1.0.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cd81b794fc1d6051acf8c4f3cb4f82833b0621272a232b4ff0cf3df1dbddb61" +checksum = "967c485e00f0bf3b1bdbe510a38a4606919cf1d34d9a37ad41f25a81aa077abe" dependencies = [ "convert_case", "once_cell", @@ -1650,6 +1656,16 @@ dependencies = [ "sha2", ] +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + [[package]] name = "phf" version = "0.11.2" @@ -2798,7 +2814,7 @@ dependencies = [ [[package]] name = "workspace-node-tools" -version = "1.0.19" +version = "2.0.0" dependencies = [ "chrono", "execute", @@ -2806,6 +2822,7 @@ dependencies = [ "icu", "napi", "napi-derive", + "petgraph", "rand", "regex", "semver", diff --git a/Cargo.toml b/Cargo.toml index 96a11b01..1d18aba5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "workspace-node-tools" -version = "1.0.19" +version = "2.0.0" edition = "2021" description = "Node workspace version tools" repository = "https://github.com/websublime/workspace-node-tools" @@ -18,8 +18,8 @@ serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" regex = "1.10.6" wax = { version = "0.6.0", features = ["walk"] } -napi-derive = { version = "2.16.11", optional = true } -napi = { version = "2.16.9", default-features = false, features = [ +napi-derive = { version = "2.16.12", optional = true } +napi = { version = "2.16.10", default-features = false, features = [ "napi9", "serde-json", "tokio_rt", @@ -30,6 +30,7 @@ git-cliff-core = "2.5.0" chrono = "0.4.38" semver = "1.0.23" rand = "0.8.5" +petgraph = "0.6.5" [build-dependencies] vergen = { version = "8.3.2", features = [ diff --git a/src/bumps.rs b/src/bumps.rs index fb00c5a2..8f67f2ef 100644 --- a/src/bumps.rs +++ b/src/bumps.rs @@ -1,5 +1,5 @@ #![warn(dead_code)] -#![warn(unused_imports)] +#![allow(unused_imports)] #![allow(clippy::all)] //! # Bumps @@ -7,21 +7,23 @@ //! This module is responsible for managing the bumps in the monorepo. use semver::{BuildMetadata, Prerelease, Version as SemVersion}; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::Value; + +use std::collections::HashMap; use std::fs::OpenOptions; use std::io::{BufWriter, Write}; use std::path::PathBuf; -use std::time::SystemTime; -use super::changes::init_changes; -use super::conventional::ConventionalPackage; +use crate::conventional::ConventionalPackage; + +use super::changes::{get_package_change, init_changes, Change}; use super::conventional::{get_conventional_for_package, ConventionalPackageOptions}; use super::git::{ - git_add, git_add_all, git_all_files_changed_since_sha, git_commit, git_config, git_current_sha, - git_fetch_all, git_push, git_tag, + git_add_all, git_all_files_changed_since_sha, git_commit, git_config, git_current_branch, + git_current_sha, git_fetch_all, git_push, git_tag, }; -use super::packages::get_packages; use super::packages::PackageInfo; +use super::packages::{get_package_info, get_packages}; use super::paths::get_project_root_path; #[cfg(feature = "napi")] @@ -48,9 +50,9 @@ pub enum Bump { #[napi(object)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct BumpOptions { - pub packages: Vec, + pub changes: Vec, pub since: Option, - pub release_as: Bump, + pub release_as: Option, pub fetch_all: Option, pub fetch_tags: Option, pub sync_deps: Option, @@ -62,9 +64,9 @@ pub struct BumpOptions { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] /// Struct representing the options for the bump operation. pub struct BumpOptions { - pub packages: Vec, + pub changes: Vec, pub since: Option, - pub release_as: Bump, + pub release_as: Option, pub fetch_all: Option, pub fetch_tags: Option, pub sync_deps: Option, @@ -78,8 +80,8 @@ pub struct BumpOptions { pub struct BumpPackage { pub from: String, pub to: String, - pub release_as: Bump, - pub conventional: ConventionalPackage, + pub package_info: PackageInfo, + pub conventional_commits: Value, } #[cfg(feature = "napi")] @@ -88,8 +90,33 @@ pub struct BumpPackage { pub struct BumpPackage { pub from: String, pub to: String, - pub release_as: Bump, + pub package_info: PackageInfo, + pub conventional_commits: Value, +} + +#[cfg(not(feature = "napi"))] +#[derive(Debug, Clone, Deserialize, Serialize)] +/// Struct representing the bump package. +pub struct RecommendBumpPackage { + pub from: String, + pub to: String, + pub package_info: PackageInfo, + pub conventional: ConventionalPackage, + pub changed_files: Vec, + pub deploy_to: Vec, +} + +#[cfg(feature = "napi")] +#[napi(object)] +#[derive(Debug, Clone, Deserialize, Serialize)] +/// Struct representing the bump package. +pub struct RecommendBumpPackage { + pub from: String, + pub to: String, + pub package_info: PackageInfo, pub conventional: ConventionalPackage, + pub changed_files: Vec, + pub deploy_to: Vec, } impl Bump { @@ -126,11 +153,7 @@ impl Bump { /// Bumps the version of the package to snapshot appending the sha to the version. fn bump_snapshot(version: String) -> SemVersion { let sha = git_current_sha(None); - let duration_since_epoch = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap(); - let timestamp_nanos = duration_since_epoch.as_nanos(); - let alpha = format!("alpha.{}.{}", timestamp_nanos, sha); + let alpha = format!("alpha.{}.{}", 0, sha); let mut sem_version = SemVersion::parse(&version).unwrap(); sem_version.pre = Prerelease::new(alpha.as_str()).unwrap_or(Prerelease::EMPTY); @@ -139,202 +162,216 @@ impl Bump { } } -/// Bumps the version of dev-dependencies and dependencies. -pub fn sync_bumps(bump_package: &BumpPackage, cwd: Option) -> Vec { - let ref root = match cwd { - Some(ref dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(), - None => get_project_root_path(None).unwrap(), - }; - - get_packages(Some(root.to_string())) - .iter() - .filter(|package| { - let mut package_json_map = serde_json::Map::new(); - package_json_map.clone_from(package.pkg_json.as_object().unwrap()); - - if package_json_map.contains_key("dependencies") { - let dependencies_value = package_json_map.get_mut("dependencies").unwrap(); - let dependencies_value = dependencies_value.as_object_mut().unwrap(); - let has_dependency = - dependencies_value.contains_key(&bump_package.conventional.package_info.name); - - if has_dependency { - dependencies_value - .entry(bump_package.conventional.package_info.name.to_string()) - .and_modify(|version| *version = json!(bump_package.to.to_string())); - - package_json_map["dependencies"] = json!(dependencies_value); - - let file = OpenOptions::new() - .write(true) - .truncate(true) - .open(&package.package_json_path) - .unwrap(); - let writer = BufWriter::new(&file); - serde_json::to_writer_pretty(writer, &package_json_map).unwrap(); - - git_add(&root.to_string(), &package.package_json_path.to_owned()) - .expect("Failed to add package.json"); - git_commit( - format!( - "chore: update dependency {} in {}", - bump_package.conventional.package_info.name.to_string(), - package.name.to_string() - ), - None, - None, - Some(root.to_string()), - ) - .expect("Failed to commit package.json"); - } +pub fn get_package_recommend_bump( + package_info: &PackageInfo, + root: &String, + options: Option, +) -> RecommendBumpPackage { + let ref current_branch = + git_current_branch(Some(root.to_string())).unwrap_or(String::from("origin/main")); + + let package_version = &package_info.version.to_string(); + let package_name = &package_info.name.to_string(); + let package_change = get_package_change( + package_name.to_string(), + current_branch.to_string(), + Some(root.to_string()), + ); + + let settings = options.unwrap_or_else(|| BumpOptions { + changes: vec![], + since: None, + release_as: None, + fetch_all: None, + fetch_tags: None, + sync_deps: None, + push: None, + cwd: None, + }); + + let ref since = settings.since.unwrap_or(String::from("origin/main")); + + let release_as = settings + .release_as + .unwrap_or_else(|| match package_change.to_owned() { + Some(change) => change.release_as, + None => Bump::Patch, + }); - return has_dependency; - } + let deploy_to = match package_change.to_owned() { + Some(change) => change.deploy, + None => vec![String::from("production")], + }; - if package_json_map.contains_key("devDependencies") { - let dev_dependencies_value = package_json_map.get_mut("devDependencies").unwrap(); - let dev_dependencies_value = dev_dependencies_value.as_object_mut().unwrap(); - let has_dependency = dev_dependencies_value - .contains_key(&bump_package.conventional.package_info.name); - - if has_dependency { - dev_dependencies_value - .entry(bump_package.conventional.package_info.name.to_string()) - .and_modify(|version| *version = json!(bump_package.to.to_string())); - - package_json_map["devDependencies"] = json!(dev_dependencies_value); - - let file = OpenOptions::new() - .write(true) - .truncate(true) - .open(&package.package_json_path) - .unwrap(); - let writer = BufWriter::new(&file); - serde_json::to_writer_pretty(writer, &package_json_map).unwrap(); - - git_add(&root.to_string(), &package.package_json_path.to_owned()) - .expect("Failed to add package.json"); - git_commit( - format!( - "chore: update devDependency {} in {}", - bump_package.conventional.package_info.name.to_string(), - package.name.to_string() - ), - None, - None, - Some(root.to_string()), - ) - .expect("Failed to commit package.json"); - } + let fetch_all = settings.fetch_all.unwrap_or(false); - return has_dependency; - } + let semversion = match release_as { + Bump::Major => Bump::bump_major(package_version.to_string()), + Bump::Minor => Bump::bump_minor(package_version.to_string()), + Bump::Patch => Bump::bump_patch(package_version.to_string()), + Bump::Snapshot => Bump::bump_snapshot(package_version.to_string()), + }; - false - }) - .map(|package| package.name.to_string()) - .collect::>() + let changed_files = git_all_files_changed_since_sha(since.to_string(), Some(root.to_string())); + let ref version = semversion.to_string(); + + let conventional = get_conventional_for_package( + &package_info, + Some(fetch_all), + Some(root.to_string()), + &Some(ConventionalPackageOptions { + version: Some(version.to_string()), + title: Some("# What changed?".to_string()), + }), + ); + + RecommendBumpPackage { + from: package_version.to_string(), + to: version.to_string(), + package_info: package_info.to_owned(), + conventional: conventional.to_owned(), + changed_files: changed_files.to_owned(), + deploy_to: deploy_to.to_owned(), + } } /// Get bumps version of the package. If sync_deps is true, it will also sync the dependencies and dev-dependencies. -/// It will also commit the changes to git. -pub fn get_bumps(options: BumpOptions) -> Vec { +pub fn get_bumps(options: &BumpOptions) -> Vec { let ref root = match options.cwd { Some(ref dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(), None => get_project_root_path(None).unwrap(), }; - let ref since = match options.since { - Some(ref since) => since.to_string(), - None => String::from("main"), - }; - - let release_as = options.release_as.to_owned(); - let mut bumps: Vec = vec![]; - if options.fetch_tags.is_some() { git_fetch_all(Some(root.to_string()), options.fetch_tags) .expect("No possible to fetch tags"); } - let packages = get_packages(Some(root.to_string())) + let since = options.since.clone().unwrap_or(String::from("main")); + + let ref packages = get_packages(Some(root.to_string())); + let changed_packages = packages .iter() - .filter(|package| options.packages.contains(&package.name)) + .filter(|package| { + options + .changes + .iter() + .any(|change| change.package == package.name) + }) .map(|package| package.to_owned()) .collect::>(); + //let changed_packages = get_changed_packages(Some(since.to_string()), Some(root.to_string())); - if packages.len() == 0 { - return bumps; + if changed_packages.len() == 0 { + return vec![]; } - for mut package in packages { - let package_version = &package.version.to_string(); - let package_name = &package.name.to_string(); + let mut bump_changes = HashMap::new(); + let mut bump_dependencies = HashMap::new(); - let already_bumped = bumps + for changed_package in changed_packages.iter() { + let change = options + .changes .iter() - .any(|b| b.conventional.package_info.name.eq(package_name)); + .find(|change| change.package == changed_package.name); - if already_bumped { - continue; + if change.is_some() { + bump_changes.insert(changed_package.name.to_string(), change.unwrap().to_owned()); } - let semversion = match release_as { - Bump::Major => Bump::bump_major(package_version.to_string()), - Bump::Minor => Bump::bump_minor(package_version.to_string()), - Bump::Patch => Bump::bump_patch(package_version.to_string()), - Bump::Snapshot => Bump::bump_snapshot(package_version.to_string()), - }; - - let dependency_semversion = match release_as { - Bump::Snapshot => Bump::Snapshot, - _ => Bump::Patch, - }; - - let changed_files = - git_all_files_changed_since_sha(since.to_string(), Some(root.to_string())); - let ref version = semversion.to_string(); - - package.update_version(version.to_string()); - package.extend_changed_files(changed_files); - - let conventional = get_conventional_for_package( - &package, - options.fetch_all, - Some(root.to_string()), - &Some(ConventionalPackageOptions { - version: Some(version.to_string()), - title: Some("# What changed?".to_string()), - }), - ); - - let bump = BumpPackage { - from: package_version.to_string(), - to: version.to_string(), - release_as, - conventional, - }; - - bumps.push(bump.to_owned()); - if options.sync_deps.unwrap_or(false) { - let sync_packages = sync_bumps(&bump, Some(root.to_string())); + packages.iter().for_each(|package| { + package.dependencies.iter().for_each(|dependency| { + if dependency.name == changed_package.name { + if change.is_some() && !bump_changes.contains_key(&package.name) { + bump_changes.insert( + package.name.to_string(), + Change { + package: package.name.to_string(), + release_as: Bump::Patch, + deploy: change.unwrap().deploy.to_owned(), + }, + ); + } + } + }); + }); + } + } - if sync_packages.len() > 0 { - let sync_bumps = get_bumps(BumpOptions { - packages: sync_packages, + let mut bumps = bump_changes + .iter() + .map(|(package_name, change)| { + let package = get_package_info(package_name.to_string(), Some(root.to_string())); + + let recommended_bump = get_package_recommend_bump( + &package.unwrap(), + root, + Some(BumpOptions { + changes: vec![change.to_owned()], since: Some(since.to_string()), - release_as: dependency_semversion, - fetch_all: options.fetch_all, - fetch_tags: options.fetch_tags, - sync_deps: Some(true), - push: Some(false), + release_as: Some(change.release_as.to_owned()), + fetch_all: options.fetch_all.to_owned(), + fetch_tags: options.fetch_tags.to_owned(), + sync_deps: options.sync_deps.to_owned(), + push: options.push.to_owned(), cwd: Some(root.to_string()), - }); + }), + ); + + let bump = BumpPackage { + from: recommended_bump.from.to_string(), + to: recommended_bump.to.to_string(), + conventional_commits: recommended_bump + .conventional + .conventional_commits + .to_owned(), + package_info: recommended_bump.package_info.to_owned(), + }; + + if bump.package_info.dependencies.len() > 0 { + bump_dependencies.insert( + package_name.to_string(), + bump.package_info.dependencies.to_owned(), + ); + } - bumps.extend(sync_bumps); + return bump; + }) + .collect::>(); + + bumps.iter_mut().for_each(|bump| { + let version = bump.to.to_string(); + bump.package_info.update_version(version.to_string()); + bump.package_info + .extend_changed_files(vec![String::from("package.json")]); + bump.package_info.write_package_json(); + }); + + if options.sync_deps.unwrap_or(false) { + bump_dependencies.iter().for_each(|(package_name, deps)| { + let temp_bumps = bumps.clone(); + let bump = bumps + .iter_mut() + .find(|b| b.package_info.name == package_name.to_string()) + .unwrap(); + + for dep in deps { + let bump_dep = temp_bumps.iter().find(|b| b.package_info.name == dep.name); + + if bump_dep.is_some() { + bump.package_info.update_dependency_version( + dep.name.to_string(), + bump_dep.unwrap().to.to_string(), + ); + bump.package_info.update_dev_dependency_version( + dep.name.to_string(), + bump_dep.unwrap().to.to_string(), + ); + bump.package_info.write_package_json(); + } } - } + }); } bumps @@ -342,7 +379,7 @@ pub fn get_bumps(options: BumpOptions) -> Vec { /// Apply version bumps, commit and push changes. Returns a list of packages that have been updated. /// Also generate changelog file and update dependencies and devDependencies in package.json. -pub fn apply_bumps(options: BumpOptions) -> Vec { +pub fn apply_bumps(options: &BumpOptions) -> Vec { let ref root = match options.cwd { Some(ref dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(), None => get_project_root_path(None).unwrap(), @@ -359,16 +396,16 @@ pub fn apply_bumps(options: BumpOptions) -> Vec { ) .expect("Failed to set git user name and email"); - let bumps = get_bumps(options.to_owned()); + let bumps = get_bumps(options); if bumps.len() != 0 { for bump in &bumps { let git_message = changes_data.message.to_owned(); let ref bump_pkg_json_file_path = - PathBuf::from(bump.conventional.package_info.package_json_path.to_string()); + PathBuf::from(bump.package_info.package_json_path.to_string()); let ref bump_changelog_file_path = - PathBuf::from(bump.conventional.package_info.package_path.to_string()) + PathBuf::from(bump.package_info.package_path.to_string()) .join(String::from("CHANGELOG.md")); // Write bump_pkg_json_file_path @@ -378,8 +415,17 @@ pub fn apply_bumps(options: BumpOptions) -> Vec { .open(bump_pkg_json_file_path) .unwrap(); let pkg_json_writer = BufWriter::new(bump_pkg_json_file); - serde_json::to_writer_pretty(pkg_json_writer, &bump.conventional.package_info.pkg_json) - .unwrap(); + serde_json::to_writer_pretty(pkg_json_writer, &bump.package_info.pkg_json).unwrap(); + + let conventional = get_conventional_for_package( + &bump.package_info, + options.fetch_all.to_owned(), + Some(root.to_string()), + &Some(ConventionalPackageOptions { + version: Some(bump.to.to_string()), + title: Some("# What changed?".to_string()), + }), + ); // Write bump_changelog_file_path let mut bump_changelog_file = OpenOptions::new() @@ -390,10 +436,10 @@ pub fn apply_bumps(options: BumpOptions) -> Vec { .unwrap(); bump_changelog_file - .write_all(bump.conventional.changelog_output.as_bytes()) + .write_all(conventional.changelog_output.as_bytes()) .unwrap(); - let ref package_tag = format!("{}@{}", bump.conventional.package_info.name, bump.to); + let ref package_tag = format!("{}@{}", bump.package_info.name, bump.to); git_add_all(&root.to_string()).expect("Failed to add all files to git"); git_commit( @@ -407,7 +453,7 @@ pub fn apply_bumps(options: BumpOptions) -> Vec { package_tag.to_string(), Some(format!( "chore: release {} to version {}", - bump.conventional.package_info.name, bump.to + bump.package_info.name, bump.to )), Some(root.to_string()), ) @@ -425,7 +471,7 @@ pub fn apply_bumps(options: BumpOptions) -> Vec { #[cfg(test)] mod tests { use super::*; - + use crate::changes::{add_change, get_change, init_changes}; use crate::manager::PackageManager; use crate::packages::get_changed_packages; use crate::paths::get_project_root_path; @@ -436,12 +482,151 @@ mod tests { use std::process::Command; use std::process::Stdio; - fn create_packages_change( + fn create_single_changes(root: &String) -> Result<(), Box> { + let change_package_a = Change { + package: String::from("@scope/package-a"), + release_as: Bump::Major, + deploy: vec![String::from("production")], + }; + + init_changes(Some(root.to_string()), &None); + + add_change(&change_package_a, Some(root.to_string())); + + Ok(()) + } + + fn create_single_package(monorepo_dir: &PathBuf) -> Result<(), Box> { + let js_path = monorepo_dir.join("packages/package-a/index.js"); + + let branch = Command::new("git") + .current_dir(&monorepo_dir) + .arg("checkout") + .arg("-b") + .arg("feat/message") + .stdout(Stdio::piped()) + .spawn() + .expect("Git branch problem"); + + branch.wait_with_output()?; + + let mut js_file = File::create(&js_path)?; + js_file + .write_all(r#"export const message = "hello package-a";"#.as_bytes()) + .unwrap(); + + let add = Command::new("git") + .current_dir(&monorepo_dir) + .arg("add") + .arg(".") + .stdout(Stdio::piped()) + .spawn() + .expect("Git add problem"); + + add.wait_with_output()?; + + let commit = Command::new("git") + .current_dir(&monorepo_dir) + .arg("commit") + .arg("-m") + .arg("feat: message to the world") + .stdout(Stdio::piped()) + .spawn() + .expect("Git commit problem"); + + commit.wait_with_output()?; + + Ok(()) + } + + fn create_multiple_changes(root: &String) -> Result<(), Box> { + let change_package_a = Change { + package: String::from("@scope/package-a"), + release_as: Bump::Major, + deploy: vec![String::from("production")], + }; + + let change_package_c = Change { + package: String::from("@scope/package-c"), + release_as: Bump::Minor, + deploy: vec![String::from("production")], + }; + + init_changes(Some(root.to_string()), &None); + + add_change(&change_package_a, Some(root.to_string())); + add_change(&change_package_c, Some(root.to_string())); + + Ok(()) + } + + fn create_multiple_packages(monorepo_dir: &PathBuf) -> Result<(), Box> { + let js_path_package_a = monorepo_dir.join("packages/package-a/index.js"); + let js_path_package_c = monorepo_dir.join("packages/package-c/index.js"); + + let branch = Command::new("git") + .current_dir(&monorepo_dir) + .arg("checkout") + .arg("-b") + .arg("feat/message") + .stdout(Stdio::piped()) + .spawn() + .expect("Git branch problem"); + + branch.wait_with_output()?; + + let mut js_file_package_a = File::create(&js_path_package_a)?; + js_file_package_a + .write_all(r#"export const message = "hello package-a";"#.as_bytes()) + .unwrap(); + + let mut js_file_package_c = File::create(&js_path_package_c)?; + js_file_package_c + .write_all(r#"export const message = "hello package-c";"#.as_bytes()) + .unwrap(); + + let add = Command::new("git") + .current_dir(&monorepo_dir) + .arg("add") + .arg(".") + .stdout(Stdio::piped()) + .spawn() + .expect("Git add problem"); + + add.wait_with_output()?; + + let commit = Command::new("git") + .current_dir(&monorepo_dir) + .arg("commit") + .arg("-m") + .arg("feat: message to the world") + .stdout(Stdio::piped()) + .spawn() + .expect("Git commit problem"); + + commit.wait_with_output()?; + + Ok(()) + } + + fn create_single_dependency_changes(root: &String) -> Result<(), Box> { + let change_package_a = Change { + package: String::from("@scope/package-b"), + release_as: Bump::Snapshot, + deploy: vec![String::from("production")], + }; + + init_changes(Some(root.to_string()), &None); + + add_change(&change_package_a, Some(root.to_string())); + + Ok(()) + } + + fn create_single_dependency_package( monorepo_dir: &PathBuf, - touch_no_dependent: bool, ) -> Result<(), Box> { let js_path = monorepo_dir.join("packages/package-b/index.js"); - let js_path_no_depend = monorepo_dir.join("packages/package-a/index.js"); let branch = Command::new("git") .current_dir(&monorepo_dir) @@ -456,16 +641,9 @@ mod tests { let mut js_file = File::create(&js_path)?; js_file - .write_all(r#"export const message = "hello";"#.as_bytes()) + .write_all(r#"export const message = "hello package-b";"#.as_bytes()) .unwrap(); - if touch_no_dependent { - let mut js_file_no_depend = File::create(&js_path_no_depend)?; - js_file_no_depend - .write_all(r#"export const message = "hello";"#.as_bytes()) - .unwrap(); - } - let add = Command::new("git") .current_dir(&monorepo_dir) .arg("add") @@ -490,8 +668,32 @@ mod tests { Ok(()) } - fn create_package_change(monorepo_dir: &PathBuf) -> Result<(), Box> { - let js_path = monorepo_dir.join("packages/package-a/index.js"); + fn create_multiple_dependency_changes(root: &String) -> Result<(), Box> { + let change_package_a = Change { + package: String::from("@scope/package-a"), + release_as: Bump::Major, + deploy: vec![String::from("production")], + }; + + let change_package_b = Change { + package: String::from("@scope/package-b"), + release_as: Bump::Major, + deploy: vec![String::from("production")], + }; + + init_changes(Some(root.to_string()), &None); + + add_change(&change_package_a, Some(root.to_string())); + add_change(&change_package_b, Some(root.to_string())); + + Ok(()) + } + + fn create_multiple_dependency_packages( + monorepo_dir: &PathBuf, + ) -> Result<(), Box> { + let js_path = monorepo_dir.join("packages/package-b/index.js"); + let js_path_no_depend = monorepo_dir.join("packages/package-a/index.js"); let branch = Command::new("git") .current_dir(&monorepo_dir) @@ -504,9 +706,14 @@ mod tests { branch.wait_with_output()?; + let mut js_file_no_depend = File::create(&js_path_no_depend)?; + js_file_no_depend + .write_all(r#"export const message = "hello package-a";"#.as_bytes()) + .unwrap(); + let mut js_file = File::create(&js_path)?; js_file - .write_all(r#"export const message = "hello";"#.as_bytes()) + .write_all(r#"export const message = "hello package-b";"#.as_bytes()) .unwrap(); let add = Command::new("git") @@ -534,83 +741,89 @@ mod tests { } #[test] - fn test_get_bumps_with_dependency() -> Result<(), Box> { - let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?; - let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf())); + fn test_single_get_bumps() -> Result<(), Box> { + let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm).unwrap(); + let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf())).unwrap(); - create_packages_change(monorepo_dir, true)?; + let ref root = project_root.to_string(); - let ref root = project_root.unwrap().to_string(); + create_single_package(monorepo_dir)?; + create_single_changes(&root)?; - let packages = get_changed_packages(Some(String::from("main")), Some(root.to_string())) - .iter() - .map(|package| package.name.to_string()) - .collect::>(); + let changes = get_change(String::from("feat/message"), Some(root.to_string())); - let bumps = get_bumps(BumpOptions { - packages, + let bumps = get_bumps(&BumpOptions { + changes, since: Some(String::from("main")), - release_as: Bump::Minor, + release_as: Some(Bump::Major), fetch_all: None, fetch_tags: None, - sync_deps: Some(true), + sync_deps: Some(false), push: Some(false), cwd: Some(root.to_string()), }); - assert_eq!(bumps.len(), 2); + assert_eq!(bumps.len(), 1); + + let first_bump = bumps.get(0); + + assert_eq!(first_bump.is_some(), true); + remove_dir_all(&monorepo_dir)?; Ok(()) } #[test] - fn test_get_bumps_without_dependency() -> Result<(), Box> { - let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?; - let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf())); + fn test_multiple_get_bumps() -> Result<(), Box> { + let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm).unwrap(); + let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf())).unwrap(); - create_packages_change(monorepo_dir, false)?; + let ref root = project_root.to_string(); - let ref root = project_root.unwrap().to_string(); + create_multiple_packages(monorepo_dir)?; + create_multiple_changes(&root)?; - let packages = get_changed_packages(Some(String::from("main")), Some(root.to_string())) - .iter() - .map(|package| package.name.to_string()) - .collect::>(); + let changes = get_change(String::from("feat/message"), Some(root.to_string())); - let bumps = get_bumps(BumpOptions { - packages, + let bumps = get_bumps(&BumpOptions { + changes, since: Some(String::from("main")), - release_as: Bump::Minor, + release_as: None, fetch_all: None, fetch_tags: None, - sync_deps: Some(true), + sync_deps: Some(false), push: Some(false), cwd: Some(root.to_string()), }); assert_eq!(bumps.len(), 2); + + let first_bump = bumps.get(0); + let second_bump = bumps.get(1); + + assert_eq!(first_bump.is_some(), true); + assert_eq!(second_bump.is_some(), true); + remove_dir_all(&monorepo_dir)?; Ok(()) } #[test] - fn test_get_single_bump() -> Result<(), Box> { - let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?; - let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf())); + fn test_single_dependency_get_bumps() -> Result<(), Box> { + let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm).unwrap(); + let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf())).unwrap(); - create_package_change(monorepo_dir)?; + let ref root = project_root.to_string(); - let ref root = project_root.unwrap().to_string(); + create_single_dependency_package(monorepo_dir)?; + create_single_dependency_changes(&root)?; - let packages = get_changed_packages(Some(String::from("main")), Some(root.to_string())) - .iter() - .map(|package| package.name.to_string()) - .collect::>(); + let changes = get_change(String::from("feat/message"), Some(root.to_string())); - let bumps = get_bumps(BumpOptions { - packages, + let bumps = get_bumps(&BumpOptions { + changes, since: Some(String::from("main")), - release_as: Bump::Minor, + release_as: None, fetch_all: None, fetch_tags: None, sync_deps: Some(true), @@ -618,69 +831,61 @@ mod tests { cwd: Some(root.to_string()), }); - assert_eq!(bumps.len(), 1); + assert_eq!(bumps.len(), 2); + + let first_bump = bumps.get(0); + let second_bump = bumps.get(1); + + assert_eq!(first_bump.is_some(), true); + assert_eq!(second_bump.is_some(), true); + remove_dir_all(&monorepo_dir)?; Ok(()) } #[test] - fn test_apply_bumps() -> Result<(), Box> { - let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?; - let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf())); - - create_packages_change(monorepo_dir, false)?; - - let ref root = project_root.unwrap().to_string(); - - let packages = get_changed_packages(Some(String::from("main")), Some(root.to_string())) - .iter() - .map(|package| package.name.to_string()) - .collect::>(); + fn test_multiple_dependency_get_bumps() -> Result<(), Box> { + let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm).unwrap(); + let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf())).unwrap(); - let main_branch = Command::new("git") - .current_dir(&monorepo_dir) - .arg("checkout") - .arg("main") - .stdout(Stdio::piped()) - .spawn() - .expect("Git checkout main problem"); + let ref root = project_root.to_string(); - main_branch.wait_with_output()?; - - let merge_branch = Command::new("git") - .current_dir(&monorepo_dir) - .arg("merge") - .arg("feat/message") - .stdout(Stdio::piped()) - .spawn() - .expect("Git merge problem"); + create_multiple_dependency_packages(monorepo_dir)?; + create_multiple_dependency_changes(&root)?; - merge_branch.wait_with_output()?; + let changes = get_change(String::from("feat/message"), Some(root.to_string())); - let bump_options = BumpOptions { - packages, + let bumps = get_bumps(&BumpOptions { + changes, since: Some(String::from("main")), - release_as: Bump::Minor, + release_as: None, fetch_all: None, fetch_tags: None, sync_deps: Some(true), push: Some(false), cwd: Some(root.to_string()), - }; + }); - let bumps = apply_bumps(bump_options); + assert_eq!(bumps.len(), 3); + + let first_bump = bumps.get(0); + let second_bump = bumps.get(1); + let third_bump = bumps.get(2); + + assert_eq!(first_bump.is_some(), true); + assert_eq!(second_bump.is_some(), true); + assert_eq!(third_bump.is_some(), true); - assert_eq!(bumps.len(), 2); remove_dir_all(&monorepo_dir)?; Ok(()) } #[test] - fn test_snapshot_bumps() -> Result<(), Box> { + fn test_apply_bumps() -> Result<(), Box> { let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?; let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf())); - create_packages_change(monorepo_dir, false)?; + create_multiple_dependency_packages(monorepo_dir)?; let ref root = project_root.unwrap().to_string(); @@ -689,6 +894,20 @@ mod tests { .map(|package| package.name.to_string()) .collect::>(); + init_changes(Some(root.to_string()), &None); + + for package in packages { + let change_package = Change { + package: package.to_string(), + release_as: Bump::Major, + deploy: vec![String::from("production")], + }; + + add_change(&change_package, Some(root.to_string())); + } + + let changes = get_change(String::from("feat/message"), Some(root.to_string())); + let main_branch = Command::new("git") .current_dir(&monorepo_dir) .arg("checkout") @@ -710,9 +929,9 @@ mod tests { merge_branch.wait_with_output()?; let bump_options = BumpOptions { - packages, + changes, since: Some(String::from("main")), - release_as: Bump::Snapshot, + release_as: Some(Bump::Minor), fetch_all: None, fetch_tags: None, sync_deps: Some(true), @@ -720,10 +939,9 @@ mod tests { cwd: Some(root.to_string()), }; - let bumps = apply_bumps(bump_options); + let bumps = apply_bumps(&bump_options); - assert_eq!(bumps.len(), 2); - assert_ne!(&bumps.get(0).unwrap().to, &bumps.get(1).unwrap().to); + assert_eq!(bumps.len(), 3); remove_dir_all(&monorepo_dir)?; Ok(()) } diff --git a/src/changes.rs b/src/changes.rs index 57bd0b87..48ed2dfb 100644 --- a/src/changes.rs +++ b/src/changes.rs @@ -310,6 +310,46 @@ pub fn get_change(branch: String, cwd: Option) -> Vec { vec![] } +/// Get a change for a specific package from the changes file in the root of the project. +pub fn get_package_change( + package_name: String, + branch: String, + cwd: Option, +) -> Option { + let ref root = match cwd { + Some(ref dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(), + None => get_project_root_path(None).unwrap(), + }; + + let root_path = Path::new(root); + let ref changes_path = root_path.join(String::from(".changes.json")); + + if changes_path.exists() { + let changes_file = File::open(changes_path).unwrap(); + let changes_reader = BufReader::new(changes_file); + + let changes: ChangesFileData = serde_json::from_reader(changes_reader).unwrap(); + + if changes.changes.contains_key(&branch) { + let branch_changes = changes.changes.get(&branch).unwrap(); + + let package_change = branch_changes + .iter() + .find(|change| change.package == package_name); + + if let Some(change) = package_change { + return Some(change.clone()); + } + + return None; + } + + return None; + } + + None +} + /// Check if a change exists in the changes file in the root of the project. pub fn change_exist(branch: String, packages_name: Vec, cwd: Option) -> bool { let ref root = match cwd { diff --git a/src/dependency.rs b/src/dependency.rs new file mode 100644 index 00000000..c671a55d --- /dev/null +++ b/src/dependency.rs @@ -0,0 +1,364 @@ +use petgraph::{stable_graph::StableDiGraph, Direction}; + +/// Must be implemented by the type you wish +/// to build a dependency graph for. See the README.md for an example +pub trait Node { + /// Encodes a dependency relationship. In a Package Manager dependency graph for intance, this might be a (package name, version) tuple. + /// It might also just be the exact same type as the one that implements the Node trait, in which case `Node::matches` can be implemented through simple equality. + type DependencyType; + + /// Returns a slice of dependencies for this Node + fn dependencies(&self) -> &[Self::DependencyType]; + + /// Returns true if the `dependency` can be met by us. + fn matches(&self, dependency: &Self::DependencyType) -> bool; +} + +/// Wrapper around dependency graph nodes. +/// Since a graph might have dependencies that cannot be resolved internally, +/// this wrapper is necessary to differentiate between internally resolved and +/// externally (unresolved) dependencies. +/// An Unresolved dependency does not necessarily mean that it *cannot* be resolved, +/// only that no Node within the graph fulfills it. +pub enum Step<'a, N: Node> { + Resolved(&'a N), + Unresolved(&'a N::DependencyType), +} + +impl<'a, N: Node> Step<'a, N> { + pub fn is_resolved(&self) -> bool { + match self { + Step::Resolved(_) => true, + Step::Unresolved(_) => false, + } + } + + pub fn as_resolved(&self) -> Option<&N> { + match self { + Step::Resolved(node) => Some(node), + Step::Unresolved(_) => None, + } + } + + pub fn as_unresolved(&self) -> Option<&N::DependencyType> { + match self { + Step::Resolved(_) => None, + Step::Unresolved(dependency) => Some(dependency), + } + } +} + +/// The [`DependencyGraph`] structure builds an internal [Directed Graph](`petgraph::stable_graph::StableDiGraph`), which can then be traversed +/// in an order which ensures that dependent Nodes are visited before their parents. +pub struct DependencyGraph<'a, N: Node> { + graph: StableDiGraph, &'a N::DependencyType>, +} + +/// The only way to build a [`DependencyGraph`] is from a slice of objects implementing [`Node`]. +/// The graph references the original items, meaning the objects cannot be modified while +/// the [`DependencyGraph`] holds a reference to them. +impl<'a, N> From<&'a [N]> for DependencyGraph<'a, N> +where + N: Node, +{ + fn from(nodes: &'a [N]) -> Self { + let mut graph = StableDiGraph::, &'a N::DependencyType>::new(); + + // Insert the input nodes into the graph, and record their positions. + // We'll be adding the edges next, and filling in any unresolved + // steps we find along the way. + let nodes: Vec<(_, _)> = nodes + .iter() + .map(|node| (node, graph.add_node(Step::Resolved(node)))) + .collect(); + + for (node, index) in nodes.iter() { + for dependency in node.dependencies() { + // Check to see if we can resolve this dependency internally. + if let Some((_, dependent)) = nodes.iter().find(|(dep, _)| dep.matches(dependency)) + { + // If we can, just add an edge between the two nodes. + graph.add_edge(*index, *dependent, dependency); + } else { + // If not, create a new "Unresolved" node, and create an edge to that. + let unresolved = graph.add_node(Step::Unresolved(dependency)); + graph.add_edge(*index, unresolved, dependency); + } + } + } + + Self { graph } + } +} + +impl<'a, N> DependencyGraph<'a, N> +where + N: Node, +{ + /// True if all graph [`Node`]s have only references to other internal [`Node`]s. + /// That is, there are no unresolved dependencies between nodes. + pub fn is_internally_resolvable(&self) -> bool { + self.graph.node_weights().all(Step::is_resolved) + } + + /// Get an iterator over unresolved dependencies, without traversing the whole graph. + /// Useful for doing pre-validation or pre-fetching of external dependencies before + /// starting to resolve internal dependencies. + pub fn unresolved_dependencies(&self) -> impl Iterator { + self.graph.node_weights().filter_map(Step::as_unresolved) + } +} + +/// Iterate over the DependencyGraph in an order which ensures dependencies are resolved before each Node is visited. +/// Note: If a `Step::Unresolved` node is returned, it is the caller's responsibility to ensure the dependency is resolved +/// before continuing. +impl<'a, N> Iterator for DependencyGraph<'a, N> +where + N: Node, +{ + type Item = Step<'a, N>; + + fn next(&mut self) -> Option { + // Returns the first node, which does not have any Outgoing + // edges, which means it is terminal. + for index in self.graph.node_indices().rev() { + if self + .graph + .neighbors_directed(index, Direction::Outgoing) + .count() + == 0 + { + return self.graph.remove_node(index); + } + } + + None + } +} + +#[cfg(test)] +mod tests { + + use super::{DependencyGraph, Node, Step}; + use semver::{BuildMetadata, Prerelease, Version, VersionReq}; + + #[derive(Debug)] + struct Package { + name: &'static str, + version: Version, + dependencies: Vec, + } + + #[derive(Debug)] + struct Dependency { + name: &'static str, + version: VersionReq, + } + + impl Node for Package { + type DependencyType = Dependency; + + fn dependencies(&self) -> &[Self::DependencyType] { + &self.dependencies[..] + } + + fn matches(&self, dependency: &Self::DependencyType) -> bool { + self.name == dependency.name && dependency.version.matches(&self.version) + } + } + + #[test] + fn test_dependencies_synchronous() { + let build = build_test_graph(); + let graph = DependencyGraph::from(&build[..]); + + assert!(!graph.is_internally_resolvable()); + + for node in graph { + match node { + Step::Resolved(build) => println!("build: {:?}", build.name), + Step::Unresolved(lookup) => println!("lookup: {:?}", lookup.name), + } + } + } + + #[test] + fn test_unresolved_dependencies() { + let build = build_test_graph(); + let graph = DependencyGraph::from(&build[..]); + + assert!(!graph.is_internally_resolvable()); + + let unresolved_dependencies: Vec<_> = graph + .unresolved_dependencies() + .map(|dep| dep.name) + .collect(); + + assert_eq!( + unresolved_dependencies, + vec!["@scope/unknown", "@scope/remote"] + ); + } + + #[test] + fn test_generate_dependency_graph() { + let _ = DependencyGraph::from(&build_test_graph()[..]); + } + + fn build_test_graph() -> Vec { + vec![ + Package { + name: "@scope/package-a", + version: semver::Version { + major: 1, + minor: 2, + patch: 3, + pre: Prerelease::new("").unwrap(), + build: BuildMetadata::EMPTY, + }, + dependencies: vec![], + }, + Package { + name: "@scope/package-b", + version: semver::Version { + major: 1, + minor: 2, + patch: 3, + pre: Prerelease::new("").unwrap(), + build: BuildMetadata::EMPTY, + }, + dependencies: vec![Dependency { + name: "@scope/package-a", + version: ">=1.0.0".parse().unwrap(), + }], + }, + Package { + name: "@scope/package-c", + version: semver::Version { + major: 1, + minor: 2, + patch: 3, + pre: Prerelease::new("").unwrap(), + build: BuildMetadata::EMPTY, + }, + dependencies: vec![Dependency { + name: "@scope/package-b", + version: ">=1.0.0".parse().unwrap(), + }], + }, + Package { + name: "@scope/package-d", + version: semver::Version { + major: 1, + minor: 2, + patch: 3, + pre: Prerelease::new("").unwrap(), + build: BuildMetadata::EMPTY, + }, + dependencies: vec![ + Dependency { + name: "@scope/package-a", + version: ">=1.0.0".parse().unwrap(), + }, + Dependency { + name: "@scope/package-b", + version: ">=1.0.0".parse().unwrap(), + }, + ], + }, + Package { + name: "@scope/package-e", + version: semver::Version { + major: 1, + minor: 2, + patch: 3, + pre: Prerelease::new("").unwrap(), + build: BuildMetadata::EMPTY, + }, + dependencies: vec![], + }, + Package { + name: "@scope/package-f", + version: semver::Version { + major: 1, + minor: 2, + patch: 3, + pre: Prerelease::new("").unwrap(), + build: BuildMetadata::EMPTY, + }, + dependencies: vec![ + Dependency { + name: "@scope/unknown", + version: ">=1.0.0".parse().unwrap(), + }, + Dependency { + name: "@scope/remote", + version: "=3.0.0".parse().unwrap(), + }, + ], + }, + ] + } + + #[test] + fn test_internally_resolved() { + let packages = vec![ + Package { + name: "@scope/package-a", + version: semver::Version { + major: 1, + minor: 2, + patch: 3, + pre: Prerelease::new("").unwrap(), + build: BuildMetadata::EMPTY, + }, + dependencies: vec![], + }, + Package { + name: "@scope/package-b", + version: semver::Version { + major: 3, + minor: 2, + patch: 0, + pre: Prerelease::new("").unwrap(), + build: BuildMetadata::EMPTY, + }, + dependencies: vec![Dependency { + name: "@scope/package-a", + version: "=1.2.3".parse().unwrap(), + }], + }, + Package { + name: "@scope/package-c", + version: semver::Version { + major: 11, + minor: 2, + patch: 4, + pre: Prerelease::new("").unwrap(), + build: BuildMetadata::EMPTY, + }, + dependencies: vec![Dependency { + name: "@scope/package-b", + version: ">=3.0.0".parse().unwrap(), + }], + }, + ]; + + let graph = DependencyGraph::from(&packages[..]); + + for package in graph { + match package { + // Print out the package name so we can verify the order ourselves + Step::Resolved(package) => println!("Building {}!", package.name), + + // Since we know that all our Packages only have internal references to each other, + // we can safely ignore any Unresolved steps in the graph. + // + // If for example `second_order` required some unknown package `external_package`, + // iterating over our graph would yield that as a Step::Unresolved *before* + // the `second_order` package. + Step::Unresolved(_) => unreachable!(), + } + } + } +} diff --git a/src/git.rs b/src/git.rs index c48b3f37..6dece4f5 100644 --- a/src/git.rs +++ b/src/git.rs @@ -918,7 +918,7 @@ mod tests { let result = get_remote_or_local_tags(project_root, Some(true)); let count = result.len(); - assert_eq!(count, 2); + assert_eq!(count, 3); remove_dir_all(&monorepo_dir)?; Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 43dd706a..14739750 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,3 +23,5 @@ pub mod conventional; pub mod bumps; pub mod changes; + +pub mod dependency; diff --git a/src/packages.rs b/src/packages.rs index 777afa54..94fb2bbf 100644 --- a/src/packages.rs +++ b/src/packages.rs @@ -13,6 +13,7 @@ use std::path::PathBuf; use std::process::{Command, Stdio}; use wax::{CandidatePath, Glob, Pattern}; +use super::dependency::Node; use super::git::get_all_files_changed_since_branch; use super::manager::{detect_package_manager, PackageManager}; use super::paths::get_project_root_path; @@ -46,6 +47,7 @@ pub struct PackageInfo { pub url: String, pub repository_info: Option, pub changed_files: Vec, + pub dependencies: Vec, } #[cfg(not(feature = "napi"))] @@ -63,6 +65,7 @@ pub struct PackageInfo { pub url: String, pub repository_info: Option, pub changed_files: Vec, + pub dependencies: Vec, } #[cfg(feature = "napi")] @@ -83,6 +86,38 @@ pub struct PackageRepositoryInfo { pub project: String, } +#[cfg(feature = "napi")] +#[napi(object)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)] +pub struct DependencyInfo { + pub name: String, + pub version: String, +} + +#[cfg(not(feature = "napi"))] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)] +pub struct DependencyInfo { + pub name: String, + pub version: String, +} + +impl Node for PackageInfo { + type DependencyType = DependencyInfo; + + fn dependencies(&self) -> &[Self::DependencyType] { + &self.dependencies[..] + } + + fn matches(&self, dependency: &Self::DependencyType) -> bool { + let dependency_version = semver::VersionReq::parse(&dependency.version).unwrap(); + let self_version = semver::Version::parse(&self.version).unwrap(); + + // Check that name is an exact match, and that the dependency + // requirements are fulfilled by our own version + self.name == dependency.name && dependency_version.matches(&self_version) + } +} + impl PackageInfo { /// Pushes a changed file to the list of changed files. pub fn push_changed_file(&mut self, file: String) { @@ -105,11 +140,51 @@ impl PackageInfo { self.changed_files.extend(founded_files); } + pub fn push_dependency(&mut self, dependency: DependencyInfo) { + self.dependencies.push(dependency); + } + /// Updates the version of the package. pub fn update_version(&mut self, version: String) { self.version = version.to_string(); self.pkg_json["version"] = Value::String(version.to_string()); } + + /// Updates a dependency version in the package.json file. + pub fn update_dependency_version(&mut self, dependency: String, version: String) { + let package_json = self.pkg_json.as_object().unwrap(); + + if package_json.contains_key("dependencies") { + let dependencies = self.pkg_json["dependencies"].as_object_mut().unwrap(); + let has_dependency = dependencies.contains_key(&dependency); + + if has_dependency { + dependencies.insert(dependency, Value::String(version)); + } + } + } + + /// Updates a dev dependency version in the package.json file. + pub fn update_dev_dependency_version(&mut self, dependency: String, version: String) { + let package_json = self.pkg_json.as_object().unwrap(); + + if package_json.contains_key("devDependencies") { + let dev_dependencies = self.pkg_json["devDependencies"].as_object_mut().unwrap(); + let has_dependency = dev_dependencies.contains_key(&dependency); + + if has_dependency { + dev_dependencies.insert(dependency, Value::String(version)); + } + } + } + + /// Write package.json file with the updated version. + pub fn write_package_json(&self) { + let package_json_file = std::fs::File::create(&self.package_json_path).unwrap(); + let package_json_writer = std::io::BufWriter::new(package_json_file); + + serde_json::to_writer_pretty(package_json_writer, &self.pkg_json).unwrap(); + } } /// Returns package info domain, scope and repository name. @@ -131,6 +206,20 @@ fn get_package_repository_info(url: &String) -> PackageRepositoryInfo { } } +/// Returns the package info of the package with the provided name. +pub fn get_package_info(package_name: String, cwd: Option) -> Option { + let project_root = match cwd { + Some(ref dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(), + None => get_project_root_path(None).unwrap(), + }; + + let packages = get_packages(Some(project_root)); + + packages + .into_iter() + .find(|package| package.name == package_name) +} + /// Get defined package manager in the monorepo pub fn get_monorepo_package_manager(cwd: Option) -> Option { let project_root = match cwd { @@ -151,7 +240,7 @@ pub fn get_packages(cwd: Option) -> Vec { }; let package_manager = get_monorepo_package_manager(Some(project_root.to_string())); - return match package_manager { + let mut packages = match package_manager { Some(PackageManager::Pnpm) => { let path = Path::new(&project_root); let pnpm_workspace = path.join("pnpm-workspace.yaml"); @@ -244,6 +333,7 @@ pub fn get_packages(cwd: Option) -> Vec { url: String::from(repo_url), repository_info: Some(repository_info), changed_files: vec![], + dependencies: vec![], } }) .filter(|pkg| !pkg.root) @@ -374,6 +464,7 @@ pub fn get_packages(cwd: Option) -> Vec { url: repo_url.to_string(), repository_info: Some(repository_info), changed_files: vec![], + dependencies: vec![], }; packages.push(pkg_info); @@ -385,6 +476,43 @@ pub fn get_packages(cwd: Option) -> Vec { Some(PackageManager::Bun) => vec![], None => vec![], }; + + for pkg in packages.iter_mut() { + let pkg_json: serde_json::Value = serde_json::from_value(pkg.pkg_json.clone()).unwrap(); + let package_json = pkg_json.as_object().unwrap(); + + if package_json.contains_key("dependencies") { + let deps = package_json.get("dependencies").unwrap(); + + if deps.is_object() { + let deps = deps.as_object().unwrap(); + + for (name, version) in deps { + pkg.push_dependency(DependencyInfo { + name: name.to_string(), + version: version.as_str().unwrap().to_string(), + }); + } + } + } + + if package_json.contains_key("devDependencies") { + let deps = package_json.get("devDependencies").unwrap(); + + if deps.is_object() { + let deps = deps.as_object().unwrap(); + + for (name, version) in deps { + pkg.push_dependency(DependencyInfo { + name: name.to_string(), + version: version.as_str().unwrap().to_string(), + }); + } + } + } + } + + packages } /// Get a list of packages that have changed since a given sha @@ -494,7 +622,7 @@ mod tests { let packages = get_packages(project_root); - assert_eq!(packages.len(), 2); + assert_eq!(packages.len(), 4); remove_dir_all(&monorepo_dir)?; Ok(()) } @@ -506,7 +634,7 @@ mod tests { let packages = get_packages(project_root); - assert_eq!(packages.len(), 2); + assert_eq!(packages.len(), 4); remove_dir_all(&monorepo_dir)?; Ok(()) } @@ -518,7 +646,7 @@ mod tests { let packages = get_packages(project_root); - assert_eq!(packages.len(), 2); + assert_eq!(packages.len(), 4); remove_dir_all(&monorepo_dir)?; Ok(()) } diff --git a/src/utils.rs b/src/utils.rs index 741c4996..8304e51d 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -82,11 +82,15 @@ pub(crate) fn create_test_monorepo( let monorepo_packages_dir = monorepo_temp_dir.join("packages"); let monorepo_package_a_dir = monorepo_packages_dir.join("package-a"); let monorepo_package_b_dir = monorepo_packages_dir.join("package-b"); + let monorepo_package_c_dir = monorepo_packages_dir.join("package-c"); + let monorepo_package_d_dir = monorepo_packages_dir.join("package-d"); create_dir(&monorepo_temp_dir)?; create_dir(&monorepo_packages_dir)?; create_dir(&monorepo_package_a_dir)?; create_dir(&monorepo_package_b_dir)?; + create_dir(&monorepo_package_c_dir)?; + create_dir(&monorepo_package_d_dir)?; #[cfg(not(windows))] std::fs::set_permissions(&monorepo_temp_dir, std::fs::Permissions::from_mode(0o777))?; @@ -97,7 +101,9 @@ pub(crate) fn create_test_monorepo( "version": "0.0.0", "workspaces": [ "packages/package-a", - "packages/package-b" + "packages/package-b", + "packages/package-c", + "packages/package-d" ] }"#; let package_root_json = serde_json::from_str::(monorepo_root_json).unwrap(); @@ -197,6 +203,93 @@ pub(crate) fn create_test_monorepo( let monorepo_package_b_json_writer = BufWriter::new(monorepo_package_b_json_file); serde_json::to_writer_pretty(monorepo_package_b_json_writer, &package_b_json).unwrap(); + let package_c_json = r#" + { + "name": "@scope/package-c", + "version": "1.0.0", + "description": "My new package C", + "main": "index.mjs", + "module": "./dist/index.mjs", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + } + }, + "typesVersions": { + "*": { + "index.d.ts": [ + "./dist/index.d.ts" + ] + } + }, + "repository": { + "url": "git+ssh://git@github.com/websublime/workspace-node-binding-tools.git", + "type": "git" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "dev": "node index.mjs" + }, + "keywords": [], + "author": "Author", + "license": "ISC" + }"#; + let package_c_json = serde_json::from_str::(package_c_json).unwrap(); + let monorepo_package_c_json_file = OpenOptions::new() + .write(true) + .append(false) + .create(true) + .open(&monorepo_package_c_dir.join("package.json").as_path()) + .unwrap(); + let monorepo_package_c_json_writer = BufWriter::new(monorepo_package_c_json_file); + serde_json::to_writer_pretty(monorepo_package_c_json_writer, &package_c_json).unwrap(); + + let package_d_json = r#" + { + "name": "@scope/package-d", + "version": "1.0.0", + "description": "My new package D", + "main": "index.mjs", + "module": "./dist/index.mjs", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + } + }, + "typesVersions": { + "*": { + "index.d.ts": [ + "./dist/index.d.ts" + ] + } + }, + "repository": { + "url": "git+ssh://git@github.com/websublime/workspace-node-binding-tools.git", + "type": "git" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "dev": "node index.mjs" + }, + "dependencies": { + "@scope/package-a": "1.0.0" + }, + "keywords": [], + "author": "Author", + "license": "ISC" + }"#; + let package_d_json = serde_json::from_str::(package_d_json).unwrap(); + let monorepo_package_d_json_file = OpenOptions::new() + .write(true) + .append(false) + .create(true) + .open(&monorepo_package_d_dir.join("package.json").as_path()) + .unwrap(); + let monorepo_package_d_json_writer = BufWriter::new(monorepo_package_d_json_file); + serde_json::to_writer_pretty(monorepo_package_d_json_writer, &package_d_json).unwrap(); + match package_manager { PackageManager::Yarn => { let yarn_lock = monorepo_temp_dir.join("yarn.lock"); @@ -308,6 +401,19 @@ pub(crate) fn create_test_monorepo( tag_b.wait_with_output()?; + let tag_c = Command::new("git") + .current_dir(&monorepo_temp_dir) + .arg("tag") + .arg("-a") + .arg("@scope/package-c@1.0.0") + .arg("-m") + .arg("chore: release package-c@1.0.0") + .stdout(Stdio::piped()) + .spawn() + .expect("Git tag problem"); + + tag_c.wait_with_output()?; + let canonic_path = &std::fs::canonicalize(Path::new(&monorepo_temp_dir)).unwrap(); let root = canonic_path.as_path().display().to_string();