diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac0b5f14..ee46ee7da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Fixed -- Cargo manifest generation for workspaces +- Workspace lockfile generation for cargo, npm, yarn, and pnpm ## [5.7.1] - 2023-09-08 diff --git a/Cargo.lock b/Cargo.lock index 39f02e080..f8ee45d84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2256,6 +2256,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "globset" version = "0.4.13" @@ -3002,8 +3008,11 @@ name = "lockfile_generator" version = "0.1.0" dependencies = [ "anyhow", + "glob", "serde", "serde_json", + "tempfile", + "thiserror", ] [[package]] @@ -5510,18 +5519,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.48" +version = "1.0.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d6d7a740b8a666a7e828dd00da9c0dc290dff53154ea77ac109281de90589b7" +checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.48" +version = "1.0.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49922ecae66cc8a249b77e68d1d0623c1b2c514f0060c27cdc68bd62a1219d35" +checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc" dependencies = [ "proc-macro2 1.0.67", "quote 1.0.33", diff --git a/lockfile_generator/Cargo.toml b/lockfile_generator/Cargo.toml index 5ade1a098..85c0b7475 100644 --- a/lockfile_generator/Cargo.toml +++ b/lockfile_generator/Cargo.toml @@ -10,3 +10,8 @@ rust-version = "1.68.0" serde = { version = "1.0.163", features = ["derive"] } serde_json = "1.0.96" anyhow = "1.0.75" +glob = "0.3.1" +thiserror = "1.0.49" + +[dev-dependencies] +tempfile = "3.3.0" diff --git a/lockfile_generator/src/lib.rs b/lockfile_generator/src/lib.rs index 98dfad681..693254801 100644 --- a/lockfile_generator/src/lib.rs +++ b/lockfile_generator/src/lib.rs @@ -1,7 +1,5 @@ -use std::error::Error as StdError; use std::ffi::OsString; -use std::fmt::{self, Display, Formatter}; -use std::path::{Path, PathBuf}; +use std::path::{Path, PathBuf, StripPrefixError}; use std::process::{Command, Stdio}; use std::string::FromUtf8Error; use std::{fs, io}; @@ -135,81 +133,30 @@ impl FileRelocator { pub type Result = std::result::Result; /// Lockfile generation error. -#[derive(Debug)] +#[derive(thiserror::Error, Debug)] pub enum Error { - InvalidUtf8(FromUtf8Error), - Json(JsonError), - Io(io::Error), - ProcessCreation(String, String, io::Error), - NonZeroExit(Option, String), + #[error("{0}")] + Anyhow(#[from] anyhow::Error), + #[error("invalid manifest path: {0:?}")] InvalidManifest(PathBuf), + #[error("utf8 parsing error")] + InvalidUtf8(#[from] FromUtf8Error), + #[error("I/O error")] + Io(#[from] io::Error), + #[error("json parsing error")] + Json(#[from] JsonError), + #[error("package manager quit unexpectedly (code: {0:?}):\n\n{1}")] + NonZeroExit(Option, String), + #[error("unsupported pip report version {1:?}, expected {0:?}")] PipReportVersionMismatch(&'static str, String), + #[error("failed to spawn command {0}: Is {1} installed?")] + ProcessCreation(String, String, io::Error), + #[error("could not strip path prefix")] + StripPrefix(#[from] StripPrefixError), + #[error("unexpected output for {0:?}: {1}")] + UnexpectedOutput(&'static str, String), + #[error("unsupported {0:?} version {2:?}, expected {1:?}")] UnsupportedCommandVersion(&'static str, &'static str, String), - Anyhow(anyhow::Error), + #[error("no lockfile was generated")] NoLockfileGenerated, } - -impl StdError for Error { - fn source(&self) -> Option<&(dyn StdError + 'static)> { - match self { - Self::InvalidUtf8(err) => Some(err), - Self::Json(err) => Some(err), - Self::Io(err) => Some(err), - Self::ProcessCreation(_, _, err) => Some(err), - Self::Anyhow(err) => err.source(), - _ => None, - } - } -} - -impl Display for Error { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidUtf8(_) => write!(f, "utf8 parsing error"), - Self::Json(_) => write!(f, "json parsing error"), - Self::Io(_) => write!(f, "I/O error"), - Self::InvalidManifest(path) => write!(f, "invalid manifest path: {path:?}"), - Self::ProcessCreation(program, tool, _) => { - write!(f, "failed to spawn command {program}: Is {tool} installed?") - }, - Self::NonZeroExit(Some(code), stderr) => { - write!(f, "package manager exited with error code {code}:\n\n{stderr}") - }, - Self::NonZeroExit(None, stderr) => { - write!(f, "package manager quit unexpectedly:\n\n{stderr}") - }, - Self::PipReportVersionMismatch(expected, received) => { - write!(f, "unsupported pip report version {received:?}, expected {expected:?}") - }, - Self::UnsupportedCommandVersion(command, expected, received) => { - write!(f, "unsupported {command:?} version {received:?}, expected {expected:?}") - }, - Self::NoLockfileGenerated => write!(f, "no lockfile was generated"), - Self::Anyhow(err) => write!(f, "{err}"), - } - } -} - -impl From for Error { - fn from(err: io::Error) -> Self { - Self::Io(err) - } -} - -impl From for Error { - fn from(err: FromUtf8Error) -> Self { - Self::InvalidUtf8(err) - } -} - -impl From for Error { - fn from(err: JsonError) -> Self { - Self::Json(err) - } -} - -impl From for Error { - fn from(err: anyhow::Error) -> Self { - Self::Anyhow(err) - } -} diff --git a/lockfile_generator/src/npm.rs b/lockfile_generator/src/npm.rs index 2c5cfd0e9..cd6121788 100644 --- a/lockfile_generator/src/npm.rs +++ b/lockfile_generator/src/npm.rs @@ -1,25 +1,31 @@ //! JavaScript npm ecosystem. +use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +use glob::Pattern; +use serde::Deserialize; + use crate::{Error, Generator, Result}; +/// Maximum upwards travelsal when searching for a workspace root. +const WORKSPACE_ROOT_RECURSION_LIMIT: usize = 16; + pub struct Npm; impl Generator for Npm { fn lockfile_path(&self, manifest_path: &Path) -> Result { - let project_path = manifest_path - .parent() - .ok_or_else(|| Error::InvalidManifest(manifest_path.to_path_buf()))?; - Ok(project_path.join("package-lock.json")) + let workspace_root = find_workspace_root(manifest_path)?; + Ok(workspace_root.join("package-lock.json")) } fn conflicting_files(&self, manifest_path: &Path) -> Result> { + let workspace_root = find_workspace_root(manifest_path)?; Ok(vec![ - self.lockfile_path(manifest_path)?, - PathBuf::from("npm-shrinkwrap.json"), - PathBuf::from("yarn.lock"), + workspace_root.join("package-lock.json"), + workspace_root.join("npm-shrinkwrap.json"), + workspace_root.join("yarn.lock"), ]) } @@ -33,3 +39,101 @@ impl Generator for Npm { "npm" } } + +/// Find the workspace root of an npm project. +pub(crate) fn find_workspace_root(manifest_path: impl AsRef) -> Result { + let manifest_path = manifest_path.as_ref(); + let original_root = manifest_path + .parent() + .ok_or_else(|| Error::InvalidManifest(manifest_path.to_path_buf()))? + .canonicalize()?; + + // Search parent directories for workspace manifests. + for path in original_root.ancestors().skip(1).take(WORKSPACE_ROOT_RECURSION_LIMIT) { + // Check if directory has an NPM manifest. + let manifest_path = path.join("package.json"); + if !manifest_path.exists() { + continue; + } + + // Parse manifest. + let content = fs::read_to_string(&manifest_path)?; + let manifest: PackageJson = serde_json::from_str(&content)?; + + // Ignore non-workspace manifests. + let workspaces = match manifest.workspaces { + Some(workspaces) => workspaces, + None => continue, + }; + + // Get original manifest's location relative to this manifest. + let relative_path = original_root.strip_prefix(path)?; + + // Check if original manifest location matches any workspace glob. + let is_root = workspaces.iter().any(|glob| { + let glob = glob.strip_prefix("./").unwrap_or(glob); + Pattern::new(glob).map_or(false, |pattern| pattern.matches_path(relative_path)) + }); + + if is_root { + return Ok(path.into()); + } else { + return Ok(original_root); + } + } + + Ok(original_root) +} + +/// Package JSON subset. +#[derive(Deserialize, Debug)] +struct PackageJson { + workspaces: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + const NON_WORKSPACE_MANIFEST: &str = r#"{ "name": "test" }"#; + + #[test] + fn root_with_workspace() { + const WORKSPACE_MANIFEST: &str = + r#"{ "name": "parent", "workspaces": ["./packages/sub/*"] }"#; + + // Write root workspace manifest. + let tempdir = tempfile::tempdir().unwrap(); + let workspace_manifest = tempdir.path().join("package.json"); + fs::write(workspace_manifest, WORKSPACE_MANIFEST).unwrap(); + + // Create irrelevant non-manifest directory. + let nothing_dir = tempdir.path().join("packages"); + fs::create_dir_all(¬hing_dir).unwrap(); + + // Write irrelevant intermediate manifest. + let sub_dir = nothing_dir.join("sub"); + fs::create_dir_all(&sub_dir).unwrap(); + let sub_manifest = sub_dir.join("package.json"); + fs::write(sub_manifest, NON_WORKSPACE_MANIFEST).unwrap(); + + // Write target project manifest. + let project_dir = sub_dir.join("project"); + fs::create_dir_all(&project_dir).unwrap(); + let project_manifest = project_dir.join("package.json"); + fs::write(&project_manifest, NON_WORKSPACE_MANIFEST).unwrap(); + + let root = find_workspace_root(&project_manifest).unwrap(); + assert_eq!(root, tempdir.path().to_path_buf().canonicalize().unwrap()); + } + + #[test] + fn root_without_workspace() { + let tempdir = tempfile::tempdir().unwrap(); + let manifest_path = tempdir.path().join("package.json"); + fs::write(&manifest_path, NON_WORKSPACE_MANIFEST).unwrap(); + + let root = find_workspace_root(&manifest_path).unwrap(); + assert_eq!(root, tempdir.path().to_path_buf().canonicalize().unwrap()); + } +} diff --git a/lockfile_generator/src/pnpm.rs b/lockfile_generator/src/pnpm.rs index 43d0ce35e..9c9cc0d0b 100644 --- a/lockfile_generator/src/pnpm.rs +++ b/lockfile_generator/src/pnpm.rs @@ -2,17 +2,35 @@ use std::path::{Path, PathBuf}; use std::process::Command; +use std::{env, fs}; use crate::{Error, Generator, Result}; +const WORKSPACE_MANIFEST_FILENAME: &str = "pnpm-workspace.yaml"; +const WORKSPACE_DIR_ENV_VAR: &str = "NPM_CONFIG_WORKSPACE_DIR"; + pub struct Pnpm; impl Generator for Pnpm { + // Based on PNPM's implementation: + // https://github.com/pnpm/pnpm/blob/98377afd3452d92183e4b643a8b122887c0406c3/workspace/find-workspace-dir/src/index.ts fn lockfile_path(&self, manifest_path: &Path) -> Result { let project_path = manifest_path .parent() .ok_or_else(|| Error::InvalidManifest(manifest_path.to_path_buf()))?; - Ok(project_path.join("pnpm-lock.yaml")) + + // Get project root from env variable. + let workspace_dir_env = env::var_os(WORKSPACE_DIR_ENV_VAR) + .or_else(|| env::var_os(WORKSPACE_DIR_ENV_VAR.to_lowercase())) + .map(PathBuf::from); + + // Fallback to recursive search for `WORKSPACE_MANIFEST_FILENAME`. + let workspace_root = workspace_dir_env.or_else(|| find_workspace_root(project_path)); + + // Fallback to non-workspace location. + let root = workspace_root.unwrap_or_default(); + + Ok(root.join("pnpm-lock.yaml")) } fn command(&self, _manifest_path: &Path) -> Command { @@ -25,3 +43,18 @@ impl Generator for Pnpm { "pnpm" } } + +/// Find PNPM workspace root. +fn find_workspace_root(path: &Path) -> Option { + for path in path.ancestors() { + let dir = fs::read_dir(path).ok()?; + + for dir_entry in dir.into_iter().flatten().map(|entry| entry.path()) { + if dir_entry.file_name().map_or(false, |name| name == WORKSPACE_MANIFEST_FILENAME) { + return Some(path.into()); + } + } + } + + None +} diff --git a/lockfile_generator/src/yarn.rs b/lockfile_generator/src/yarn.rs index c971d1e5a..44b216b24 100644 --- a/lockfile_generator/src/yarn.rs +++ b/lockfile_generator/src/yarn.rs @@ -4,16 +4,14 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; -use crate::{Error, Generator, Result}; +use crate::{npm, Error, Generator, Result}; pub struct Yarn; impl Generator for Yarn { fn lockfile_path(&self, manifest_path: &Path) -> Result { - let project_path = manifest_path - .parent() - .ok_or_else(|| Error::InvalidManifest(manifest_path.to_path_buf()))?; - Ok(project_path.join("yarn.lock")) + let workspace_root = npm::find_workspace_root(manifest_path)?; + Ok(workspace_root.join("yarn.lock")) } fn command(&self, _manifest_path: &Path) -> Command {