use crate::bump::{self, BumpRule};
use crate::changes::{ChangeId, PackageChanges};
use crate::detection;
use crate::manifest::{Manifest, ReleasedVersions};
use crate::package::Package;
use crate::workspace::{PackageGroups, Workspace};
use anyhow::{Context, Result};
use semver::Version;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reason {
Changed,
Dependency,
Fixed,
}
#[derive(Debug)]
pub struct PackagePlan<'a> {
pub package: &'a Package,
pub rule: BumpRule,
pub reason: Reason,
pub changes: PackageChanges,
pub manifests: Vec<(&'a Manifest, Version)>,
dependencies: Vec<&'a Package>,
}
impl PackagePlan<'_> {
pub fn next_version(&self) -> &Version {
&self.manifests[0].1
}
}
#[derive(Debug)]
pub struct Plan<'a> {
pub packages: Vec<PackagePlan<'a>>,
}
impl<'a> Plan<'a> {
pub fn is_empty(&self) -> bool {
self.packages.is_empty()
}
pub fn get(&self, package: &Package) -> Option<&PackagePlan<'a>> {
self
.packages
.iter()
.find(|planned| planned.package == package)
}
pub fn versions(&self) -> ReleasedVersions {
self
.packages
.iter()
.flat_map(|planned| &planned.manifests)
.map(|(manifest, version)| {
let key = (manifest.kind(), manifest.name().to_string());
(key, version.clone())
})
.collect()
}
pub fn released_dependencies(&self, planned: &PackagePlan) -> Vec<&PackagePlan<'a>> {
planned
.dependencies
.iter()
.filter_map(|dependency| self.get(dependency))
.collect()
}
pub fn dependency_updates(&self, planned: &PackagePlan) -> Vec<String> {
self
.released_dependencies(planned)
.into_iter()
.flat_map(|dependency| &dependency.manifests)
.map(|(manifest, version)| format!("update dependencies: {}@{version}", manifest.name()))
.collect()
}
}
impl fmt::Display for Plan<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for planned in &self.packages {
writeln!(
f,
"[{}] {} -> {} ({})",
planned.package.name(),
planned.package.version(),
planned.next_version(),
planned.rule.name()
)?;
for note in &planned.changes.notes {
writeln!(f, " * {}", note.title)?;
}
for commit in &planned.changes.commits {
writeln!(f, " - {} {}", &commit.sha[..7], commit.summary)?;
}
for update in self.dependency_updates(planned) {
writeln!(f, " - {update}")?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Decision {
pub skip: bool,
pub rule: Option<BumpRule>,
pub excluded: BTreeSet<ChangeId>,
}
#[derive(Debug, Clone, Default)]
pub struct Decisions<'a>(BTreeMap<&'a Package, Decision>);
impl<'a> Decisions<'a> {
pub fn get(&self, package: &Package) -> Option<&Decision> {
self.0.get(package)
}
pub fn edit(&mut self, package: &'a Package) -> &mut Decision {
self.0.entry(package).or_default()
}
}
pub struct Planner<'a> {
workspace: &'a Workspace,
detected: Vec<PackageChanges>,
fixed: PackageGroups<'a>,
linked: PackageGroups<'a>,
}
#[derive(Debug, Clone)]
struct Choice {
rule: BumpRule,
pinned: bool,
}
impl Choice {
fn applied_to(&self, version: &Version) -> BumpRule {
match self.pinned {
true => self.rule.clone(),
false => self.rule.clone().for_version(version),
}
}
}
impl<'a> Planner<'a> {
pub fn new(workspace: &'a Workspace) -> Result<Self> {
Ok(Self {
workspace,
detected: detection::detect_changes(workspace)?,
fixed: workspace.fixed_groups()?,
linked: workspace.linked_groups()?,
})
}
pub fn workspace(&self) -> &'a Workspace {
self.workspace
}
pub fn changes_of(&self, package: &Package) -> &PackageChanges {
&self.detected[self.index(package)]
}
pub fn fixed_with(&self, package: &'a Package) -> Vec<&'a Package> {
self
.fixed
.iter()
.find(|group| group.contains(package))
.map_or(vec![package], |group| group.iter().copied().collect())
}
pub fn plan(&self, decisions: &Decisions<'a>) -> Result<Plan<'a>> {
let mut changes = self
.workspace
.packages
.iter()
.zip(&self.detected)
.map(|(package, detected)| match decisions.get(package) {
Some(decision) => detected.without(&decision.excluded),
None => detected.clone(),
})
.collect::<Vec<_>>();
let selected = self.select(decisions);
let choices = self.choose_rules(&selected, &changes, decisions);
let fixed_versions = self.fixed_versions(&choices)?;
let packages = self
.workspace
.topo_sort(selected.iter().copied())
.into_iter()
.map(|package| {
let rule = choices[package].applied_to(package.version());
let manifests = package
.manifests()
.iter()
.map(|manifest| {
let version = match fixed_versions.get(package) {
Some(version) => version.clone(),
None => bump::bump(manifest.version(), &rule)?,
};
Ok((manifest, version))
})
.collect::<Result<_>>()?;
Ok(PackagePlan {
package,
rule,
reason: self.reason(package, &selected),
changes: std::mem::take(&mut changes[self.index(package)]),
manifests,
dependencies: self.workspace.dependencies_of(package),
})
})
.collect::<Result<_>>()?;
Ok(Plan { packages })
}
fn index(&self, package: &Package) -> usize {
self
.workspace
.index_of(package)
.expect("packages come from the workspace")
}
fn select(&self, decisions: &Decisions<'a>) -> BTreeSet<&'a Package> {
let packages = &self.workspace.packages;
let mut skipped = packages
.iter()
.filter(|package| decisions.get(package).is_some_and(|decision| decision.skip))
.collect::<BTreeSet<_>>();
for group in self.fixed.iter() {
if !group.is_disjoint(&skipped) {
skipped.extend(group);
}
}
let mut selected = packages
.iter()
.zip(&self.detected)
.filter(|(package, detected)| !detected.is_empty() && !skipped.contains(package))
.map(|(package, _)| package)
.collect::<BTreeSet<_>>();
loop {
let before = selected.len();
let dependents = selected
.iter()
.flat_map(|&package| self.workspace.dependents_of(package))
.collect::<Vec<_>>();
selected.extend(dependents);
for group in self.fixed.iter() {
if !group.is_disjoint(&selected) {
selected.extend(group);
}
}
selected.retain(|package| !skipped.contains(package));
if selected.len() == before {
return selected;
}
}
}
fn choose_rules(
&self,
selected: &BTreeSet<&'a Package>,
changes: &[PackageChanges],
decisions: &Decisions<'a>,
) -> BTreeMap<&'a Package, Choice> {
let mut choices = selected
.iter()
.map(|&package| {
let choice = match decisions
.get(package)
.and_then(|decision| decision.rule.clone())
{
Some(rule) => Choice { rule, pinned: true },
None => Choice {
rule: changes[self.index(package)]
.rule()
.unwrap_or(BumpRule::Patch),
pinned: false,
},
};
(package, choice)
})
.collect::<BTreeMap<_, _>>();
for group in self.fixed.iter() {
let pinned = group
.iter()
.filter_map(|package| choices.get(package))
.find(|choice| choice.pinned)
.cloned();
let shared = pinned.or_else(|| {
highest_rule(group, &choices).map(|rule| Choice {
rule,
pinned: false,
})
});
if let Some(shared) = shared {
for package in group {
if let Some(choice) = choices.get_mut(package) {
*choice = shared.clone();
}
}
}
}
for group in self.linked.iter() {
if let Some(rule) = highest_rule(group, &choices) {
for package in group {
if let Some(choice) = choices.get_mut(package)
&& !choice.pinned
{
choice.rule = rule.clone();
}
}
}
}
choices
}
fn fixed_versions(
&self,
choices: &BTreeMap<&'a Package, Choice>,
) -> Result<BTreeMap<&'a Package, Version>> {
let mut versions = BTreeMap::new();
for group in self.fixed.iter() {
let Some(choice) = group.iter().find_map(|package| choices.get(package)) else {
continue;
};
let highest = group
.iter()
.map(|package| package.version())
.max_by(|a, b| a.cmp_precedence(b))
.context("A package group must not be empty")?;
let next = bump::bump(highest, &choice.applied_to(highest))?;
for package in group {
versions.insert(*package, next.clone());
}
}
Ok(versions)
}
fn reason(&self, package: &Package, selected: &BTreeSet<&'a Package>) -> Reason {
if !self.changes_of(package).is_empty() {
Reason::Changed
} else if self
.workspace
.dependencies_of(package)
.iter()
.any(|dependency| selected.contains(dependency))
{
Reason::Dependency
} else {
Reason::Fixed
}
}
}
fn highest_rule(
group: &BTreeSet<&Package>,
choices: &BTreeMap<&Package, Choice>,
) -> Option<BumpRule> {
group
.iter()
.filter_map(|package| choices.get(package))
.map(|choice| choice.rule.clone())
.reduce(BumpRule::max)
}