diff --git a/lockfile_generator/src/lib.rs b/lockfile_generator/src/lib.rs index 98dfad681..21f67661a 100644 --- a/lockfile_generator/src/lib.rs +++ b/lockfile_generator/src/lib.rs @@ -145,6 +145,7 @@ pub enum Error { InvalidManifest(PathBuf), PipReportVersionMismatch(&'static str, String), UnsupportedCommandVersion(&'static str, &'static str, String), + UnexpectedOutput(&'static str, String), Anyhow(anyhow::Error), NoLockfileGenerated, } @@ -184,8 +185,9 @@ impl Display for Error { 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}"), + Self::UnexpectedOutput(cmd, output) => write!(f, "unexpected output for {cmd:?}: {output}"), + Self::NoLockfileGenerated => write!(f, "no lockfile was generated"), } } } diff --git a/lockfile_generator/src/npm.rs b/lockfile_generator/src/npm.rs index 2c5cfd0e9..ad79219ba 100644 --- a/lockfile_generator/src/npm.rs +++ b/lockfile_generator/src/npm.rs @@ -12,7 +12,22 @@ impl Generator for Npm { let project_path = manifest_path .parent() .ok_or_else(|| Error::InvalidManifest(manifest_path.to_path_buf()))?; - Ok(project_path.join("package-lock.json")) + + let output = Command::new("npm").current_dir(project_path).args(["root"]).output()?; + + // Ensure command was successful. + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::NonZeroExit(output.status.code(), stderr.into())); + } + + // Parse root node_modules output. + let stdout = String::from_utf8(output.stdout).map_err(Error::InvalidUtf8)?; + let workspace_root = stdout + .strip_suffix("/node_modules\n") + .ok_or_else(|| Error::UnexpectedOutput("npm root", stdout.clone()))?; + + Ok(PathBuf::from(workspace_root).join("package-lock.json")) } fn conflicting_files(&self, manifest_path: &Path) -> Result> { diff --git a/lockfile_generator/src/pnpm.rs b/lockfile_generator/src/pnpm.rs index 43d0ce35e..9968de0f0 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(|workspace_dir| PathBuf::from(workspace_dir)); + + // 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_else(|| PathBuf::new()); + + 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(mut path: &Path) -> Option { + loop { + 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()); + } + } + + path = path.parent()?; + } +} diff --git a/lockfile_generator/src/yarn.rs b/lockfile_generator/src/yarn.rs index c971d1e5a..2c8e2a7af 100644 --- a/lockfile_generator/src/yarn.rs +++ b/lockfile_generator/src/yarn.rs @@ -4,6 +4,8 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +use serde::Deserialize; + use crate::{Error, Generator, Result}; pub struct Yarn; @@ -13,7 +15,38 @@ impl Generator for Yarn { let project_path = manifest_path .parent() .ok_or_else(|| Error::InvalidManifest(manifest_path.to_path_buf()))?; - Ok(project_path.join("yarn.lock")) + + let output = Command::new("yarn") + .current_dir(project_path) + .args(["workspaces", "list", "--json"]) + .output()?; + + // Ensure command was successful. + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::NonZeroExit(output.status.code(), stderr.into())); + } + + // Convert output to actual valid json. + let stdout = String::from_utf8(output.stdout).map_err(Error::InvalidUtf8)?; + let workspaces_json = format!("[{}]", stdout.trim_end().replace('\n', ",")); + + // Parse workspace list. + let mut workspaces: Vec = serde_json::from_str(&workspaces_json)?; + + // Sort by longest location path, to prefer longer matches. + workspaces.sort_by(|a, b| b.location.len().cmp(&a.location.len())); + + // Find workspace root by stripping the first matching location path. + let project_str = project_path + .to_str() + .ok_or_else(|| Error::InvalidManifest(manifest_path.to_path_buf()))?; + let workspace_root = workspaces + .into_iter() + .find_map(|project| project_str.strip_suffix(&project.location)) + .unwrap_or(project_str); + + Ok(PathBuf::from(workspace_root).join("yarn.lock")) } fn command(&self, _manifest_path: &Path) -> Command { @@ -52,3 +85,9 @@ fn yarn_version(manifest_path: &Path) -> Result { Err(Error::NonZeroExit(output.status.code(), stderr.into())) } } + +/// Yarn workspace project. +#[derive(Deserialize)] +struct Workspace { + location: String, +}