use crate::error::UpgradeError;
use crate::types::DependencyType;
use crate::upgrade::registry::{RegistryClient, UpgradeType};
use chrono::{DateTime, Utc};
use futures::stream::{self, StreamExt};
use package_json::PackageJson;
use semver::Version;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use sublime_standard_tools::filesystem::{AsyncFileSystem, FileSystemManager};
#[derive(Debug, Clone)]
pub struct DetectionOptions {
pub include_dependencies: bool,
pub include_dev_dependencies: bool,
pub include_peer_dependencies: bool,
pub include_optional_dependencies: bool,
pub package_filter: Option<Vec<String>>,
pub dependency_filter: Option<Vec<String>>,
pub include_prereleases: bool,
pub concurrency: usize,
}
impl Default for DetectionOptions {
fn default() -> Self {
Self {
include_dependencies: false,
include_dev_dependencies: false,
include_peer_dependencies: false,
include_optional_dependencies: false,
package_filter: None,
dependency_filter: None,
include_prereleases: false,
concurrency: 10,
}
}
}
impl DetectionOptions {
#[must_use]
pub fn all() -> Self {
Self {
include_dependencies: true,
include_dev_dependencies: true,
include_peer_dependencies: true,
include_optional_dependencies: true,
concurrency: 10,
..Default::default()
}
}
#[must_use]
pub fn production_only() -> Self {
Self { include_dependencies: true, concurrency: 10, ..Default::default() }
}
#[must_use]
pub fn dev_only() -> Self {
Self { include_dev_dependencies: true, concurrency: 10, ..Default::default() }
}
pub(crate) fn matches_package_filter(&self, package_name: &str) -> bool {
match &self.package_filter {
Some(filter) => filter.iter().any(|name| name == package_name),
None => true,
}
}
pub(crate) fn matches_dependency_filter(&self, dependency_name: &str) -> bool {
match &self.dependency_filter {
Some(filter) => filter.iter().any(|name| name == dependency_name),
None => true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpgradePreview {
pub detected_at: DateTime<Utc>,
pub packages: Vec<PackageUpgrades>,
pub summary: UpgradeSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageUpgrades {
pub package_name: String,
pub package_path: PathBuf,
pub current_version: Option<String>,
pub upgrades: Vec<DependencyUpgrade>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyUpgrade {
pub name: String,
pub current_version: String,
pub latest_version: String,
pub upgrade_type: UpgradeType,
pub dependency_type: DependencyType,
pub registry_url: String,
pub version_info: VersionInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionInfo {
pub available_versions: Vec<String>,
pub latest_stable: String,
pub latest_prerelease: Option<String>,
pub deprecated: Option<String>,
pub published_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpgradeSummary {
pub packages_scanned: usize,
pub total_dependencies: usize,
pub upgrades_available: usize,
pub major_upgrades: usize,
pub minor_upgrades: usize,
pub patch_upgrades: usize,
pub deprecated_dependencies: usize,
}
pub async fn detect_upgrades(
workspace_root: &Path,
registry_client: &RegistryClient,
fs: &FileSystemManager,
options: DetectionOptions,
) -> Result<UpgradePreview, UpgradeError> {
let detected_at = Utc::now();
let package_files = find_package_json_files(workspace_root, fs).await?;
let mut all_packages = Vec::new();
let mut total_dependencies = 0;
let mut upgrades_available = 0;
let mut major_upgrades = 0;
let mut minor_upgrades = 0;
let mut patch_upgrades = 0;
let mut deprecated_dependencies = 0;
for package_json_path in package_files {
let package_json = read_package_json(&package_json_path, fs).await?;
let package_name = if package_json.name.is_empty() {
"unnamed".to_string()
} else {
package_json.name.clone()
};
if !options.matches_package_filter(&package_name) {
continue;
}
let dependencies = extract_dependencies(&package_json, &options);
total_dependencies += dependencies.len();
let upgrades = detect_package_upgrades(&dependencies, registry_client, &options).await?;
upgrades_available += upgrades.len();
for upgrade in &upgrades {
match upgrade.upgrade_type {
UpgradeType::Major => major_upgrades += 1,
UpgradeType::Minor => minor_upgrades += 1,
UpgradeType::Patch => patch_upgrades += 1,
}
if upgrade.version_info.deprecated.is_some() {
deprecated_dependencies += 1;
}
}
if !upgrades.is_empty() {
let package_path = package_json_path
.parent()
.ok_or_else(|| UpgradeError::FileSystemError {
path: package_json_path.clone(),
reason: "Cannot determine parent directory of package.json".to_string(),
})?
.to_path_buf();
all_packages.push(PackageUpgrades {
package_name,
package_path,
current_version: if package_json.version.is_empty() {
None
} else {
Some(package_json.version.clone())
},
upgrades,
});
}
}
let summary = UpgradeSummary {
packages_scanned: all_packages.len(),
total_dependencies,
upgrades_available,
major_upgrades,
minor_upgrades,
patch_upgrades,
deprecated_dependencies,
};
Ok(UpgradePreview { detected_at, packages: all_packages, summary })
}
pub(crate) async fn find_package_json_files(
workspace_root: &Path,
fs: &FileSystemManager,
) -> Result<Vec<PathBuf>, UpgradeError> {
let mut package_files = Vec::new();
let root_package_json = workspace_root.join("package.json");
if fs.exists(&root_package_json).await {
package_files.push(root_package_json);
}
let common_patterns = vec!["packages/*/package.json", "apps/*/package.json"];
for pattern in common_patterns {
let _pattern_path = workspace_root.join(pattern);
}
if package_files.is_empty() {
return Err(UpgradeError::NoPackagesFound { workspace_root: workspace_root.to_path_buf() });
}
Ok(package_files)
}
pub(crate) async fn read_package_json(
path: &Path,
fs: &FileSystemManager,
) -> Result<PackageJson, UpgradeError> {
let content = fs.read_file_string(path).await.map_err(|e| UpgradeError::FileSystemError {
path: path.to_path_buf(),
reason: format!("Failed to read package.json: {}", e),
})?;
serde_json::from_str(&content).map_err(|e| UpgradeError::PackageJsonError {
path: path.to_path_buf(),
reason: format!("Failed to parse package.json: {}", e),
})
}
#[derive(Debug, Clone)]
pub(crate) struct DependencyToCheck {
pub(crate) name: String,
pub(crate) version_spec: String,
pub(crate) dependency_type: DependencyType,
}
pub(crate) fn extract_dependencies(
package_json: &PackageJson,
options: &DetectionOptions,
) -> Vec<DependencyToCheck> {
let mut dependencies = Vec::new();
if options.include_dependencies
&& let Some(deps) = &package_json.dependencies
{
for (name, version) in deps {
if !is_internal_dependency(version) && options.matches_dependency_filter(name) {
dependencies.push(DependencyToCheck {
name: name.clone(),
version_spec: version.clone(),
dependency_type: DependencyType::Regular,
});
}
}
}
if options.include_dev_dependencies
&& let Some(deps) = &package_json.dev_dependencies
{
for (name, version) in deps {
if !is_internal_dependency(version) && options.matches_dependency_filter(name) {
dependencies.push(DependencyToCheck {
name: name.clone(),
version_spec: version.clone(),
dependency_type: DependencyType::Dev,
});
}
}
}
if options.include_peer_dependencies
&& let Some(deps) = &package_json.peer_dependencies
{
for (name, version) in deps {
if !is_internal_dependency(version) && options.matches_dependency_filter(name) {
dependencies.push(DependencyToCheck {
name: name.clone(),
version_spec: version.clone(),
dependency_type: DependencyType::Peer,
});
}
}
}
if options.include_optional_dependencies
&& let Some(deps) = &package_json.optional_dependencies
{
for (name, version) in deps {
if !is_internal_dependency(version) && options.matches_dependency_filter(name) {
dependencies.push(DependencyToCheck {
name: name.clone(),
version_spec: version.clone(),
dependency_type: DependencyType::Optional,
});
}
}
}
dependencies
}
pub(crate) fn is_internal_dependency(version_spec: &str) -> bool {
version_spec.starts_with("workspace:")
|| version_spec.starts_with("file:")
|| version_spec.starts_with("link:")
|| version_spec.starts_with("portal:")
}
async fn detect_package_upgrades(
dependencies: &[DependencyToCheck],
registry_client: &RegistryClient,
options: &DetectionOptions,
) -> Result<Vec<DependencyUpgrade>, UpgradeError> {
let upgrades = stream::iter(dependencies)
.map(|dep| async move { detect_single_upgrade(dep, registry_client, options).await })
.buffer_unordered(options.concurrency)
.collect::<Vec<_>>()
.await;
let mut valid_upgrades = Vec::new();
for result in upgrades {
match result {
Ok(Some(upgrade)) => valid_upgrades.push(upgrade),
Ok(None) => {}
Err(e) => {
eprintln!("Warning: Failed to check upgrade: {}", e);
}
}
}
Ok(valid_upgrades)
}
async fn detect_single_upgrade(
dependency: &DependencyToCheck,
registry_client: &RegistryClient,
options: &DetectionOptions,
) -> Result<Option<DependencyUpgrade>, UpgradeError> {
let metadata = registry_client.get_package_info(&dependency.name).await?;
let current_version = extract_version_from_spec(&dependency.version_spec)?;
let latest_version = if options.include_prereleases {
find_latest_version(&metadata.versions)?
} else {
metadata.latest.clone()
};
let current = Version::parse(¤t_version).map_err(|e| UpgradeError::InvalidVersion {
version: current_version.clone(),
message: format!("Failed to parse current version: {}", e),
})?;
let latest = Version::parse(&latest_version).map_err(|e| UpgradeError::InvalidVersion {
version: latest_version.clone(),
message: format!("Failed to parse latest version: {}", e),
})?;
if current >= latest {
return Ok(None);
}
let upgrade_type =
registry_client.compare_versions(&dependency.name, ¤t_version, &latest_version)?;
let latest_prerelease = find_latest_prerelease(&metadata.versions);
let registry_url = registry_client.resolve_registry_url(&dependency.name);
let version_info = VersionInfo {
available_versions: metadata.versions.clone(),
latest_stable: metadata.latest.clone(),
latest_prerelease,
deprecated: metadata.deprecated.clone(),
published_at: metadata.version_published_at(&latest_version),
};
Ok(Some(DependencyUpgrade {
name: dependency.name.clone(),
current_version: dependency.version_spec.clone(),
latest_version,
upgrade_type,
dependency_type: dependency.dependency_type,
registry_url,
version_info,
}))
}
pub(crate) fn extract_version_from_spec(spec: &str) -> Result<String, UpgradeError> {
let trimmed = spec.trim();
let version = trimmed
.trim_start_matches('^')
.trim_start_matches('~')
.trim_start_matches(">=")
.trim_start_matches('>')
.trim_start_matches("<=")
.trim_start_matches('<')
.trim_start_matches('=');
if version.is_empty() {
return Err(UpgradeError::InvalidVersion {
version: spec.to_string(),
message: "Version spec is empty after removing prefixes".to_string(),
});
}
Ok(version.to_string())
}
pub(crate) fn find_latest_version(versions: &[String]) -> Result<String, UpgradeError> {
let mut parsed_versions: Vec<Version> = Vec::new();
for version_str in versions {
if let Ok(version) = Version::parse(version_str) {
parsed_versions.push(version);
}
}
if parsed_versions.is_empty() {
return Err(UpgradeError::InvalidVersion {
version: "none".to_string(),
message: "No valid versions found".to_string(),
});
}
parsed_versions.sort();
let latest = parsed_versions.last().ok_or_else(|| UpgradeError::InvalidVersion {
version: "none".to_string(),
message: "Failed to find latest version".to_string(),
})?;
Ok(latest.to_string())
}
pub(crate) fn find_latest_prerelease(versions: &[String]) -> Option<String> {
let mut prerelease_versions: Vec<Version> = Vec::new();
for version_str in versions {
if let Ok(version) = Version::parse(version_str)
&& !version.pre.is_empty()
{
prerelease_versions.push(version);
}
}
if prerelease_versions.is_empty() {
return None;
}
prerelease_versions.sort();
prerelease_versions.last().map(|v| v.to_string())
}