use super::Project;
use super::types::{ProjectDescriptor, ProjectInfo, ProjectValidationStatus};
use crate::error::Result;
use crate::filesystem::{AsyncFileSystem, FileSystemManager};
use package_json::PackageJson;
use std::path::Path;
#[derive(Debug)]
pub struct ProjectValidator<F: AsyncFileSystem = FileSystemManager> {
fs: F,
}
impl ProjectValidator<FileSystemManager> {
#[must_use]
pub fn new() -> Self {
Self { fs: FileSystemManager::new() }
}
}
impl<F: AsyncFileSystem> ProjectValidator<F> {
#[must_use]
pub fn with_filesystem(fs: F) -> Self {
Self { fs }
}
pub async fn validate_project(&self, project: &mut ProjectDescriptor) -> Result<()> {
match project {
ProjectDescriptor::NodeJs(project) => {
if project.is_monorepo() {
self.validate_monorepo_project(project).await;
} else {
self.validate_simple_project(project).await;
}
Ok(())
}
}
}
async fn validate_simple_project(&self, project: &mut Project) {
let mut errors = Vec::new();
let mut warnings = Vec::new();
self.validate_package_json(project.root(), &mut errors, &mut warnings).await;
self.validate_package_manager_consistency(project, &mut errors, &mut warnings).await;
self.validate_dependencies(project, &mut errors, &mut warnings).await;
let status = self.create_validation_status(errors, warnings);
project.set_validation_status(status);
}
async fn validate_monorepo_project(&self, project: &mut Project) {
let mut errors = Vec::new();
let mut warnings = Vec::new();
if project.internal_dependencies.is_empty() {
warnings.push("Monorepo detected but no packages found".to_string());
}
if !self.fs.exists(project.root()).await {
errors.push("Monorepo root directory does not exist".to_string());
project.set_validation_status(ProjectValidationStatus::Error(errors));
return;
}
for package in &project.internal_dependencies {
if self.fs.exists(&package.absolute_path).await {
let package_json_path = package.absolute_path.join("package.json");
if !self.fs.exists(&package_json_path).await {
warnings.push(format!("Package '{}' is missing package.json", package.name));
}
} else {
errors.push(format!(
"Package '{}' directory does not exist at {}",
package.name,
package.absolute_path.display()
));
}
}
for package in &project.internal_dependencies {
for dep_name in &package.workspace_dependencies {
if !project.internal_dependencies.iter().any(|p| p.name == *dep_name) {
errors.push(format!(
"Package '{}' depends on workspace package '{}' which was not found",
package.name, dep_name
));
}
}
for dep_name in &package.workspace_dev_dependencies {
if !project.internal_dependencies.iter().any(|p| p.name == *dep_name) {
warnings.push(format!(
"Package '{}' has dev dependency on workspace package '{}' which was not found",
package.name, dep_name
));
}
}
}
if let Some(package_json) = project.package_json() {
self.validate_package_json_content(package_json, &mut warnings);
}
if let Some(package_manager) = project.package_manager() {
let lock_file_path = package_manager.lock_file_path();
if !self.fs.exists(&lock_file_path).await {
warnings.push(format!(
"Detected {} but lock file is missing: {}",
package_manager.kind().command(),
lock_file_path.display()
));
}
self.check_conflicting_lock_files(
project.root(),
package_manager.kind(),
&mut warnings,
)
.await;
} else if project.package_json().is_some() {
warnings.push("Package manager could not be detected".to_string());
}
let validation_status = if !errors.is_empty() {
ProjectValidationStatus::Error(errors)
} else if !warnings.is_empty() {
ProjectValidationStatus::Warning(warnings)
} else {
ProjectValidationStatus::Valid
};
project.set_validation_status(validation_status);
}
async fn validate_package_json(
&self,
root: &Path,
errors: &mut Vec<String>,
warnings: &mut Vec<String>,
) {
let package_json_path = root.join("package.json");
if !self.fs.exists(&package_json_path).await {
errors.push("Missing package.json file".to_string());
return;
}
match self.fs.read_file_string(&package_json_path).await {
Ok(content) => {
if let Err(e) = serde_json::from_str::<PackageJson>(&content) {
errors.push(format!("Invalid package.json format: {e}"));
} else {
if let Ok(package_json) = serde_json::from_str::<PackageJson>(&content) {
self.validate_package_json_content(&package_json, warnings);
}
}
}
Err(e) => {
errors.push(format!("Failed to read package.json: {e}"));
}
}
}
#[allow(clippy::unused_self)]
fn validate_package_json_content(
&self,
package_json: &PackageJson,
warnings: &mut Vec<String>,
) {
if package_json.name.is_empty() {
warnings.push("Package name is empty".to_string());
}
if package_json.version == "1.0.0" {
warnings.push("Package is using default version (1.0.0)".to_string());
}
if package_json.description.is_none() {
warnings.push("Package description is missing".to_string());
}
if package_json.license.is_none() {
warnings.push("Package license is missing".to_string());
}
}
async fn validate_package_manager_consistency(
&self,
project: &Project,
_errors: &mut [String],
warnings: &mut Vec<String>,
) {
if let Some(package_manager) = project.package_manager() {
let lock_file_path = package_manager.lock_file_path();
if !self.fs.exists(&lock_file_path).await {
warnings.push(format!(
"Detected {} but lock file is missing: {}",
package_manager.kind().command(),
lock_file_path.display()
));
}
self.check_conflicting_lock_files(project.root(), package_manager.kind(), warnings)
.await;
} else if project.package_json().is_some() {
warnings.push("Package manager could not be detected".to_string());
}
}
async fn check_conflicting_lock_files(
&self,
root: &Path,
detected_kind: crate::node::PackageManagerKind,
warnings: &mut Vec<String>,
) {
use crate::node::PackageManagerKind;
let lock_files = [
(PackageManagerKind::Npm, "package-lock.json"),
(PackageManagerKind::Yarn, "yarn.lock"),
(PackageManagerKind::Pnpm, "pnpm-lock.yaml"),
(PackageManagerKind::Bun, "bun.lockb"),
];
for (kind, lock_file) in &lock_files {
if *kind != detected_kind {
let lock_path = root.join(lock_file);
if self.fs.exists(&lock_path).await {
warnings.push(format!(
"Conflicting lock file found: {} (detected: {})",
lock_file,
detected_kind.command()
));
}
}
}
}
async fn validate_dependencies(
&self,
project: &Project,
_errors: &mut [String],
warnings: &mut Vec<String>,
) {
if let Some(package_json) = project.package_json() {
let has_dependencies = package_json.dependencies.is_some()
|| package_json.dev_dependencies.is_some()
|| package_json.peer_dependencies.is_some();
if has_dependencies {
let node_modules_path = project.root().join("node_modules");
if self.fs.exists(&node_modules_path).await {
match self.fs.read_dir(&node_modules_path).await {
Ok(_) => {
}
Err(_) => {
warnings
.push("Could not check node_modules directory status".to_string());
}
}
} else {
warnings.push(
"Dependencies declared but node_modules directory is missing".to_string(),
);
}
}
}
}
#[allow(clippy::unused_self)]
fn create_validation_status(
&self,
errors: Vec<String>,
warnings: Vec<String>,
) -> ProjectValidationStatus {
if !errors.is_empty() {
ProjectValidationStatus::Error(errors)
} else if !warnings.is_empty() {
ProjectValidationStatus::Warning(warnings)
} else {
ProjectValidationStatus::Valid
}
}
}
impl<F: AsyncFileSystem> Default for ProjectValidator<F>
where
F: Default,
{
fn default() -> Self {
Self { fs: F::default() }
}
}