use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::path::Path;
use std::path::PathBuf;
use monochange_core::DependencySyncChange;
use monochange_core::DiscoveryReport;
use monochange_core::Ecosystem;
use monochange_core::MonochangeError;
use monochange_core::MonochangeResult;
use monochange_core::VersionStrategy;
use semver::Version;
use serde::Serialize;
use serde_json::Value as JsonValue;
use toml_edit::DocumentMut;
use toml_edit::Item;
use toml_edit::Value as TomlValue;
use crate::discover_workspace;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum VersionsOutputFormat {
Text,
Json,
}
pub type VersionInventory = BTreeMap<String, String>;
#[derive(Debug, Serialize)]
pub struct SyncResult {
pub applied: bool,
pub strategy: VersionStrategy,
pub changes: Vec<FileSyncResult>,
pub skipped: Vec<SkippedSyncPackage>,
}
#[derive(Debug, Serialize)]
pub struct VersionSyncPlan {
pub strategy: VersionStrategy,
pub files: Vec<VersionSyncPlanFile>,
pub skipped: Vec<SkippedSyncPackage>,
}
#[derive(Debug, Serialize)]
pub struct VersionSyncPlanFile {
pub path: String,
pub ecosystem: Ecosystem,
pub changes: Vec<DependencySyncChange>,
#[serde(skip)]
pub(crate) manifest_path: PathBuf,
#[serde(skip)]
pub(crate) contents: String,
}
#[derive(Debug, Serialize)]
pub struct FileSyncResult {
pub path: String,
pub ecosystem: Ecosystem,
pub changes: Vec<DependencySyncChange>,
}
#[derive(Clone, Debug, Serialize)]
pub struct SkippedSyncPackage {
pub path: String,
pub package_name: String,
pub ecosystem: Ecosystem,
pub reason: String,
}
type DetectVersionSyncChanges = fn(
&str,
&BTreeMap<String, String>,
&BTreeSet<String>,
VersionStrategy,
) -> MonochangeResult<Vec<DependencySyncChange>>;
type ApplyVersionSyncChanges = fn(&str, &[DependencySyncChange]) -> MonochangeResult<String>;
#[derive(Clone, Copy)]
struct VersionSyncAdapter {
ecosystem: Ecosystem,
detect_changes: DetectVersionSyncChanges,
apply_changes: ApplyVersionSyncChanges,
}
impl VersionSyncAdapter {
fn for_ecosystem(ecosystem: Ecosystem) -> Self {
match ecosystem {
Ecosystem::Cargo => {
Self {
ecosystem,
detect_changes: detect_cargo_changes,
apply_changes: apply_cargo_changes,
}
}
Ecosystem::Dart => {
Self {
ecosystem,
detect_changes: detect_dart_changes,
apply_changes: apply_dart_changes,
}
}
Ecosystem::Deno => {
Self {
ecosystem,
detect_changes: detect_deno_changes,
apply_changes: apply_deno_changes,
}
}
Ecosystem::Go => {
Self {
ecosystem,
detect_changes: detect_go_changes,
apply_changes: apply_go_changes,
}
}
Ecosystem::Npm => {
Self {
ecosystem,
detect_changes: detect_npm_changes,
apply_changes: apply_npm_changes,
}
}
Ecosystem::Python => {
Self {
ecosystem,
detect_changes: detect_python_changes,
apply_changes: apply_python_changes,
}
}
#[allow(unreachable_patterns)]
_ => panic!("unsupported ecosystem: {ecosystem:?}"),
}
}
}
pub fn sync_workspace_versions(
root: &Path,
strategy: VersionStrategy,
dry_run: bool,
) -> MonochangeResult<SyncResult> {
let plan = plan_workspace_versions(root, strategy)?;
apply_version_sync_plan(&plan, !dry_run)
}
pub fn plan_workspace_versions(
root: &Path,
strategy: VersionStrategy,
) -> MonochangeResult<VersionSyncPlan> {
let discovery = discover_workspace(root)?;
plan_discovered_workspace_versions(root, strategy, &discovery)
}
pub(crate) fn plan_discovered_workspace_versions(
root: &Path,
strategy: VersionStrategy,
discovery: &DiscoveryReport,
) -> MonochangeResult<VersionSyncPlan> {
let version_map = package_version_map(discovery);
let workspace_package_names = workspace_package_names(discovery);
let mut files = Vec::with_capacity(discovery.packages.len());
let skipped = Vec::new();
for package in &discovery.packages {
let adapter = VersionSyncAdapter::for_ecosystem(package.ecosystem);
if version_map.is_empty() {
continue;
}
let manifest_path = root.join(&package.manifest_path);
let contents = read_manifest(&manifest_path)?;
let changes =
(adapter.detect_changes)(&contents, &version_map, &workspace_package_names, strategy)
.map_err(|error| {
sync_context_error("detect", adapter.ecosystem, &package.manifest_path, &error)
})?;
if changes.is_empty() {
continue;
}
files.push(VersionSyncPlanFile {
path: package.manifest_path.to_string_lossy().to_string(),
ecosystem: package.ecosystem,
changes,
manifest_path,
contents,
});
}
Ok(VersionSyncPlan {
strategy,
files,
skipped,
})
}
pub fn apply_version_sync_plan(
plan: &VersionSyncPlan,
write_files: bool,
) -> MonochangeResult<SyncResult> {
let mut changes = Vec::with_capacity(plan.files.len());
for file in &plan.files {
if write_files {
let updated_contents =
apply_sync_changes(&file.contents, &file.changes, file.ecosystem).map_err(
|error| {
sync_context_error("apply", file.ecosystem, Path::new(&file.path), &error)
},
)?;
write_manifest(&file.manifest_path, updated_contents)?;
}
changes.push(FileSyncResult {
path: file.path.clone(),
ecosystem: file.ecosystem,
changes: file.changes.clone(),
});
}
Ok(SyncResult {
applied: write_files,
strategy: plan.strategy,
changes,
skipped: plan.skipped.clone(),
})
}
fn package_version_map(discovery: &DiscoveryReport) -> BTreeMap<String, String> {
discovery
.packages
.iter()
.filter_map(|package| {
package
.current_version
.as_ref()
.map(|version| (package.name.clone(), version.to_string()))
})
.collect()
}
fn workspace_package_names(discovery: &DiscoveryReport) -> BTreeSet<String> {
discovery
.packages
.iter()
.map(|package| package.name.clone())
.collect()
}
pub(crate) fn sync_context_error(
operation: &str,
ecosystem: Ecosystem,
path: &Path,
error: &MonochangeError,
) -> MonochangeError {
MonochangeError::Config(format!(
"failed to {operation} {:?} version sync changes for {}: {error}",
ecosystem,
path.display()
))
}
pub(crate) fn version_prefix_for_strategy(
ecosystem: Ecosystem,
strategy: VersionStrategy,
) -> &'static str {
if strategy == VersionStrategy::Exact {
return "";
}
if matches!(ecosystem, Ecosystem::Cargo | Ecosystem::Go) {
return if strategy == VersionStrategy::Compatible {
">="
} else {
""
};
}
match ecosystem {
Ecosystem::Dart => monochange_dart::default_dependency_version_prefix(),
Ecosystem::Deno => monochange_deno::default_dependency_version_prefix(),
Ecosystem::Npm => monochange_npm::default_dependency_version_prefix(),
Ecosystem::Python => monochange_python::default_dependency_version_prefix(),
Ecosystem::Cargo | Ecosystem::Go => "",
#[allow(unreachable_patterns)]
_ => panic!("unsupported ecosystem: {ecosystem:?}"),
}
}
pub(crate) fn target_constraint(
ecosystem: Ecosystem,
version: &str,
strategy: VersionStrategy,
) -> String {
let version = if ecosystem == Ecosystem::Go && !version.starts_with('v') {
format!("v{version}")
} else {
version.to_string()
};
format!(
"{}{}",
version_prefix_for_strategy(ecosystem, strategy),
version
)
}
fn push_change(
changes: &mut Vec<DependencySyncChange>,
dependency_name: &str,
section: &str,
old_value: &str,
new_value: String,
) {
if old_value != new_value {
changes.push(DependencySyncChange {
dependency_name: dependency_name.to_string(),
section: section.to_string(),
old_value: old_value.to_string(),
new_value,
});
}
}
fn parse_python_dependency_name(spec: &str) -> Option<String> {
let name = spec
.chars()
.take_while(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
.collect::<String>();
(!name.is_empty()).then_some(name)
}
pub(crate) fn normalize_python_package_name(name: &str) -> String {
let mut result = String::with_capacity(name.len());
let mut prev_was_separator = false;
for ch in name.chars() {
if matches!(ch, '-' | '_' | '.') {
if !prev_was_separator {
result.push('-');
}
prev_was_separator = true;
} else {
result.push(ch.to_ascii_lowercase());
prev_was_separator = false;
}
}
result
}
pub(crate) fn extract_python_version_constraint(spec: &str, name: &str) -> Option<String> {
let mut rest = spec.get(name.len()..)?.trim_start();
if rest.starts_with('[') {
let extras_end = rest.find(']')? + 1;
rest = rest.get(extras_end..)?.trim_start();
}
let constraint = rest.split(';').next().unwrap_or_default().trim();
(!constraint.is_empty()).then(|| constraint.to_string())
}
fn detect_toml_dependency_changes(
contents: &str,
version_map: &BTreeMap<String, String>,
workspace_package_names: &BTreeSet<String>,
strategy: VersionStrategy,
fields: &[&str],
ecosystem: Ecosystem,
) -> MonochangeResult<Vec<DependencySyncChange>> {
let document = contents.parse::<DocumentMut>().map_err(|error| {
MonochangeError::Config(format!("failed to parse toml for sync: {error}"))
})?;
let mut changes = Vec::new();
for field in fields {
let Some(table) = document.get(field).and_then(Item::as_table_like) else {
continue;
};
for dep_name in workspace_package_names {
let Some(item) = table.get(dep_name) else {
continue;
};
let Some(version) = version_map.get(dep_name) else {
continue;
};
let new_value = target_constraint(ecosystem, version, strategy);
if let Some(old_value) = item.as_str() {
push_change(&mut changes, dep_name, field, old_value, new_value);
continue;
}
let Some(old_value) = item
.as_inline_table()
.and_then(|table| table.get("version"))
.and_then(TomlValue::as_str)
else {
continue;
};
push_change(&mut changes, dep_name, field, old_value, new_value);
}
}
Ok(changes)
}
pub(crate) fn detect_cargo_changes(
contents: &str,
version_map: &BTreeMap<String, String>,
workspace_package_names: &BTreeSet<String>,
strategy: VersionStrategy,
) -> MonochangeResult<Vec<DependencySyncChange>> {
detect_toml_dependency_changes(
contents,
version_map,
workspace_package_names,
strategy,
monochange_cargo::default_dependency_fields(),
Ecosystem::Cargo,
)
}
pub(crate) fn detect_python_changes(
contents: &str,
version_map: &BTreeMap<String, String>,
workspace_package_names: &BTreeSet<String>,
strategy: VersionStrategy,
) -> MonochangeResult<Vec<DependencySyncChange>> {
let document = contents.parse::<DocumentMut>().map_err(|error| {
MonochangeError::Config(format!("failed to parse pyproject.toml for sync: {error}"))
})?;
let mut changes = Vec::new();
let Some(project) = document.get("project").and_then(Item::as_table_like) else {
return Ok(changes);
};
let Some(deps) = project.get("dependencies").and_then(Item::as_array) else {
return Ok(changes);
};
for dep in deps.iter().filter_map(TomlValue::as_str) {
let Some(dep_name) = parse_python_dependency_name(dep) else {
continue;
};
let normalized_name = normalize_python_package_name(&dep_name);
let Some(workspace_name) = workspace_package_names
.iter()
.find(|name| normalize_python_package_name(name) == normalized_name)
else {
continue;
};
let Some(version) = version_map.get(workspace_name) else {
continue;
};
let new_value = target_constraint(Ecosystem::Python, version, strategy);
let old_value = extract_python_version_constraint(dep, &dep_name).unwrap_or_default();
push_change(
&mut changes,
workspace_name,
"dependencies",
&old_value,
new_value,
);
}
Ok(changes)
}
pub(crate) fn detect_go_changes(
contents: &str,
version_map: &BTreeMap<String, String>,
workspace_package_names: &BTreeSet<String>,
strategy: VersionStrategy,
) -> MonochangeResult<Vec<DependencySyncChange>> {
let contents = std::str::from_utf8(contents.as_bytes())
.map_err(|error| MonochangeError::Config(format!("failed to read go.mod text: {error}")))?;
let mut changes = Vec::new();
for line in contents.lines() {
let trimmed = line.trim();
let parts = trimmed.split_whitespace().collect::<Vec<_>>();
let (Some(dep_name), Some(old_value)) = (match parts.as_slice() {
["require", name, version] | [name, version] | [name, version, "//", "indirect"] => {
(Some(*name), Some(*version))
}
_ => (None, None),
}) else {
continue;
};
if !workspace_package_names.contains(dep_name) {
continue;
}
let Some(version) = version_map.get(dep_name) else {
continue;
};
push_change(
&mut changes,
dep_name,
"require",
old_value,
target_constraint(Ecosystem::Go, version, strategy),
);
}
Ok(changes)
}
pub(crate) fn detect_deno_changes(
contents: &str,
version_map: &BTreeMap<String, String>,
workspace_package_names: &BTreeSet<String>,
strategy: VersionStrategy,
) -> MonochangeResult<Vec<DependencySyncChange>> {
let normalized = monochange_core::strip_json_comments(contents);
let json: JsonValue = serde_json::from_str(&normalized).map_err(|error| {
MonochangeError::Config(format!("failed to parse deno json for sync: {error}"))
})?;
let mut changes = Vec::new();
for section in ["imports", "dependencies"] {
let Some(deps) = json.get(section).and_then(JsonValue::as_object) else {
continue;
};
for dep_name in workspace_package_names {
let Some(old_value) = deps.get(dep_name).and_then(JsonValue::as_str) else {
continue;
};
let Some(version) = version_map.get(dep_name) else {
continue;
};
push_change(
&mut changes,
dep_name,
section,
old_value,
target_constraint(Ecosystem::Deno, version, strategy),
);
}
}
Ok(changes)
}
pub(crate) fn detect_dart_changes(
contents: &str,
version_map: &BTreeMap<String, String>,
workspace_package_names: &BTreeSet<String>,
strategy: VersionStrategy,
) -> MonochangeResult<Vec<DependencySyncChange>> {
monochange_dart::sync_internal_dependency_versions(
contents,
version_map,
workspace_package_names,
strategy,
)
}
fn detect_npm_changes(
contents: &str,
version_map: &BTreeMap<String, String>,
workspace_package_names: &BTreeSet<String>,
strategy: VersionStrategy,
) -> MonochangeResult<Vec<DependencySyncChange>> {
monochange_npm::sync_internal_dependency_versions(
contents,
version_map,
workspace_package_names,
strategy,
)
}
fn apply_cargo_changes(
contents: &str,
changes: &[DependencySyncChange],
) -> MonochangeResult<String> {
let fields = monochange_cargo::default_dependency_fields();
let versioned_deps = versioned_deps_from_changes(changes);
monochange_cargo::update_versioned_file_text(
contents,
monochange_cargo::CargoVersionedFileKind::Manifest,
fields,
None,
None,
&versioned_deps,
&BTreeMap::new(),
)
.map_err(|error| MonochangeError::Config(format!("failed to update Cargo.toml: {error}")))
}
fn apply_deno_changes(
contents: &str,
changes: &[DependencySyncChange],
) -> MonochangeResult<String> {
let fields = &["imports", "dependencies"];
let versioned_deps = versioned_deps_from_changes(changes);
let normalized = monochange_core::strip_json_comments(contents);
monochange_core::update_json_manifest_text(&normalized, None, fields, &versioned_deps)
}
fn apply_go_changes(contents: &str, changes: &[DependencySyncChange]) -> MonochangeResult<String> {
let contents = std::str::from_utf8(contents.as_bytes())
.map_err(|error| MonochangeError::Config(format!("failed to read go.mod text: {error}")))?;
let mut versioned_deps = versioned_deps_from_changes(changes);
for change in changes {
if let Some(module_name) = change.dependency_name.rsplit('/').next() {
versioned_deps.insert(module_name.to_string(), change.new_value.clone());
}
}
Ok(monochange_go::update_go_mod_text(contents, &versioned_deps))
}
fn apply_python_changes(
contents: &str,
changes: &[DependencySyncChange],
) -> MonochangeResult<String> {
monochange_python::update_versioned_file_text(
contents,
monochange_python::PythonVersionedFileKind::Manifest,
None,
&versioned_deps_from_changes(changes),
)
.map_err(|error| MonochangeError::Config(format!("failed to update pyproject.toml: {error}")))
}
fn apply_dart_changes(
contents: &str,
changes: &[DependencySyncChange],
) -> MonochangeResult<String> {
let fields = monochange_dart::default_dependency_fields();
let versioned_deps = versioned_deps_from_changes(changes);
monochange_dart::update_manifest_text(contents, None, fields, &versioned_deps)
}
fn apply_npm_changes(contents: &str, changes: &[DependencySyncChange]) -> MonochangeResult<String> {
let fields = monochange_npm::default_dependency_fields();
let versioned_deps = versioned_deps_from_changes(changes);
monochange_core::update_json_manifest_text(contents, None, fields, &versioned_deps)
}
fn versioned_deps_from_changes(changes: &[DependencySyncChange]) -> BTreeMap<String, String> {
changes
.iter()
.map(|change| (change.dependency_name.clone(), change.new_value.clone()))
.collect()
}
pub(crate) fn read_manifest(path: &Path) -> MonochangeResult<String> {
std::fs::read_to_string(path)
.map_err(|error| MonochangeError::Io(format!("failed to read {}: {error}", path.display())))
}
pub(crate) fn write_manifest(path: &Path, contents: String) -> MonochangeResult<()> {
std::fs::write(path, contents).map_err(|error| {
MonochangeError::Io(format!("failed to write {}: {error}", path.display()))
})
}
pub fn apply_sync_changes(
contents: &str,
changes: &[DependencySyncChange],
ecosystem: Ecosystem,
) -> MonochangeResult<String> {
let adapter = VersionSyncAdapter::for_ecosystem(ecosystem);
(adapter.apply_changes)(contents, changes)
}
pub(crate) fn format_sync_result(result: &SyncResult, dry_run: bool, _quiet: bool) -> String {
if result.changes.is_empty() && result.skipped.is_empty() {
return String::new();
}
let mut output = String::new();
let action = if dry_run { "would update" } else { "updated" };
for file_result in &result.changes {
for change in &file_result.changes {
let _ = writeln!(
output,
"{action} {} → {} in {} ({})",
change.old_value, change.new_value, change.dependency_name, file_result.path
);
}
}
if !result.skipped.is_empty() {
if !output.is_empty() {
output.push('\n');
}
let _ = writeln!(output, "Skipped unsupported ecosystems:");
for skipped in &result.skipped {
let _ = writeln!(
output,
"- {} ({:?}): {}",
skipped.path, skipped.ecosystem, skipped.reason
);
}
}
let _ = writeln!(
output,
"\nStrategy: {} (package config → ecosystem config → ecosystem default; --strategy overrides)",
strategy_name(result.strategy)
);
if dry_run {
output.push_str("\n(dry run — no files were modified)\n");
}
output
}
pub(crate) fn format_sync_result_json(result: &SyncResult) -> String {
let mut output = serde_json::to_string_pretty(result)
.unwrap_or_else(|error| panic!("serialize versions result: {error}"));
output.push('\n');
output
}
pub(crate) fn format_sync_result_for_cli(
result: &SyncResult,
dry_run: bool,
quiet: bool,
format: VersionsOutputFormat,
) -> String {
match format {
VersionsOutputFormat::Text => format_sync_result(result, dry_run, quiet),
VersionsOutputFormat::Json => format_sync_result_json(result),
}
}
pub fn list_workspace_versions(root: &Path) -> MonochangeResult<VersionInventory> {
let discovery = discover_workspace(root)?;
Ok(version_inventory(&discovery))
}
pub(crate) fn version_inventory(discovery: &DiscoveryReport) -> VersionInventory {
let package_versions_by_id = discovery
.packages
.iter()
.filter_map(|package| {
package
.current_version
.as_ref()
.map(|version| (package.id.clone(), version.clone()))
})
.collect::<BTreeMap<_, _>>();
let mut inventory = discovery
.packages
.iter()
.filter_map(|package| {
package.current_version.as_ref().map(|version| {
let key = package
.metadata
.get("config_id")
.cloned()
.unwrap_or_else(|| package.id.clone());
(key, version.to_string())
})
})
.collect::<VersionInventory>();
for group in &discovery.version_groups {
if let Some(version) = group_current_version(group.members.iter(), &package_versions_by_id)
{
inventory.insert(group.group_id.clone(), version.to_string());
}
}
inventory
}
fn group_current_version<'a>(
members: impl IntoIterator<Item = &'a String>,
package_versions_by_id: &BTreeMap<String, Version>,
) -> Option<Version> {
members
.into_iter()
.filter_map(|member| package_versions_by_id.get(member))
.max()
.cloned()
}
pub(crate) fn format_version_inventory_for_cli(
inventory: &VersionInventory,
format: VersionsOutputFormat,
) -> String {
match format {
VersionsOutputFormat::Json => {
serde_json::to_string_pretty(inventory)
.unwrap_or_else(|error| panic!("serialize version inventory: {error}"))
}
VersionsOutputFormat::Text => {
inventory
.iter()
.map(|(id, version)| format!("{id}: {version}"))
.collect::<Vec<_>>()
.join("\n")
}
}
}
pub(crate) fn parse_strategy(strategy_str: &str) -> VersionStrategy {
match strategy_str {
"exact" => VersionStrategy::Exact,
"caret" => VersionStrategy::Caret,
"compatible" => VersionStrategy::Compatible,
_ => VersionStrategy::Default,
}
}
pub(crate) fn parse_versions_output_format(format_str: &str) -> VersionsOutputFormat {
match format_str {
"json" => VersionsOutputFormat::Json,
_ => VersionsOutputFormat::Text,
}
}
pub(crate) fn strategy_name(strategy: VersionStrategy) -> &'static str {
match strategy {
VersionStrategy::Default => "default",
VersionStrategy::Exact => "exact",
VersionStrategy::Caret => "caret",
VersionStrategy::Compatible => "compatible",
}
}