use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::path::PathBuf;
use anyhow::Context;
use log::info;
use crate::model::changeset::{ChangeType, Changeset};
use crate::model::config::DependencyBump;
use crate::package_manager::Project;
use super::version::effective_new_version;
use super::{PropagationMap, PropagationResult};
pub(super) fn build_reverse_dep_graph(projects: &[Project]) -> BTreeMap<String, Vec<String>> {
let project_names: BTreeSet<String> = projects.iter().map(|p| p.name().to_string()).collect();
let mut reverse_deps: BTreeMap<String, Vec<String>> = BTreeMap::new();
for project in projects {
for dep_name in project.dependency_names() {
if project_names.contains(dep_name.as_str()) {
reverse_deps
.entry(dep_name.clone())
.or_default()
.push(project.name().to_string());
}
}
}
reverse_deps
}
pub(super) fn mark_propagation_bumps(
aggregated: &BTreeMap<String, ChangeType>,
version_overrides: &BTreeMap<String, semver::Version>,
reverse_deps: &BTreeMap<String, Vec<String>>,
dep_bump: DependencyBump,
) -> PropagationMap {
let mut queue: VecDeque<(String, ChangeType)> = aggregated
.iter()
.map(|(name, &ct)| (name.clone(), ct))
.collect();
let mut propagation_map: PropagationMap = BTreeMap::new();
while let Some((bumped_name, upstream_ct)) = queue.pop_front() {
let effective_ct = dep_bump.to_change_type(upstream_ct);
let Some(dependents) = reverse_deps.get(&bumped_name) else {
continue;
};
for dependent_name in dependents {
if version_overrides.contains_key(dependent_name.as_str()) {
continue; }
let current_ct = aggregated
.get(dependent_name.as_str())
.copied()
.or_else(|| {
propagation_map
.get(dependent_name.as_str())
.map(|(ct, _)| *ct)
});
if current_ct.is_some_and(|c| c >= effective_ct) {
continue; }
let entry = propagation_map
.entry(dependent_name.clone())
.or_insert_with(|| (effective_ct, BTreeSet::new()));
entry.0 = effective_ct;
entry.1.insert(bumped_name.clone());
queue.push_back((dependent_name.clone(), effective_ct));
}
}
propagation_map
}
pub(super) async fn write_out_of_scope_changeset(
pkg_name: &str,
effective_ct: ChangeType,
dep_msgs: &[String],
env: &crate::Env,
dry_run: bool,
) -> anyhow::Result<Option<PathBuf>> {
let message = format!("Dependency updates: {}", dep_msgs.join(", "));
let mut packages = BTreeMap::new();
packages.insert(pkg_name.to_string(), effective_ct);
let changeset = Changeset::new(packages, Some(message));
if dry_run {
info!(
"Would write dependency propagation changeset for \
out-of-scope package '{pkg_name}' ({effective_ct})"
);
return Ok(None);
}
let path = changeset
.write(env.git(), env.fs())
.await
.with_context(|| format!("Failed to write propagation changeset for '{pkg_name}'"))?;
info!(
"Wrote dependency propagation changeset for '{pkg_name}': {}",
path.display()
);
Ok(Some(path))
}
pub(super) async fn apply_dependency_propagation(
projects: &[Project],
aggregated: &mut BTreeMap<String, ChangeType>,
version_overrides: &BTreeMap<String, semver::Version>,
package_filter: &[String],
dep_bump: DependencyBump,
env: &crate::Env,
dry_run: bool,
) -> anyhow::Result<PropagationResult> {
let reverse_deps = build_reverse_dep_graph(projects);
let propagation_map =
mark_propagation_bumps(aggregated, version_overrides, &reverse_deps, dep_bump);
if propagation_map.is_empty() {
return Ok((BTreeMap::new(), Vec::new()));
}
let mut dep_entries: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut new_changeset_paths: Vec<PathBuf> = Vec::new();
for (pkg_name, (effective_ct, upstream_names)) in &propagation_map {
let dep_msgs: Vec<String> = upstream_names
.iter()
.map(|up| {
match effective_new_version(
up,
projects,
aggregated,
version_overrides,
&propagation_map,
) {
Some(v) => format!("`{up}` bumped to {v}"),
None => format!("`{up}` bumped"),
}
})
.collect();
if package_filter.is_empty() || package_filter.contains(pkg_name) {
let existing_ct = aggregated.get(pkg_name.as_str()).copied();
if existing_ct.is_none_or(|c| c < *effective_ct) {
aggregated.insert(pkg_name.clone(), *effective_ct);
dep_entries.insert(pkg_name.clone(), dep_msgs);
info!(
"{pkg_name}: dependency propagation bump ({effective_ct}) from {}",
upstream_names
.iter()
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
}
} else if let Some(path) =
write_out_of_scope_changeset(pkg_name, *effective_ct, &dep_msgs, env, dry_run).await?
{
new_changeset_paths.push(path);
}
}
Ok((dep_entries, new_changeset_paths))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_project(name: &str, version: &str) -> Project {
crate::package_manager::Project::new_test_with_version(name, version.parse().unwrap())
}
fn make_project_with_deps(name: &str, version: &str, deps: Vec<&str>) -> Project {
crate::package_manager::Project::new_test_with_deps(name, version, deps)
}
#[test]
fn propagation_change_type_patch_mode_always_returns_patch() {
for upstream in [ChangeType::Patch, ChangeType::Minor, ChangeType::Major] {
assert_eq!(
DependencyBump::Patch.to_change_type(upstream),
ChangeType::Patch,
);
}
}
#[test]
fn propagation_change_type_minor_mode_always_returns_minor() {
for upstream in [ChangeType::Patch, ChangeType::Minor, ChangeType::Major] {
assert_eq!(
DependencyBump::Minor.to_change_type(upstream),
ChangeType::Minor,
);
}
}
#[test]
fn propagation_change_type_major_mode_always_returns_major() {
for upstream in [ChangeType::Patch, ChangeType::Minor, ChangeType::Major] {
assert_eq!(
DependencyBump::Major.to_change_type(upstream),
ChangeType::Major,
);
}
}
#[test]
fn propagation_change_type_match_mode_mirrors_upstream() {
assert_eq!(
DependencyBump::Match.to_change_type(ChangeType::Patch),
ChangeType::Patch,
);
assert_eq!(
DependencyBump::Match.to_change_type(ChangeType::Minor),
ChangeType::Minor,
);
assert_eq!(
DependencyBump::Match.to_change_type(ChangeType::Major),
ChangeType::Major,
);
}
#[test]
fn propagation_change_type_auto_mode_maps_minor_and_patch_to_patch() {
assert_eq!(
DependencyBump::Auto.to_change_type(ChangeType::Patch),
ChangeType::Patch,
);
assert_eq!(
DependencyBump::Auto.to_change_type(ChangeType::Minor),
ChangeType::Patch,
);
}
#[test]
fn propagation_change_type_auto_mode_maps_major_to_major() {
assert_eq!(
DependencyBump::Auto.to_change_type(ChangeType::Major),
ChangeType::Major,
);
}
#[test]
fn build_reverse_dep_graph_empty_projects_returns_empty() {
let graph = build_reverse_dep_graph(&[]);
assert!(graph.is_empty());
}
#[test]
fn build_reverse_dep_graph_no_deps_returns_empty() {
let projects = vec![
make_project("pkg-a", "1.0.0"),
make_project("pkg-b", "1.0.0"),
];
let graph = build_reverse_dep_graph(&projects);
assert!(graph.is_empty());
}
#[test]
fn build_reverse_dep_graph_filters_external_deps() {
let projects = vec![
make_project_with_deps("pkg-a", "1.0.0", vec!["serde", "pkg-b"]),
make_project("pkg-b", "1.0.0"),
];
let graph = build_reverse_dep_graph(&projects);
assert_eq!(graph.len(), 1);
assert_eq!(graph["pkg-b"], vec!["pkg-a"]);
}
#[test]
fn build_reverse_dep_graph_multiple_dependents_on_same_package() {
let projects = vec![
make_project_with_deps("pkg-a", "1.0.0", vec!["pkg-c"]),
make_project_with_deps("pkg-b", "1.0.0", vec!["pkg-c"]),
make_project("pkg-c", "1.0.0"),
];
let graph = build_reverse_dep_graph(&projects);
let mut dependents = graph["pkg-c"].clone();
dependents.sort();
assert_eq!(dependents, vec!["pkg-a", "pkg-b"]);
}
#[test]
fn mark_propagation_bumps_empty_aggregated_returns_empty() {
let aggregated = BTreeMap::new();
let version_overrides = BTreeMap::new();
let reverse_deps = BTreeMap::new();
let result = mark_propagation_bumps(
&aggregated,
&version_overrides,
&reverse_deps,
DependencyBump::Auto,
);
assert!(result.is_empty());
}
#[test]
fn mark_propagation_bumps_skips_linked_packages() {
let mut aggregated = BTreeMap::new();
aggregated.insert("pkg-a".to_string(), ChangeType::Major);
let mut version_overrides = BTreeMap::new();
version_overrides.insert("pkg-b".to_string(), "2.0.0".parse().unwrap());
let mut reverse_deps: BTreeMap<String, Vec<String>> = BTreeMap::new();
reverse_deps.insert("pkg-a".to_string(), vec!["pkg-b".to_string()]);
let result = mark_propagation_bumps(
&aggregated,
&version_overrides,
&reverse_deps,
DependencyBump::Auto,
);
assert!(!result.contains_key("pkg-b"));
}
#[test]
fn mark_propagation_bumps_equal_change_type_does_not_propagate() {
let mut aggregated = BTreeMap::new();
aggregated.insert("pkg-a".to_string(), ChangeType::Minor);
aggregated.insert("pkg-b".to_string(), ChangeType::Minor);
let version_overrides = BTreeMap::new();
let mut reverse_deps: BTreeMap<String, Vec<String>> = BTreeMap::new();
reverse_deps.insert("pkg-a".to_string(), vec!["pkg-b".to_string()]);
let result = mark_propagation_bumps(
&aggregated,
&version_overrides,
&reverse_deps,
DependencyBump::Match, );
assert!(
!result.contains_key("pkg-b"),
"Equal ct should not create a propagation entry: {result:?}"
);
}
#[test]
fn mark_propagation_bumps_only_upgrades_not_downgrades() {
let mut aggregated = BTreeMap::new();
aggregated.insert("pkg-a".to_string(), ChangeType::Patch);
aggregated.insert("pkg-b".to_string(), ChangeType::Major);
let version_overrides = BTreeMap::new();
let mut reverse_deps: BTreeMap<String, Vec<String>> = BTreeMap::new();
reverse_deps.insert("pkg-a".to_string(), vec!["pkg-b".to_string()]);
let result = mark_propagation_bumps(
&aggregated,
&version_overrides,
&reverse_deps,
DependencyBump::Auto, );
assert!(!result.contains_key("pkg-b"));
}
#[test]
fn mark_propagation_bumps_diamond_graph_no_duplicate_upstreams() {
let mut aggregated = BTreeMap::new();
aggregated.insert("pkg-a".to_string(), ChangeType::Minor);
aggregated.insert("pkg-x".to_string(), ChangeType::Major);
let version_overrides = BTreeMap::new();
let mut reverse_deps: BTreeMap<String, Vec<String>> = BTreeMap::new();
reverse_deps.insert("pkg-a".to_string(), vec!["pkg-b".to_string()]);
reverse_deps.insert("pkg-x".to_string(), vec!["pkg-b".to_string()]);
reverse_deps.insert("pkg-b".to_string(), vec!["pkg-d".to_string()]);
let result = mark_propagation_bumps(
&aggregated,
&version_overrides,
&reverse_deps,
DependencyBump::Match,
);
assert!(result.contains_key("pkg-d"));
let (_, upstreams) = &result["pkg-d"];
assert_eq!(upstreams.len(), 1);
assert!(upstreams.contains("pkg-b"));
}
#[test]
fn mark_propagation_bumps_terminates_with_circular_deps() {
let mut aggregated = BTreeMap::new();
aggregated.insert("pkg-a".to_string(), ChangeType::Minor);
let version_overrides = BTreeMap::new();
let mut reverse_deps: BTreeMap<String, Vec<String>> = BTreeMap::new();
reverse_deps.insert("pkg-a".to_string(), vec!["pkg-b".to_string()]);
reverse_deps.insert("pkg-b".to_string(), vec!["pkg-a".to_string()]);
let result = mark_propagation_bumps(
&aggregated,
&version_overrides,
&reverse_deps,
DependencyBump::Auto,
);
assert!(result.contains_key("pkg-b"));
}
}