diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46028d93..46fdf546 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,27 @@ jobs: test: name: Test Suite runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20] steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Create pnpm-lock.yaml + run: | + echo "node-version: 20" > pnpm-lock.yaml + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + #cache: 'pnpm' + - name: Delete pnpm-lock.yaml + run: | + rm -f pnpm-lock.yaml - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 diff --git a/Cargo.lock b/Cargo.lock index f5023f6c..65c30b30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "addr2line" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + [[package]] name = "adler32" version = "1.2.0" @@ -44,6 +59,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +[[package]] +name = "backtrace" +version = "0.3.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + [[package]] name = "bitflags" version = "2.6.0" @@ -469,6 +499,12 @@ dependencies = [ "wasi", ] +[[package]] +name = "gimli" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" + [[package]] name = "git-cliff-core" version = "2.4.0" @@ -566,6 +602,12 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + [[package]] name = "humansize" version = "2.1.3" @@ -1240,6 +1282,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +dependencies = [ + "adler", +] + [[package]] name = "napi" version = "2.16.8" @@ -1251,6 +1302,9 @@ dependencies = [ "napi-derive", "napi-sys", "once_cell", + "serde", + "serde_json", + "tokio", ] [[package]] @@ -1366,6 +1420,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -1375,6 +1439,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "576dfe1fc8f9df304abb159d767a29d0476f7750fbf8aa7ad07816004a207434" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -1509,6 +1582,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + [[package]] name = "pkg-config" version = "0.3.30" @@ -1680,6 +1759,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + [[package]] name = "rustc_version" version = "0.4.0" @@ -1966,6 +2051,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" +dependencies = [ + "backtrace", + "num_cpus", + "pin-project-lite", +] + [[package]] name = "toml" version = "0.8.14" diff --git a/Cargo.toml b/Cargo.toml index a333fd39..bebf2a77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ serde_json = "1.0.111" regex = "1.10.3" wax = { version = "0.6.0", features = ["walk"] } napi-derive = "2.16.8" -napi = { version = "2.16.7", default-features = false, features = ["napi4"] } +napi = { version = "2.16.7", default-features = false, features = ["napi9", "serde-json", "tokio_rt"] } package_json_schema = "0.2.1" icu = "1.5.0" version-compare = "0.2" diff --git a/packages/package-a/package.json b/packages/package-a/package.json new file mode 100644 index 00000000..1c0595a8 --- /dev/null +++ b/packages/package-a/package.json @@ -0,0 +1,16 @@ +{ + "name": "@scope/package-a", + "version": "1.0.0", + "description": "", + "main": "index.mjs", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "dev": "node index.mjs" + }, + "dependencies": { + "@scope/package-b": "workspace:*" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/packages/package-b/package.json b/packages/package-b/package.json new file mode 100644 index 00000000..ed1385da --- /dev/null +++ b/packages/package-b/package.json @@ -0,0 +1,13 @@ +{ + "name": "@scope/package-b", + "version": "1.0.0", + "description": "", + "main": "index.mjs", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "dev": "node index.mjs" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/src/lib.rs b/src/lib.rs index 98e85c6b..83fff267 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,3 +4,4 @@ extern crate napi_derive; pub mod agent; pub mod filesystem; +pub mod monorepo; diff --git a/src/monorepo/mod.rs b/src/monorepo/mod.rs new file mode 100644 index 00000000..0fab8a16 --- /dev/null +++ b/src/monorepo/mod.rs @@ -0,0 +1,2 @@ +pub mod packages; +pub mod utils; diff --git a/src/monorepo/packages.rs b/src/monorepo/packages.rs new file mode 100644 index 00000000..72a85a76 --- /dev/null +++ b/src/monorepo/packages.rs @@ -0,0 +1,283 @@ +#![warn(dead_code)] +#![allow(clippy::needless_borrow)] +#![allow(clippy::unused_io_amount)] + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::agent::manager::Agent; +use crate::filesystem::paths::get_project_root_path; +use execute::Execute; +use package_json_schema::PackageJson; +use std::path::Path; +use std::process::{Command, Stdio}; +use wax::{CandidatePath, Glob, Pattern}; + +#[derive(Debug, Deserialize, Serialize)] +struct PnpmInfo { + name: String, + path: String, + private: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +struct PkgJson { + workspaces: Vec, +} + +#[napi(object)] +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PackageInfo { + pub name: String, + pub private: bool, + pub package_json_path: String, + pub package_path: String, + pub pkg_json: Value, + pub root: bool, + pub version: String, +} + +pub struct Monorepo; + +impl Monorepo { + pub fn get_project_root_path() -> Option { + get_project_root_path() + } + + pub fn get_agent() -> Option { + let path = Monorepo::get_project_root_path().unwrap(); + let path = Path::new(&path); + + Agent::detect(&path) + } + + pub fn get_packages() -> Vec { + return match Monorepo::get_agent() { + Some(Agent::Pnpm) => { + let path = Monorepo::get_project_root_path().unwrap(); + let mut command = Command::new("pnpm"); + command + .arg("list") + .arg("-r") + .arg("--depth") + .arg("-1") + .arg("--json"); + + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + let output = command.execute_output().unwrap(); + let output = String::from_utf8(output.stdout).unwrap(); + + let pnpm_info = serde_json::from_str::>(&output).unwrap(); + + pnpm_info + .iter() + .map(|info| { + let package_json_path = format!("{}/package.json", info.path); + let package_json = std::fs::read_to_string(&package_json_path).unwrap(); + let pkg_json = PackageJson::try_from(package_json).unwrap(); + let version = pkg_json.version.clone().unwrap_or(String::from("0.0.0")); + let is_root = info.path == path; + + PackageInfo { + name: info.name.clone(), + private: info.private, + package_json_path, + package_path: info.path.clone(), + pkg_json: Value::String(pkg_json.to_string()), + root: is_root, + version, + } + }) + .collect::>() + } + Some(Agent::Yarn) | Some(Agent::Npm) => { + let path = Monorepo::get_project_root_path().unwrap(); + let path = Path::new(&path); + let package_json = path.join("package.json"); + let mut packages = vec![]; + + let package_json = std::fs::read_to_string(package_json).unwrap(); + + let PkgJson { mut workspaces, .. } = + serde_json::from_str::(&package_json).unwrap(); + + let globs = workspaces + .iter_mut() + .map(|workspace| { + return match workspace.ends_with("/*") { + true => { + workspace.push_str("*/package.json"); + Glob::new(workspace).unwrap() + } + false => { + workspace.push_str("/package.json"); + Glob::new(workspace).unwrap() + } + }; + }) + .collect::>(); + + let patterns = wax::any(globs).unwrap(); + + let glob = Glob::new("**/package.json").unwrap(); + + for entry in glob + .walk(path) + .not([ + "**/node_modules/**", + "**/src/**", + "**/dist/**", + "**/tests/**", + ]) + .unwrap() + { + let entry = entry.unwrap(); + + if patterns.is_match(CandidatePath::from( + entry.path().strip_prefix(path).unwrap(), + )) { + let package_json = std::fs::read_to_string(entry.path()).unwrap(); + let pkg_json = PackageJson::try_from(package_json).unwrap(); + let private = + matches!(pkg_json.private, Some(package_json_schema::Private::True)); + let name = pkg_json.name.clone().unwrap(); + let version = pkg_json.version.clone().unwrap_or(String::from("0.0.0")); + let content = pkg_json.to_string(); + + let pkg_info = PackageInfo { + name, + private, + package_json_path: entry.path().to_str().unwrap().to_string(), + package_path: entry + .path() + .parent() + .unwrap() + .to_str() + .unwrap() + .to_string(), + pkg_json: Value::String(content), + root: false, + version, + }; + + packages.push(pkg_info); + } + } + + packages + } + Some(Agent::Bun) => vec![], + None => vec![], + }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs::{remove_file, File}, + io::Write, + }; + + fn create_agent_file(path: &Path) -> File { + File::create(path).expect("File not created") + } + + fn delete_file(path: &Path) { + remove_file(path).expect("File not deleted"); + } + + fn create_root_package_json(path: &Path) { + let mut file = File::create(path).expect("File not created"); + file.write( + r#" + { + "name": "@scope/root", + "version": "0.0.0", + "workspaces": [ + "packages/package-a", + "packages/package-b" + ] + }"# + .as_bytes(), + ) + .expect("File not written"); + } + + fn create_pnpm_workspace(path: &Path) { + let mut file = File::create(path).expect("File not created"); + file.write( + r#" + packages: + - "packages/*" + "# + .as_bytes(), + ) + .expect("File not written"); + } + + #[test] + fn monorepo_root_path() { + let path = std::env::current_dir().expect("Current user home directory"); + let npm_lock = path.join("package-lock.json"); + + create_agent_file(&npm_lock); + + let root_path = Monorepo::get_project_root_path(); + + assert_eq!(root_path, Some(path.to_str().unwrap().to_string())); + + delete_file(&npm_lock); + } + + #[test] + fn monorepo_agent() { + let path = std::env::current_dir().expect("Current user home directory"); + let npm_lock = path.join("package-lock.json"); + + create_agent_file(&npm_lock); + + let agent = Monorepo::get_agent(); + + assert_eq!(agent, Some(Agent::Npm)); + + delete_file(&npm_lock); + } + + #[test] + fn monorepo_npm() { + let path = std::env::current_dir().expect("Current user home directory"); + let npm_lock = path.join("package-lock.json"); + let package_json = path.join("package.json"); + + create_agent_file(&npm_lock); + create_root_package_json(&package_json); + + let packages = Monorepo::get_packages(); + + assert_eq!(packages.len(), 2); + + delete_file(&npm_lock); + delete_file(&package_json); + } + + #[test] + fn monorepo_pnpm() { + let path = std::env::current_dir().expect("Current user home directory"); + let pnpm_lock = path.join("pnpm-lock.yaml"); + let pnpm_workspace = path.join("pnpm-workspace.yaml"); + + create_agent_file(&pnpm_lock); + create_pnpm_workspace(&pnpm_workspace); + + let packages = Monorepo::get_packages(); + + assert_eq!(packages.len(), 2); + + delete_file(&pnpm_lock); + delete_file(&pnpm_workspace); + } +} diff --git a/src/monorepo/utils.rs b/src/monorepo/utils.rs new file mode 100644 index 00000000..7794329f --- /dev/null +++ b/src/monorepo/utils.rs @@ -0,0 +1,30 @@ +#![allow(clippy::option_map_or_none)] + +use regex::Regex; + +#[derive(Debug)] +pub struct PackageScopeMetadata { + pub full: String, + pub name: String, + pub version: String, + pub path: Option, +} + +pub fn package_scope_name_version(pkg_name: &str) -> Option { + let regex = Regex::new("^((?:@[^/@]+/)?[^/@]+)(?:@([^/]+))?(/.*)?$").unwrap(); + + let matches = regex.captures(pkg_name).unwrap(); + + if matches.len() > 0 { + return Some(PackageScopeMetadata { + full: matches.get(0).map_or("", |m| m.as_str()).to_string(), + name: matches.get(1).map_or("", |m| m.as_str()).to_string(), + version: matches.get(2).map_or("", |m| m.as_str()).to_string(), + path: matches + .get(3) + .map_or(None, |m| Some(m.as_str().to_string())), + }); + } + + None +}