use crate::error::{Result, GitError, PublishError};
use crate::workspace::WorkspaceInfo;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::process::Stdio;
use tokio::process::Command as AsyncCommand;
#[derive(Debug)]
pub struct WorkspaceValidator {
workspace: WorkspaceInfo,
repo_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationResult {
pub success: bool,
pub checks: Vec<ValidationCheck>,
pub critical_errors: Vec<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationCheck {
pub name: String,
pub passed: bool,
pub message: String,
pub critical: bool,
pub duration_ms: u64,
}
impl WorkspaceValidator {
pub fn new(workspace: WorkspaceInfo) -> Result<Self> {
let repo_path = workspace.root.clone();
Ok(Self {
workspace,
repo_path,
})
}
pub async fn validate(&self) -> Result<ValidationResult> {
let mut checks = Vec::new();
let mut critical_errors = Vec::new();
let mut warnings = Vec::new();
self.validate_git_state(&mut checks, &mut critical_errors).await?;
self.validate_version_consistency(&mut checks, &mut critical_errors, &mut warnings).await?;
self.validate_builds(&mut checks, &mut critical_errors, &mut warnings).await?;
self.validate_credentials(&mut checks, &mut warnings).await?;
self.validate_dependencies(&mut checks, &mut critical_errors, &mut warnings).await?;
self.validate_crates_io_readiness(&mut checks, &mut warnings).await?;
let success = critical_errors.is_empty();
Ok(ValidationResult {
success,
checks,
critical_errors,
warnings,
})
}
async fn validate_git_state(
&self,
checks: &mut Vec<ValidationCheck>,
critical_errors: &mut Vec<String>,
) -> Result<()> {
let start_time = std::time::Instant::now();
let status_result = self.check_working_directory_clean().await;
let duration = start_time.elapsed().as_millis() as u64;
match status_result {
Ok(true) => {
checks.push(ValidationCheck {
name: "Git Working Directory".to_string(),
passed: true,
message: "Working directory is clean".to_string(),
critical: true,
duration_ms: duration,
});
}
Ok(false) => {
let error_msg = "Working directory has uncommitted changes";
checks.push(ValidationCheck {
name: "Git Working Directory".to_string(),
passed: false,
message: error_msg.to_string(),
critical: true,
duration_ms: duration,
});
critical_errors.push(error_msg.to_string());
}
Err(e) => {
let error_msg = format!("Failed to check git status: {}", e);
checks.push(ValidationCheck {
name: "Git Working Directory".to_string(),
passed: false,
message: error_msg.clone(),
critical: true,
duration_ms: duration,
});
critical_errors.push(error_msg);
}
}
let branch_check_start = std::time::Instant::now();
let branch_result = self.check_valid_branch().await;
let branch_duration = branch_check_start.elapsed().as_millis() as u64;
match branch_result {
Ok(branch_name) => {
checks.push(ValidationCheck {
name: "Git Branch".to_string(),
passed: true,
message: format!("On branch: {}", branch_name),
critical: false,
duration_ms: branch_duration,
});
}
Err(e) => {
let error_msg = format!("Invalid git branch state: {}", e);
checks.push(ValidationCheck {
name: "Git Branch".to_string(),
passed: false,
message: error_msg.clone(),
critical: true,
duration_ms: branch_duration,
});
critical_errors.push(error_msg);
}
}
Ok(())
}
async fn check_working_directory_clean(&self) -> Result<bool> {
let output = AsyncCommand::new("git")
.args(&["status", "--porcelain"])
.current_dir(&self.repo_path)
.output()
.await
.map_err(|e| GitError::OperationFailed {
operation: "git status".to_string(),
reason: e.to_string(),
})?;
if !output.status.success() {
return Err(GitError::OperationFailed {
operation: "git status".to_string(),
reason: String::from_utf8_lossy(&output.stderr).to_string(),
}.into());
}
Ok(output.stdout.is_empty())
}
async fn check_valid_branch(&self) -> Result<String> {
let output = AsyncCommand::new("git")
.args(&["branch", "--show-current"])
.current_dir(&self.repo_path)
.output()
.await
.map_err(|e| GitError::OperationFailed {
operation: "git branch".to_string(),
reason: e.to_string(),
})?;
if !output.status.success() {
return Err(GitError::OperationFailed {
operation: "git branch".to_string(),
reason: String::from_utf8_lossy(&output.stderr).to_string(),
}.into());
}
let branch_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if branch_name.is_empty() {
Ok("detached HEAD".to_string())
} else {
Ok(branch_name)
}
}
async fn validate_version_consistency(
&self,
checks: &mut Vec<ValidationCheck>,
critical_errors: &mut Vec<String>,
warnings: &mut Vec<String>,
) -> Result<()> {
let start_time = std::time::Instant::now();
let workspace_version = match self.workspace.workspace_version() {
Ok(version) => version,
Err(e) => {
let error_msg = format!("Failed to get workspace version: {}", e);
checks.push(ValidationCheck {
name: "Version Consistency".to_string(),
passed: false,
message: error_msg.clone(),
critical: true,
duration_ms: start_time.elapsed().as_millis() as u64,
});
critical_errors.push(error_msg);
return Ok(());
}
};
let mut version_mismatches = Vec::new();
let mut dependency_version_issues = Vec::new();
for (package_name, package_info) in &self.workspace.packages {
if package_info.version != workspace_version {
let mismatch = format!(
"Package '{}' version '{}' doesn't match workspace version '{}'",
package_name, package_info.version, workspace_version
);
version_mismatches.push(mismatch);
}
for dep_name in &package_info.workspace_dependencies {
if let Some(dep_package) = self.workspace.packages.get(dep_name) {
if let Some(dep_spec) = package_info.all_dependencies.get(dep_name) {
if let Some(dep_version) = &dep_spec.version {
if dep_version != &dep_package.version {
let issue = format!(
"Package '{}' depends on '{}' version '{}' but '{}' is at version '{}'",
package_name, dep_name, dep_version, dep_name, dep_package.version
);
dependency_version_issues.push(issue);
}
}
}
}
}
}
let duration = start_time.elapsed().as_millis() as u64;
if version_mismatches.is_empty() && dependency_version_issues.is_empty() {
checks.push(ValidationCheck {
name: "Version Consistency".to_string(),
passed: true,
message: format!("All packages consistent with workspace version {}", workspace_version),
critical: true,
duration_ms: duration,
});
} else {
let mut message_parts = Vec::new();
if !version_mismatches.is_empty() {
message_parts.extend(version_mismatches.iter().cloned());
critical_errors.extend(version_mismatches.clone());
}
if !dependency_version_issues.is_empty() {
message_parts.extend(dependency_version_issues.iter().cloned());
warnings.extend(dependency_version_issues);
}
checks.push(ValidationCheck {
name: "Version Consistency".to_string(),
passed: false,
message: message_parts.join("; "),
critical: !version_mismatches.is_empty(),
duration_ms: duration,
});
}
Ok(())
}
async fn validate_builds(
&self,
checks: &mut Vec<ValidationCheck>,
critical_errors: &mut Vec<String>,
warnings: &mut Vec<String>,
) -> Result<()> {
let start_time = std::time::Instant::now();
let mut build_failures = Vec::new();
let mut build_warnings = Vec::new();
for (package_name, package_info) in &self.workspace.packages {
match self.test_package_build(package_info).await {
Ok(BuildResult::Success) => {
}
Ok(BuildResult::Warning(warning)) => {
build_warnings.push(format!("{}: {}", package_name, warning));
}
Err(e) => {
build_failures.push(format!("{}: {}", package_name, e));
}
}
}
let duration = start_time.elapsed().as_millis() as u64;
if build_failures.is_empty() {
let message = if build_warnings.is_empty() {
"All packages build successfully".to_string()
} else {
format!("All packages build successfully ({} warnings)", build_warnings.len())
};
checks.push(ValidationCheck {
name: "Package Builds".to_string(),
passed: true,
message,
critical: true,
duration_ms: duration,
});
warnings.extend(build_warnings);
} else {
checks.push(ValidationCheck {
name: "Package Builds".to_string(),
passed: false,
message: format!("{} packages failed to build", build_failures.len()),
critical: true,
duration_ms: duration,
});
critical_errors.extend(build_failures);
warnings.extend(build_warnings);
}
Ok(())
}
async fn test_package_build(&self, package_info: &crate::workspace::PackageInfo) -> Result<BuildResult> {
let mut cmd = AsyncCommand::new("cargo");
cmd.arg("check")
.arg("--manifest-path")
.arg(&package_info.cargo_toml_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let output = cmd.output().await
.map_err(|e| PublishError::PublishFailed {
package: package_info.name.clone(),
reason: format!("Failed to execute cargo check: {}", e),
})?;
if output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("warning:") {
Ok(BuildResult::Warning(stderr.to_string()))
} else {
Ok(BuildResult::Success)
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(PublishError::DryRunFailed {
package: package_info.name.clone(),
reason: stderr.to_string(),
}.into())
}
}
async fn validate_credentials(
&self,
checks: &mut Vec<ValidationCheck>,
warnings: &mut Vec<String>,
) -> Result<()> {
let start_time = std::time::Instant::now();
match self.check_cargo_login().await {
Ok(true) => {
checks.push(ValidationCheck {
name: "Crates.io Authentication".to_string(),
passed: true,
message: "Successfully authenticated with crates.io".to_string(),
critical: false,
duration_ms: start_time.elapsed().as_millis() as u64,
});
}
Ok(false) => {
let warning_msg = "Not authenticated with crates.io - run 'cargo login' first";
checks.push(ValidationCheck {
name: "Crates.io Authentication".to_string(),
passed: false,
message: warning_msg.to_string(),
critical: false,
duration_ms: start_time.elapsed().as_millis() as u64,
});
warnings.push(warning_msg.to_string());
}
Err(e) => {
let warning_msg = format!("Could not verify crates.io authentication: {}", e);
checks.push(ValidationCheck {
name: "Crates.io Authentication".to_string(),
passed: false,
message: warning_msg.clone(),
critical: false,
duration_ms: start_time.elapsed().as_millis() as u64,
});
warnings.push(warning_msg);
}
}
Ok(())
}
async fn check_cargo_login(&self) -> Result<bool> {
let mut cmd = AsyncCommand::new("cargo");
cmd.arg("login")
.arg("--help")
.stdout(Stdio::null())
.stderr(Stdio::null());
let mut whoami_cmd = AsyncCommand::new("cargo");
whoami_cmd.arg("owner")
.arg("--list")
.arg("nonexistent-crate-name-12345")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let output = whoami_cmd.output().await
.map_err(|_e| PublishError::AuthenticationError)?;
let stderr = String::from_utf8_lossy(&output.stderr);
Ok(!stderr.contains("authentication") && !stderr.contains("token"))
}
async fn validate_dependencies(
&self,
checks: &mut Vec<ValidationCheck>,
critical_errors: &mut Vec<String>,
warnings: &mut Vec<String>,
) -> Result<()> {
let start_time = std::time::Instant::now();
let mut missing_dependencies = Vec::new();
let mut version_conflicts = Vec::new();
for (package_name, package_info) in &self.workspace.packages {
for dep_name in &package_info.workspace_dependencies {
if !self.workspace.packages.contains_key(dep_name) {
missing_dependencies.push(format!(
"Package '{}' depends on missing workspace package '{}'",
package_name, dep_name
));
}
if let Some(dep_spec) = package_info.all_dependencies.get(dep_name) {
if dep_spec.version.is_none() && dep_spec.path.is_some() {
version_conflicts.push(format!(
"Package '{}' dependency on '{}' lacks version (needed for crates.io)",
package_name, dep_name
));
}
}
}
}
let duration = start_time.elapsed().as_millis() as u64;
if missing_dependencies.is_empty() && version_conflicts.is_empty() {
checks.push(ValidationCheck {
name: "Workspace Dependencies".to_string(),
passed: true,
message: "All workspace dependencies are properly configured".to_string(),
critical: true,
duration_ms: duration,
});
} else {
let mut all_issues = Vec::new();
all_issues.extend(missing_dependencies.iter().cloned());
all_issues.extend(version_conflicts.iter().cloned());
checks.push(ValidationCheck {
name: "Workspace Dependencies".to_string(),
passed: false,
message: format!("{} dependency issues found", all_issues.len()),
critical: !missing_dependencies.is_empty(),
duration_ms: duration,
});
if !missing_dependencies.is_empty() {
critical_errors.extend(missing_dependencies);
}
if !version_conflicts.is_empty() {
warnings.extend(version_conflicts);
}
}
Ok(())
}
async fn validate_crates_io_readiness(
&self,
checks: &mut Vec<ValidationCheck>,
warnings: &mut Vec<String>,
) -> Result<()> {
let start_time = std::time::Instant::now();
let mut readiness_issues = Vec::new();
for (package_name, package_info) in &self.workspace.packages {
if package_info.config.description.is_none() {
readiness_issues.push(format!(
"Package '{}' missing description (recommended for crates.io)",
package_name
));
}
if package_info.config.license.is_none() {
readiness_issues.push(format!(
"Package '{}' missing license (required for crates.io)",
package_name
));
}
if package_info.config.homepage.is_none() && package_info.config.repository.is_none() {
readiness_issues.push(format!(
"Package '{}' missing homepage or repository (recommended for crates.io)",
package_name
));
}
}
let duration = start_time.elapsed().as_millis() as u64;
if readiness_issues.is_empty() {
checks.push(ValidationCheck {
name: "Crates.io Readiness".to_string(),
passed: true,
message: "All packages ready for crates.io publishing".to_string(),
critical: false,
duration_ms: duration,
});
} else {
checks.push(ValidationCheck {
name: "Crates.io Readiness".to_string(),
passed: false,
message: format!("{} metadata issues found", readiness_issues.len()),
critical: false,
duration_ms: duration,
});
warnings.extend(readiness_issues);
}
Ok(())
}
}
#[derive(Debug)]
enum BuildResult {
Success,
Warning(String),
}
impl ValidationResult {
pub fn failed_checks(&self) -> Vec<&ValidationCheck> {
self.checks.iter().filter(|check| !check.passed).collect()
}
pub fn critical_failed_checks(&self) -> Vec<&ValidationCheck> {
self.checks
.iter()
.filter(|check| !check.passed && check.critical)
.collect()
}
pub fn total_duration_ms(&self) -> u64 {
self.checks.iter().map(|check| check.duration_ms).sum()
}
pub fn passed_with_warnings(&self) -> bool {
self.success && !self.warnings.is_empty()
}
pub fn summary(&self) -> String {
let total_checks = self.checks.len();
let passed_checks = self.checks.iter().filter(|c| c.passed).count();
let critical_failures = self.critical_failed_checks().len();
if self.success {
if self.warnings.is_empty() {
format!("✅ All {} checks passed", total_checks)
} else {
format!(
"✅ {}/{} checks passed ({} warnings)",
passed_checks, total_checks, self.warnings.len()
)
}
} else {
format!(
"❌ {}/{} checks passed ({} critical failures)",
passed_checks, total_checks, critical_failures
)
}
}
}
impl ValidationCheck {
pub fn is_failure(&self) -> bool {
!self.passed
}
pub fn is_critical_failure(&self) -> bool {
!self.passed && self.critical
}
pub fn format_result(&self) -> String {
let status = if self.passed { "✅" } else { "❌" };
let criticality = if self.critical { " [CRITICAL]" } else { "" };
format!(
"{} {} ({}ms){}: {}",
status, self.name, self.duration_ms, criticality, self.message
)
}
}