use crate::error::{Result, VersionError};
use crate::version::{TomlEditor, TomlBackup};
use crate::workspace::WorkspaceInfo;
use semver::Version;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug)]
pub struct VersionUpdater {
workspace: WorkspaceInfo,
backups: Vec<TomlBackup>,
}
#[derive(Debug, Clone)]
pub struct UpdateResult {
pub previous_version: Version,
pub new_version: Version,
pub packages_updated: usize,
pub dependencies_updated: usize,
pub modified_files: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct UpdateConfig {
pub create_backups: bool,
pub update_internal_dependencies: bool,
pub preserve_workspace_inheritance: bool,
}
impl Default for UpdateConfig {
fn default() -> Self {
Self {
create_backups: true,
update_internal_dependencies: true,
preserve_workspace_inheritance: true,
}
}
}
impl VersionUpdater {
pub fn new(workspace: WorkspaceInfo) -> Self {
Self {
workspace,
backups: Vec::new(),
}
}
pub fn update_workspace_version(
&mut self,
new_version: &Version,
config: UpdateConfig,
) -> Result<UpdateResult> {
let current_version = self.workspace.workspace_version()
.and_then(|v| Version::parse(&v).map_err(|e| VersionError::ParseFailed {
version: v,
source: e,
}.into()))?;
if new_version <= ¤t_version {
return Err(VersionError::InvalidVersion {
version: new_version.to_string(),
reason: format!(
"New version '{}' must be greater than current version '{}'",
new_version, current_version
),
}.into());
}
let mut modified_files = Vec::new();
let mut packages_updated = 0;
let mut dependencies_updated = 0;
if let Err(e) = self.update_root_workspace_version(new_version, &config, &mut modified_files) {
self.rollback_all_changes()?;
return Err(e);
}
let packages_to_update: Vec<(String, crate::workspace::PackageInfo)> =
self.workspace.packages.iter()
.map(|(name, info)| (name.clone(), info.clone()))
.collect();
for (package_name, package_info) in packages_to_update {
match self.update_package_version(
&package_name,
&package_info,
new_version,
&config,
&mut modified_files,
&mut packages_updated,
&mut dependencies_updated,
) {
Ok(()) => {}
Err(e) => {
self.rollback_all_changes()?;
return Err(e);
}
}
}
Ok(UpdateResult {
previous_version: current_version,
new_version: new_version.clone(),
packages_updated,
dependencies_updated,
modified_files,
})
}
fn update_root_workspace_version(
&mut self,
new_version: &Version,
config: &UpdateConfig,
modified_files: &mut Vec<PathBuf>,
) -> Result<()> {
let workspace_cargo_toml = self.workspace.root.join("Cargo.toml");
let mut editor = TomlEditor::open(&workspace_cargo_toml)?;
if config.create_backups {
self.backups.push(editor.create_backup());
}
editor.update_workspace_version(new_version)?;
editor.save()?;
modified_files.push(workspace_cargo_toml);
Ok(())
}
fn update_package_version(
&mut self,
_package_name: &str,
package_info: &crate::workspace::PackageInfo,
new_version: &Version,
config: &UpdateConfig,
modified_files: &mut Vec<PathBuf>,
packages_updated: &mut usize,
dependencies_updated: &mut usize,
) -> Result<()> {
let mut editor = TomlEditor::open(&package_info.cargo_toml_path)?;
if config.create_backups {
self.backups.push(editor.create_backup());
}
let mut package_modified = false;
if !editor.uses_workspace_version() || !config.preserve_workspace_inheritance {
editor.update_package_version(new_version)?;
package_modified = true;
*packages_updated += 1;
}
if config.update_internal_dependencies {
let internal_deps_to_update = self.collect_internal_dependencies_to_update(package_info, new_version);
for (dep_name, dep_version) in internal_deps_to_update {
editor.update_dependency_version(&dep_name, &dep_version)?;
package_modified = true;
*dependencies_updated += 1;
}
}
if package_modified {
editor.save()?;
modified_files.push(package_info.cargo_toml_path.clone());
}
Ok(())
}
fn collect_internal_dependencies_to_update(
&self,
package_info: &crate::workspace::PackageInfo,
new_version: &Version,
) -> HashMap<String, Version> {
let mut updates = HashMap::new();
for dep_name in &package_info.workspace_dependencies {
if self.workspace.packages.contains_key(dep_name) {
updates.insert(dep_name.clone(), new_version.clone());
}
}
updates
}
pub fn rollback_all_changes(&self) -> Result<()> {
let mut rollback_errors = Vec::new();
for backup in self.backups.iter().rev() {
if let Err(e) = TomlEditor::restore_from_backup(backup) {
rollback_errors.push(format!("Failed to restore {}: {}", backup.file_path.display(), e));
}
}
if !rollback_errors.is_empty() {
return Err(VersionError::TomlUpdateFailed {
path: PathBuf::from("multiple_files"),
reason: format!("Rollback failures: {}", rollback_errors.join("; ")),
}.into());
}
Ok(())
}
pub fn clear_backups(&mut self) {
self.backups.clear();
}
pub fn validate_version_consistency(&self) -> Result<ConsistencyReport> {
let workspace_version = self.workspace.workspace_version()
.and_then(|v| Version::parse(&v).map_err(|e| VersionError::ParseFailed {
version: v,
source: e,
}.into()))?;
let mut inconsistencies = Vec::new();
let mut packages_checked = 0;
let mut dependencies_checked = 0;
for (package_name, package_info) in &self.workspace.packages {
if let Some(toml::Value::Boolean(false)) = package_info.config.other.get("publish") {
continue;
}
packages_checked += 1;
if let Ok(package_version) = Version::parse(&package_info.version) {
if package_version != workspace_version {
inconsistencies.push(VersionInconsistency {
package: package_name.clone(),
dependency: None,
expected_version: workspace_version.clone(),
actual_version: package_version,
inconsistency_type: InconsistencyType::PackageVersion,
});
}
}
for dep_name in &package_info.workspace_dependencies {
dependencies_checked += 1;
if let Some(dep_spec) = package_info.all_dependencies.get(dep_name) {
if let Some(dep_version_str) = &dep_spec.version {
if let Ok(dep_version) = Version::parse(dep_version_str) {
if dep_version != workspace_version {
inconsistencies.push(VersionInconsistency {
package: package_name.clone(),
dependency: Some(dep_name.clone()),
expected_version: workspace_version.clone(),
actual_version: dep_version,
inconsistency_type: InconsistencyType::DependencyVersion,
});
}
}
} else {
let dep_package = self.workspace.packages.get(dep_name);
let dep_is_publishable = dep_package
.and_then(|p| p.config.other.get("publish"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
if dep_is_publishable {
inconsistencies.push(VersionInconsistency {
package: package_name.clone(),
dependency: Some(dep_name.clone()),
expected_version: workspace_version.clone(),
actual_version: Version::new(0, 0, 0), inconsistency_type: InconsistencyType::MissingVersion,
});
}
}
}
}
}
Ok(ConsistencyReport {
workspace_version,
packages_checked,
dependencies_checked,
inconsistencies,
})
}
pub fn preview_update(&self, new_version: &Version) -> Result<UpdatePreview> {
let current_version = self.workspace.workspace_version()
.and_then(|v| Version::parse(&v).map_err(|e| VersionError::ParseFailed {
version: v,
source: e,
}.into()))?;
let mut files_to_modify = Vec::new();
let mut packages_to_update = Vec::new();
let mut dependencies_to_update = Vec::new();
files_to_modify.push(self.workspace.root.join("Cargo.toml"));
for (package_name, package_info) in &self.workspace.packages {
let editor = TomlEditor::open(&package_info.cargo_toml_path)?;
let mut package_changes = Vec::new();
if !editor.uses_workspace_version() {
package_changes.push(VersionChange {
field: "version".to_string(),
from: current_version.clone(),
to: new_version.clone(),
});
}
for dep_name in &package_info.workspace_dependencies {
if self.workspace.packages.contains_key(dep_name) {
package_changes.push(VersionChange {
field: format!("dependencies.{}", dep_name),
from: current_version.clone(),
to: new_version.clone(),
});
dependencies_to_update.push(DependencyUpdate {
package: package_name.clone(),
dependency: dep_name.clone(),
from: current_version.clone(),
to: new_version.clone(),
});
}
}
if !package_changes.is_empty() {
files_to_modify.push(package_info.cargo_toml_path.clone());
packages_to_update.push(PackageUpdate {
name: package_name.clone(),
file_path: package_info.cargo_toml_path.clone(),
changes: package_changes,
});
}
}
Ok(UpdatePreview {
from_version: current_version,
to_version: new_version.clone(),
files_to_modify,
packages_to_update,
dependencies_to_update,
})
}
pub fn workspace(&self) -> &WorkspaceInfo {
&self.workspace
}
pub fn backup_count(&self) -> usize {
self.backups.len()
}
}
#[derive(Debug, Clone)]
pub struct ConsistencyReport {
pub workspace_version: Version,
pub packages_checked: usize,
pub dependencies_checked: usize,
pub inconsistencies: Vec<VersionInconsistency>,
}
#[derive(Debug, Clone)]
pub struct VersionInconsistency {
pub package: String,
pub dependency: Option<String>,
pub expected_version: Version,
pub actual_version: Version,
pub inconsistency_type: InconsistencyType,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InconsistencyType {
PackageVersion,
DependencyVersion,
MissingVersion,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct UpdatePreview {
pub from_version: Version,
pub to_version: Version,
pub files_to_modify: Vec<PathBuf>,
pub packages_to_update: Vec<PackageUpdate>,
pub dependencies_to_update: Vec<DependencyUpdate>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PackageUpdate {
pub name: String,
pub file_path: PathBuf,
pub changes: Vec<VersionChange>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DependencyUpdate {
pub package: String,
pub dependency: String,
pub from: Version,
pub to: Version,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct VersionChange {
pub field: String,
pub from: Version,
pub to: Version,
}
impl ConsistencyReport {
pub fn is_consistent(&self) -> bool {
self.inconsistencies.is_empty()
}
pub fn inconsistencies_by_type(&self, inconsistency_type: InconsistencyType) -> Vec<&VersionInconsistency> {
self.inconsistencies
.iter()
.filter(|inc| inc.inconsistency_type == inconsistency_type)
.collect()
}
pub fn format_report(&self) -> String {
if self.is_consistent() {
format!(
"✅ Workspace version consistency: {} packages and {} dependencies are all at version {}",
self.packages_checked, self.dependencies_checked, self.workspace_version
)
} else {
let mut report = format!(
"❌ Found {} version inconsistencies (workspace version: {})\n",
self.inconsistencies.len(), self.workspace_version
);
for inconsistency in &self.inconsistencies {
let location = match &inconsistency.dependency {
Some(dep) => format!("{}::{}", inconsistency.package, dep),
None => inconsistency.package.clone(),
};
report.push_str(&format!(
" - {}: expected {}, found {}\n",
location, inconsistency.expected_version, inconsistency.actual_version
));
}
report
}
}
}
impl UpdatePreview {
pub fn total_changes(&self) -> usize {
self.packages_to_update.iter().map(|p| p.changes.len()).sum::<usize>()
+ self.dependencies_to_update.len()
}
pub fn format_preview(&self) -> String {
let mut preview = format!(
"Version update preview: {} → {}\n",
self.from_version, self.to_version
);
preview.push_str(&format!("Files to modify: {}\n", self.files_to_modify.len()));
preview.push_str(&format!("Packages to update: {}\n", self.packages_to_update.len()));
preview.push_str(&format!("Dependencies to update: {}\n", self.dependencies_to_update.len()));
preview.push_str(&format!("Total changes: {}\n", self.total_changes()));
if !self.packages_to_update.is_empty() {
preview.push_str("\nPackages:\n");
for package in &self.packages_to_update {
preview.push_str(&format!(" - {}\n", package.name));
}
}
preview
}
}