use super::Project;
use super::types::{ProjectDescriptor, ProjectKind, ProjectValidationStatus};
use crate::config::StandardConfig;
use crate::error::{Error, Result};
use crate::filesystem::{AsyncFileSystem, FileSystemManager};
use crate::monorepo::{MonorepoDetector, MonorepoDetectorTrait};
use crate::node::{PackageManager, RepoKind};
use async_trait::async_trait;
use package_json::PackageJson;
use std::path::{Path, PathBuf};
#[derive(Debug)]
struct ProjectMetadata {
root: PathBuf,
package_json: Option<PackageJson>,
package_manager: Option<PackageManager>,
validation_status: ProjectValidationStatus,
}
impl ProjectMetadata {
fn new(root: PathBuf) -> Self {
Self {
root,
package_json: None,
package_manager: None,
validation_status: ProjectValidationStatus::NotValidated,
}
}
}
#[async_trait]
pub trait ProjectDetectorTrait: Send + Sync {
async fn detect(
&self,
path: &Path,
config: Option<&StandardConfig>,
) -> Result<ProjectDescriptor>;
async fn detect_kind(&self, path: &Path) -> Result<ProjectKind>;
async fn is_valid_project(&self, path: &Path) -> bool;
}
#[async_trait]
pub trait ProjectDetectorWithFs<F: AsyncFileSystem>: ProjectDetectorTrait {
fn filesystem(&self) -> &F;
async fn detect_multiple(
&self,
paths: &[&Path],
config: Option<&StandardConfig>,
) -> Vec<Result<ProjectDescriptor>>;
}
#[derive(Debug)]
pub struct ProjectDetector<F: AsyncFileSystem = FileSystemManager> {
fs: F,
}
impl ProjectDetector<FileSystemManager> {
#[must_use]
pub fn new() -> Self {
let fs = FileSystemManager::new();
Self { fs }
}
}
impl<F: AsyncFileSystem + Clone + 'static> ProjectDetector<F> {
#[must_use]
pub fn with_filesystem(fs: F) -> Self {
Self { fs }
}
async fn load_project_metadata(
&self,
path: &Path,
config: &StandardConfig,
) -> Result<ProjectMetadata> {
let mut metadata = ProjectMetadata::new(path.to_path_buf());
let package_json_path = path.join("package.json");
if self.fs.exists(&package_json_path).await {
let content = self.fs.read_file_string(&package_json_path).await?;
metadata.package_json = Some(
serde_json::from_str::<PackageJson>(&content)
.map_err(|e| Error::operation(format!("Invalid package.json: {e}")))?,
);
}
log::debug!(
"Package manager detection with config: detection_order={:?}, detect_from_env={}",
config.package_managers.detection_order,
config.package_managers.detect_from_env
);
if let Ok(pm) = PackageManager::detect_with_config(path, &config.package_managers) {
metadata.package_manager = Some(pm);
}
if config.validation.require_package_json && metadata.package_json.is_none() {
metadata.validation_status = ProjectValidationStatus::Error(vec![
"package.json is required by configuration".to_string(),
]);
} else {
metadata.validation_status = ProjectValidationStatus::NotValidated;
}
Ok(metadata)
}
fn should_detect_monorepo(config: &StandardConfig) -> bool {
!config.monorepo.workspace_patterns.is_empty() && config.monorepo.max_search_depth > 0
}
async fn detect_monorepo_with_config(
&self,
path: &Path,
config: &StandardConfig,
) -> Result<crate::monorepo::MonorepoDescriptor> {
log::debug!(
"Detecting monorepo with config: max_depth={}, patterns={:?}, exclude={:?}",
config.monorepo.max_search_depth,
config.monorepo.workspace_patterns,
config.monorepo.exclude_patterns
);
let config_aware_detector =
MonorepoDetector::with_filesystem_and_config(self.fs.clone(), config.monorepo.clone());
config_aware_detector.detect_monorepo(path).await
}
pub async fn detect(
&self,
path: impl AsRef<Path>,
config: Option<&StandardConfig>,
) -> Result<ProjectDescriptor> {
let path = path.as_ref();
self.validate_project_path(path).await?;
let effective_config = match config {
Some(cfg) => cfg.clone(),
None => StandardConfig::default(),
};
let metadata = self.load_project_metadata(path, &effective_config).await?;
let project_kind = if Self::should_detect_monorepo(&effective_config) {
if let Ok(monorepo) = self.detect_monorepo_with_config(path, &effective_config).await {
ProjectKind::Repository(RepoKind::Monorepo(monorepo.kind().clone()))
} else {
ProjectKind::Repository(RepoKind::Simple)
}
} else {
ProjectKind::Repository(RepoKind::Simple)
};
let mut project = Project::new(metadata.root, project_kind);
project.package_manager = metadata.package_manager;
project.package_json = metadata.package_json;
project.validation_status = metadata.validation_status;
if project.is_monorepo()
&& let Ok(monorepo) = self.detect_monorepo_with_config(path, &effective_config).await
{
project.internal_dependencies = monorepo.packages().to_vec();
}
Ok(ProjectDescriptor::NodeJs(project))
}
pub async fn detect_kind(&self, path: impl AsRef<Path>) -> Result<ProjectKind> {
let default_config = StandardConfig::default();
self.detect_kind_with_config(path, &default_config).await
}
pub async fn detect_kind_with_config(
&self,
path: impl AsRef<Path>,
config: &StandardConfig,
) -> Result<ProjectKind> {
let path = path.as_ref();
self.validate_project_path(path).await?;
if Self::should_detect_monorepo(config) {
let config_aware_detector = MonorepoDetector::with_filesystem_and_config(
self.fs.clone(),
config.monorepo.clone(),
);
if let Some(monorepo_kind) = config_aware_detector.is_monorepo_root(path).await? {
return Ok(ProjectKind::Repository(RepoKind::Monorepo(monorepo_kind)));
}
}
Ok(ProjectKind::Repository(RepoKind::Simple))
}
async fn validate_project_path(&self, path: &Path) -> Result<()> {
if !self.fs.exists(path).await {
return Err(Error::operation(format!("Path does not exist: {}", path.display())));
}
let package_json_path = path.join("package.json");
if !self.fs.exists(&package_json_path).await {
return Err(Error::operation(format!(
"No package.json found at: {}",
package_json_path.display()
)));
}
Ok(())
}
#[must_use]
pub async fn is_valid_project(&self, path: impl AsRef<Path>) -> bool {
let path = path.as_ref();
if self.validate_project_path(path).await.is_err() {
return false;
}
let package_json_path = path.join("package.json");
match self.fs.read_file_string(&package_json_path).await {
Ok(content) => match serde_json::from_str::<PackageJson>(&content) {
Ok(_) => true,
Err(e) => {
log::warn!(
"Failed to parse package.json at {}: {}",
package_json_path.display(),
e
);
false
}
},
Err(e) => {
log::debug!(
"Could not read package.json at {} for validation: {}",
package_json_path.display(),
e
);
false
}
}
}
}
#[async_trait]
impl<F: AsyncFileSystem + Clone + 'static> ProjectDetectorTrait for ProjectDetector<F> {
async fn detect(
&self,
path: &Path,
config: Option<&StandardConfig>,
) -> Result<ProjectDescriptor> {
self.detect(path, config).await
}
async fn detect_kind(&self, path: &Path) -> Result<ProjectKind> {
self.detect_kind(path).await
}
async fn is_valid_project(&self, path: &Path) -> bool {
self.is_valid_project(path).await
}
}
#[async_trait]
impl<F: AsyncFileSystem + Clone + 'static> ProjectDetectorWithFs<F> for ProjectDetector<F> {
fn filesystem(&self) -> &F {
&self.fs
}
async fn detect_multiple(
&self,
paths: &[&Path],
config: Option<&StandardConfig>,
) -> Vec<Result<ProjectDescriptor>> {
let mut results = Vec::with_capacity(paths.len());
let futures = paths.iter().map(|path| self.detect(path, config));
for future in futures {
results.push(future.await);
}
results
}
}
impl<F: AsyncFileSystem + Clone> Default for ProjectDetector<F>
where
F: Default,
{
fn default() -> Self {
let fs = F::default();
Self { fs }
}
}