use super::{MonorepoDescriptor, MonorepoKind, WorkspacePackage};
use crate::error::{Error, Result};
use crate::filesystem::{AsyncFileSystem, FileSystemManager};
use crate::project::ProjectValidationStatus;
use async_trait::async_trait;
use glob;
use std::path::{Path, PathBuf};
#[async_trait]
pub trait MonorepoDetectorTrait: Send + Sync {
async fn is_monorepo_root(&self, path: &Path) -> Result<Option<MonorepoKind>>;
async fn find_monorepo_root(
&self,
start_path: &Path,
) -> Result<Option<(PathBuf, MonorepoKind)>>;
async fn detect_monorepo(&self, path: &Path) -> Result<MonorepoDescriptor>;
async fn detect_packages(&self, root: &Path) -> Result<Vec<WorkspacePackage>>;
async fn has_multiple_packages(&self, path: &Path) -> bool;
}
#[async_trait]
pub trait MonorepoDetectorWithFs<F: AsyncFileSystem>: MonorepoDetectorTrait {
fn filesystem(&self) -> &F;
async fn detect_packages_multiple(&self, roots: &[&Path])
-> Vec<Result<Vec<WorkspacePackage>>>;
async fn detect_packages_parallel(
&self,
root: &Path,
max_concurrent: usize,
) -> Result<Vec<WorkspacePackage>>;
}
#[derive(Debug, Clone)]
pub struct MonorepoDetector<F: AsyncFileSystem = FileSystemManager> {
fs: F,
config: crate::config::MonorepoConfig,
}
impl MonorepoDetector<FileSystemManager> {
#[must_use]
pub fn new() -> Self {
Self { fs: FileSystemManager::new(), config: crate::config::MonorepoConfig::default() }
}
#[must_use]
pub fn new_with_config(config: crate::config::MonorepoConfig) -> Self {
Self { fs: FileSystemManager::new(), config }
}
}
impl<F: AsyncFileSystem + Clone> MonorepoDetector<F> {
#[must_use]
pub fn with_filesystem(fs: F) -> Self {
Self { fs, config: crate::config::MonorepoConfig::default() }
}
#[must_use]
pub fn with_filesystem_and_config(fs: F, config: crate::config::MonorepoConfig) -> Self {
Self { fs, config }
}
}
#[async_trait]
impl<F: AsyncFileSystem + Clone> MonorepoDetectorTrait for MonorepoDetector<F> {
async fn is_monorepo_root(&self, path: &Path) -> Result<Option<MonorepoKind>> {
let yarn_lock_path = path.join("yarn.lock");
if self.fs.exists(&yarn_lock_path).await {
let package_json_path = path.join("package.json");
if self.fs.exists(&package_json_path).await
&& let Ok(content) = self.fs.read_file_string(&package_json_path).await
{
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&content)
&& let Some(workspaces) = json_value.get("workspaces")
&& !workspaces.is_null()
{
return Ok(Some(MonorepoKind::YarnWorkspaces));
}
}
}
let pnpm_lock_path = path.join("pnpm-lock.yaml");
if self.fs.exists(&pnpm_lock_path).await {
let package_json_path = path.join("package.json");
if self.fs.exists(&package_json_path).await {
match self.fs.read_file_string(&package_json_path).await {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(json_value) => {
if let Some(workspaces) = json_value.get("workspaces")
&& !workspaces.is_null()
{
return Ok(Some(MonorepoKind::PnpmWorkspaces));
}
}
Err(e) => {
log::warn!(
"Failed to parse package.json for pnpm workspace detection at {}: {}",
package_json_path.display(),
e
);
}
},
Err(e) => {
log::debug!(
"Could not read package.json for pnpm workspace detection at {}: {}",
package_json_path.display(),
e
);
}
}
}
}
let pnpm_workspace_path = path.join("pnpm-workspace.yaml");
if self.fs.exists(&pnpm_workspace_path).await {
return Ok(Some(MonorepoKind::PnpmWorkspaces));
}
let bun_lockb_path = path.join("bun.lockb");
if self.fs.exists(&bun_lockb_path).await {
return Ok(Some(MonorepoKind::BunWorkspaces));
}
let deno_json_path = path.join("deno.json");
if self.fs.exists(&deno_json_path).await {
return Ok(Some(MonorepoKind::DenoWorkspaces));
}
let package_json_path = path.join("package.json");
let npm_lock_path = path.join("package-lock.json");
if self.fs.exists(&package_json_path).await && self.fs.exists(&npm_lock_path).await {
match self.fs.read_file_string(&package_json_path).await {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(json_value) => {
if let Some(workspaces) = json_value.get("workspaces")
&& !workspaces.is_null()
{
return Ok(Some(MonorepoKind::NpmWorkSpace));
}
}
Err(e) => {
log::warn!(
"Failed to parse package.json for npm workspace detection at {}: {}",
package_json_path.display(),
e
);
}
},
Err(e) => {
log::debug!(
"Could not read package.json for npm workspace detection at {}: {}",
package_json_path.display(),
e
);
}
}
}
Ok(None)
}
async fn find_monorepo_root(
&self,
start_path: &Path,
) -> Result<Option<(PathBuf, MonorepoKind)>> {
let mut current_path = start_path.to_path_buf();
loop {
if let Some(kind) = self.is_monorepo_root(¤t_path).await? {
return Ok(Some((current_path, kind)));
}
if let Some(parent) = current_path.parent() {
current_path = parent.to_path_buf();
} else {
break;
}
}
Ok(None)
}
async fn detect_monorepo(&self, path: &Path) -> Result<MonorepoDescriptor> {
let kind = self.is_monorepo_root(path).await?.ok_or_else(|| {
use crate::error::{FileSystemError, MonorepoError};
Error::Monorepo(MonorepoError::Detection {
source: FileSystemError::NotFound { path: path.to_path_buf() },
})
})?;
let packages = self.detect_packages(path).await?;
Ok(MonorepoDescriptor::new(
kind,
path.to_path_buf(),
packages,
None, None, ProjectValidationStatus::NotValidated,
))
}
#[allow(clippy::assigning_clones)]
async fn detect_packages(&self, root: &Path) -> Result<Vec<WorkspacePackage>> {
let mut packages = Vec::new();
let discovered_scopes = self.discover_internal_scopes(root).await?;
let package_json_path = root.join("package.json");
if !self.fs.exists(&package_json_path).await {
return Err(Error::operation(format!(
"No package.json found at monorepo root: {}",
package_json_path.display()
)));
}
let content = self.fs.read_file_string(&package_json_path).await?;
let json_value: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| Error::operation(format!("Invalid package.json: {e}")))?;
let mut workspace_patterns = if let Some(workspaces) = json_value.get("workspaces") {
if let Some(array) = workspaces.as_array() {
array
.iter()
.filter_map(|v| v.as_str())
.map(std::string::ToString::to_string)
.collect::<Vec<String>>()
} else {
Vec::new() }
} else {
Vec::new() };
if workspace_patterns.is_empty() {
workspace_patterns.clone_from(&self.config.workspace_patterns);
} else {
let mut unique_patterns = std::collections::HashSet::new();
for pattern in &workspace_patterns {
unique_patterns.insert(pattern.clone());
}
for pattern in &self.config.workspace_patterns {
unique_patterns.insert(pattern.clone());
}
workspace_patterns = unique_patterns.into_iter().collect();
}
if workspace_patterns.is_empty() {
return Ok(packages);
}
let mut discovered_package_names: std::collections::HashSet<String> =
std::collections::HashSet::new();
for pattern in workspace_patterns {
let full_pattern = root.join(&pattern).to_string_lossy().to_string();
if let Ok(paths) = glob::glob(&full_pattern) {
for dir_path in paths.flatten() {
if self.should_exclude_path(&dir_path) {
continue;
}
if let Ok(metadata) = self.fs.metadata(&dir_path).await
&& metadata.is_dir()
{
let package_json_path = dir_path.join("package.json");
if self.fs.exists(&package_json_path).await
&& let Ok(package) = self
.load_workspace_package(&package_json_path, &discovered_scopes)
.await
{
if discovered_package_names.insert(package.name.clone()) {
packages.push(package);
}
}
}
}
}
}
Ok(packages)
}
async fn has_multiple_packages(&self, path: &Path) -> bool {
if let Ok(packages) = self.detect_packages(path).await { packages.len() > 1 } else { false }
}
}
#[async_trait]
impl<F: AsyncFileSystem + Clone> MonorepoDetectorWithFs<F> for MonorepoDetector<F> {
fn filesystem(&self) -> &F {
&self.fs
}
async fn detect_packages_multiple(
&self,
roots: &[&Path],
) -> Vec<Result<Vec<WorkspacePackage>>> {
let mut results = Vec::with_capacity(roots.len());
let futures = roots.iter().map(|root| self.detect_packages(root));
for future in futures {
results.push(future.await);
}
results
}
async fn detect_packages_parallel(
&self,
root: &Path,
_max_concurrent: usize,
) -> Result<Vec<WorkspacePackage>> {
self.detect_packages(root).await
}
}
impl<F: AsyncFileSystem + Clone> MonorepoDetector<F> {
async fn discover_internal_scopes(&self, root: &Path) -> Result<Vec<String>> {
let mut discovered_scopes = std::collections::HashSet::new();
let package_json_path = root.join("package.json");
if !self.fs.exists(&package_json_path).await {
return Ok(Vec::new());
}
let content = self.fs.read_file_string(&package_json_path).await?;
let json_value: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| Error::operation(format!("Invalid package.json: {e}")))?;
let mut workspace_patterns = if let Some(workspaces) = json_value.get("workspaces") {
if let Some(array) = workspaces.as_array() {
array
.iter()
.filter_map(|v| v.as_str())
.map(std::string::ToString::to_string)
.collect::<Vec<String>>()
} else {
Vec::new()
}
} else {
self.config.workspace_patterns.clone()
};
if workspace_patterns.is_empty() {
workspace_patterns.clone_from(&self.config.workspace_patterns);
}
for pattern in workspace_patterns {
let full_pattern = root.join(&pattern).to_string_lossy().to_string();
if let Ok(paths) = glob::glob(&full_pattern) {
for dir_path in paths.flatten() {
if self.should_exclude_path(&dir_path) {
continue;
}
if let Ok(metadata) = self.fs.metadata(&dir_path).await
&& metadata.is_dir()
{
let package_json_path = dir_path.join("package.json");
if self.fs.exists(&package_json_path).await
&& let Ok(pkg_content) =
self.fs.read_file_string(&package_json_path).await
&& let Ok(pkg_json) =
serde_json::from_str::<serde_json::Value>(&pkg_content)
&& let Some(name) = pkg_json.get("name").and_then(|v| v.as_str())
{
if name.starts_with('@')
&& let Some(slash_pos) = name.find('/')
{
let scope = format!("{}/", &name[..slash_pos]);
discovered_scopes.insert(scope);
}
}
}
}
}
}
let mut scopes: Vec<String> = discovered_scopes.into_iter().collect();
scopes.sort();
Ok(scopes)
}
fn should_exclude_path(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
for exclude_pattern in &self.config.exclude_patterns {
if Self::matches_exclusion_pattern(&path_str, exclude_pattern) {
return true;
}
for component in path.components() {
if let Some(component_str) = component.as_os_str().to_str()
&& Self::matches_exclusion_pattern(component_str, exclude_pattern)
{
return true;
}
}
}
false
}
fn matches_exclusion_pattern(path_component: &str, exclude_pattern: &str) -> bool {
if path_component == exclude_pattern {
return true;
}
if exclude_pattern.contains('*') {
return Self::glob_matches(path_component, exclude_pattern);
}
if exclude_pattern.starts_with('.') || exclude_pattern.starts_with('_') {
return path_component.contains(exclude_pattern);
}
if let Some(remaining) = path_component.strip_prefix(exclude_pattern) {
return remaining.is_empty()
|| remaining.starts_with('/')
|| remaining.starts_with('\\');
}
false
}
fn glob_matches(text: &str, pattern: &str) -> bool {
match glob::Pattern::new(pattern) {
Ok(compiled_pattern) => compiled_pattern.matches(text),
Err(_) => {
Self::simple_wildcard_match(text, pattern)
}
}
}
fn simple_wildcard_match(text: &str, pattern: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(prefix) = pattern.strip_suffix('*') {
return text.starts_with(prefix);
}
if let Some(suffix) = pattern.strip_prefix('*') {
return text.ends_with(suffix);
}
if let Some(star_pos) = pattern.find('*') {
let prefix = &pattern[..star_pos];
let suffix = &pattern[star_pos + 1..];
return text.starts_with(prefix)
&& text.ends_with(suffix)
&& text.len() >= prefix.len() + suffix.len();
}
false
}
fn is_workspace_dependency(&self, dep_name: &str, discovered_scopes: &[String]) -> bool {
for field in &self.config.custom_workspace_fields {
if dep_name.starts_with(field) {
return true;
}
}
for scope in discovered_scopes {
if dep_name.starts_with(scope) {
return true;
}
}
if dep_name.starts_with("file:")
|| dep_name.starts_with("./")
|| dep_name.starts_with("../")
{
return true;
}
for pattern in &self.config.workspace_patterns {
if let Some(prefix) = pattern.strip_suffix("/*") {
let workspace_scope = format!("@{prefix}/");
if dep_name.starts_with(&workspace_scope) {
return true;
}
}
if pattern.contains('*') && pattern != "*" {
if self.matches_workspace_pattern(dep_name, pattern, discovered_scopes) {
return true;
}
}
}
false
}
fn matches_workspace_pattern(
&self,
dep_name: &str,
workspace_pattern: &str,
discovered_scopes: &[String],
) -> bool {
let pattern_parts: Vec<&str> = workspace_pattern.split('/').collect();
if workspace_pattern.contains("*/") {
return Self::analyze_complex_workspace_pattern(
dep_name,
&pattern_parts,
discovered_scopes,
);
}
if !workspace_pattern.contains('/') && workspace_pattern != "*" {
return Self::analyze_simple_workspace_pattern(
dep_name,
workspace_pattern,
discovered_scopes,
);
}
if workspace_pattern == "*" {
return self.analyze_wildcard_workspace_pattern(dep_name, discovered_scopes);
}
false
}
fn analyze_complex_workspace_pattern(
dep_name: &str,
pattern_parts: &[&str],
discovered_scopes: &[String],
) -> bool {
for (index, part) in pattern_parts.iter().enumerate() {
if *part == "*" {
let has_before = index > 0;
let has_after = index < pattern_parts.len() - 1;
if has_before && has_after {
let before_part = pattern_parts[index - 1];
let after_part = pattern_parts[index + 1];
if Self::matches_contextual_pattern(
dep_name,
before_part,
after_part,
discovered_scopes,
) {
return true;
}
} else if has_before {
let scope_base = pattern_parts[index - 1];
if Self::matches_scope_based_pattern(dep_name, scope_base, discovered_scopes) {
return true;
}
} else if has_after {
let scope_suffix = pattern_parts[index + 1];
if Self::matches_reverse_scope_pattern(
dep_name,
scope_suffix,
discovered_scopes,
) {
return true;
}
}
} else {
let scope_pattern = format!("@{part}/");
if dep_name.starts_with(&scope_pattern) {
return true;
}
if dep_name.starts_with('@') && dep_name.contains(part) {
return true;
}
}
}
false
}
fn matches_contextual_pattern(
dep_name: &str,
before_part: &str,
after_part: &str,
discovered_scopes: &[String],
) -> bool {
for scope in discovered_scopes {
if dep_name.starts_with(scope) {
return true;
}
}
if dep_name.starts_with('@') {
let scope_with_suffix = format!("@{before_part}/");
let after_scope = format!("@{after_part}/");
return dep_name.starts_with(&scope_with_suffix) || dep_name.starts_with(&after_scope);
}
false
}
fn matches_scope_based_pattern(
dep_name: &str,
scope_base: &str,
discovered_scopes: &[String],
) -> bool {
for scope in discovered_scopes {
if dep_name.starts_with(scope) {
return true;
}
}
if dep_name.starts_with('@') {
let direct_scope = format!("@{scope_base}/");
if dep_name.starts_with(&direct_scope) {
return true;
}
return Self::infer_scope_from_workspace_pattern(
dep_name,
scope_base,
discovered_scopes,
);
}
false
}
fn matches_reverse_scope_pattern(
dep_name: &str,
scope_suffix: &str,
discovered_scopes: &[String],
) -> bool {
for scope in discovered_scopes {
if dep_name.starts_with(scope) {
return true;
}
}
if dep_name.starts_with('@') {
let suffix_scope = format!("@{scope_suffix}/");
if dep_name.starts_with(&suffix_scope) {
return true;
}
}
false
}
fn infer_scope_from_workspace_pattern(
dep_name: &str,
workspace_dir: &str,
discovered_scopes: &[String],
) -> bool {
for scope in discovered_scopes {
if dep_name.starts_with(scope) {
return true;
}
}
if dep_name.starts_with('@') {
let workspace_scope = format!("@{workspace_dir}/");
return dep_name.starts_with(&workspace_scope);
}
false
}
fn analyze_simple_workspace_pattern(
dep_name: &str,
workspace_pattern: &str,
discovered_scopes: &[String],
) -> bool {
if dep_name.starts_with('@') {
let scope_pattern = format!("@{workspace_pattern}/");
if dep_name.starts_with(&scope_pattern) {
return true;
}
for scope in discovered_scopes {
if dep_name.starts_with(scope) {
return true;
}
}
return Self::infer_scope_from_workspace_pattern(
dep_name,
workspace_pattern,
discovered_scopes,
);
}
false
}
fn analyze_wildcard_workspace_pattern(
&self,
dep_name: &str,
discovered_scopes: &[String],
) -> bool {
for scope in discovered_scopes {
if dep_name.starts_with(scope) {
return true;
}
}
for field in &self.config.custom_workspace_fields {
if dep_name.starts_with(field) {
return true;
}
}
if dep_name.starts_with("file:")
|| dep_name.starts_with("./")
|| dep_name.starts_with("../")
{
return true;
}
for pattern in &self.config.workspace_patterns {
if !pattern.contains('*') {
let workspace_scope = format!("@{pattern}/");
if dep_name.starts_with(&workspace_scope) {
return true;
}
}
}
false
}
async fn load_workspace_package(
&self,
package_json_path: &Path,
discovered_scopes: &[String],
) -> Result<WorkspacePackage> {
let content = self.fs.read_file_string(package_json_path).await?;
let json_value: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| Error::operation(format!("Invalid package.json: {e}")))?;
let location = package_json_path
.parent()
.ok_or_else(|| Error::operation("Invalid package.json path"))?
.to_path_buf();
let absolute_path = match location.canonicalize() {
Ok(canonical) => canonical,
Err(e) => {
log::debug!(
"Failed to canonicalize path {}: {}. Using original path.",
location.display(),
e
);
location.clone()
}
};
let name = json_value.get("name").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
let version =
json_value.get("version").and_then(|v| v.as_str()).unwrap_or("0.0.0").to_string();
let mut workspace_dependencies = Vec::new();
let mut workspace_dev_dependencies = Vec::new();
if let Some(deps) = json_value.get("dependencies")
&& let Some(deps_obj) = deps.as_object()
{
for (dep_name, _) in deps_obj {
if self.is_workspace_dependency(dep_name, discovered_scopes) {
workspace_dependencies.push(dep_name.clone());
}
}
}
if let Some(dev_deps) = json_value.get("devDependencies")
&& let Some(dev_deps_obj) = dev_deps.as_object()
{
for (dep_name, _) in dev_deps_obj {
if self.is_workspace_dependency(dep_name, discovered_scopes) {
workspace_dev_dependencies.push(dep_name.clone());
}
}
}
Ok(WorkspacePackage {
name,
version,
location,
absolute_path,
workspace_dependencies,
workspace_dev_dependencies,
})
}
}
impl<F: AsyncFileSystem + Clone> Default for MonorepoDetector<F>
where
F: Default,
{
fn default() -> Self {
Self::with_filesystem(F::default())
}
}