use crate::adapters::{
CargoAdapter, FormatAdapter, GoAdapter, JsonFormat, MsbuildAdapter, NpmAdapter, PubAdapter,
PythonAdapter, TomlFormat, YamlFormat,
};
use crate::config::{Config, Projection, ReleaseUnitConfig};
use crate::error::{Error, Result};
use crate::intent::Intent;
use crate::model::{Adapter, ProjectionMode};
use crate::ownership::check_physical_ownership;
use crate::plan::{PlanReleaseUnit, ReleasePlan};
use crate::projection_file::normalized;
use crate::workspace_files::{contained, write_contained, EditTree, SharedVersions};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileWrite {
pub path: PathBuf,
pub contents: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApplyResult {
pub writes: Vec<FileWrite>,
pub deletes: Vec<PathBuf>,
pub notices: Vec<String>,
pub plan: ReleasePlan,
}
impl ApplyResult {
pub fn build(root: &Path, channel: Option<&str>) -> Result<Self> {
let config = Config::load(root)?;
check_physical_ownership(root, &config)?;
let intents = Intent::load_all(root, &config)?;
let plan = ReleasePlan::from_inputs(root, &config, &intents, channel)?;
let mut tree = EditTree::new(root);
let by_id: BTreeMap<_, _> = plan
.release_units
.iter()
.map(|release_unit| (release_unit.id.as_str(), release_unit))
.collect();
let mut workspace_versions = SharedVersions::default();
let mut notices = Vec::new();
for release_unit in &plan.release_units {
let config_release_unit = &config.release_units[&release_unit.id];
for projection in &config_release_unit.projections {
let go_major = projection.adapter == Adapter::Go
&& release_unit.bump == crate::model::Bump::Major;
if projection.mode != ProjectionMode::Committed && !go_major {
continue;
}
edit_projection_version(
&mut tree,
root,
config_release_unit,
projection,
&release_unit.new_version,
&mut workspace_versions,
)?;
if go_major {
notices.push(format!(
"rewrite Go module major path {}",
projection
.file
.workspace_path(&config_release_unit.path)
.display()
));
}
}
let changelog = config_release_unit.path.join("CHANGELOG.md");
let current = tree.read_optional(&changelog)?;
let next = update_changelog(
current.as_deref(),
&release_unit.release_notes,
&release_unit.new_version,
channel.is_none(),
);
tree.set(&changelog, next)?;
}
rewrite_internal_dependencies(&mut tree, root, &config, &by_id)?;
let deletes = if channel.is_none() {
intents
.iter()
.map(|intent| {
intent
.path
.strip_prefix(root)
.unwrap_or(&intent.path)
.to_owned()
})
.collect()
} else {
Vec::new()
};
Ok(Self {
writes: tree.finish(),
deletes,
notices,
plan,
})
}
pub fn operations(&self) -> Vec<String> {
self.notices
.iter()
.cloned()
.chain(
self.writes
.iter()
.map(|write| format!("write {}", write.path.display())),
)
.chain(
self.deletes
.iter()
.map(|path| format!("delete {}", path.display())),
)
.collect()
}
pub fn apply(&self, root: &Path, dry_run: bool) -> Result<()> {
if dry_run {
return Ok(());
}
write_contained(root, &self.writes)?;
for relative in &self.deletes {
let path = root.join(relative);
std::fs::remove_file(&path).map_err(|error| Error::io(&path, error))?;
}
Ok(())
}
}
fn edit_projection_version(
tree: &mut EditTree<'_>,
root: &Path,
release_unit: &ReleaseUnitConfig,
projection: &Projection,
version: &str,
workspace_versions: &mut SharedVersions,
) -> Result<()> {
let relative = projection.file.workspace_path(&release_unit.path);
let text = tree.read(&relative)?;
let edited = match projection.adapter {
Adapter::Npm => NpmAdapter.edit_version(&text, version)?,
Adapter::Cargo => match CargoAdapter.version(&text)? {
Some(_) => CargoAdapter.edit_version(&text, version)?,
None => {
let workspace_manifest = workspace_manifest(root, &relative)?;
if let Some(existing) =
workspace_versions.claim(root, &workspace_manifest, version)?
{
return Err(Error::Validation(format!(
"Cargo workspace members require conflicting versions {existing} and {version}"
)));
}
let workspace_text = tree.read(&workspace_manifest)?;
let workspace_edited =
CargoAdapter.edit_workspace_version(&workspace_text, version)?;
tree.set(&workspace_manifest, workspace_edited)?;
text
}
},
Adapter::Json => JsonFormat.edit_text(
&text,
projection.pointer.as_deref().expect("validated pointer"),
version,
)?,
Adapter::Toml => TomlFormat.edit_text(
&text,
projection.pointer.as_deref().expect("validated pointer"),
version,
)?,
Adapter::Yaml => YamlFormat.edit_text(
&text,
projection.pointer.as_deref().expect("validated pointer"),
version,
)?,
Adapter::Pub => PubAdapter.edit_version(&text, version)?,
Adapter::Python => PythonAdapter.edit_version(&text, version)?,
Adapter::Msbuild => MsbuildAdapter.edit_version(&text, version)?,
Adapter::Go => GoAdapter.edit_major_module_path(&text, version)?,
};
tree.set(&relative, edited)?;
Ok(())
}
fn rewrite_internal_dependencies(
tree: &mut EditTree<'_>,
root: &Path,
config: &Config,
released: &BTreeMap<&str, &PlanReleaseUnit>,
) -> Result<()> {
for (dependent_id, dependent) in &config.release_units {
if !released.contains_key(dependent_id.as_str()) {
continue;
}
for dependency_id in &dependent.depends_on {
let Some(dependency_release) = released.get(dependency_id.as_str()) else {
continue;
};
let dependency = &config.release_units[dependency_id];
for adapter in [
Adapter::Npm,
Adapter::Cargo,
Adapter::Pub,
Adapter::Python,
Adapter::Msbuild,
Adapter::Go,
] {
let Some(source_projection) = dependency
.projections
.iter()
.find(|projection| projection.adapter == adapter)
else {
continue;
};
let Some(target_projection) = dependent.projections.iter().find(|projection| {
projection.adapter == adapter && projection.mode == ProjectionMode::Committed
}) else {
continue;
};
let source_path = source_projection.file.workspace_path(&dependency.path);
let source_text = tree.read(&source_path)?;
let dependency_name = match adapter {
Adapter::Npm => NpmAdapter.name(&source_text)?,
Adapter::Cargo => CargoAdapter.name(&source_text)?,
Adapter::Pub => PubAdapter.name(&source_text)?,
Adapter::Python => PythonAdapter.name(&source_text)?,
Adapter::Msbuild => MsbuildAdapter
.name(&source_text)
.unwrap_or_else(|_| dependency_id.clone()),
Adapter::Go => GoAdapter.name(&source_text)?,
_ => unreachable!(),
};
let target_path = target_projection.file.workspace_path(&dependent.path);
let target_text = tree.read(&target_path)?;
let edited = match adapter {
Adapter::Npm => NpmAdapter.edit_dependency(
&target_text,
&dependency_name,
&dependency_release.new_version,
)?,
Adapter::Cargo => {
if CargoAdapter.dependency_is_inherited(&target_text, &dependency_name)? {
let workspace = workspace_manifest(root, &target_path)?;
let workspace_text = tree.read(&workspace)?;
let workspace_edited = CargoAdapter.edit_dependency(
&workspace_text,
&dependency_name,
&dependency_release.new_version,
)?;
tree.set(&workspace, workspace_edited)?;
target_text
} else {
CargoAdapter.edit_dependency(
&target_text,
&dependency_name,
&dependency_release.new_version,
)?
}
}
Adapter::Pub => PubAdapter.edit_dependency(
&target_text,
&dependency_name,
&dependency_release.new_version,
)?,
Adapter::Python => PythonAdapter.edit_dependency(
&target_text,
&dependency_name,
&dependency_release.new_version,
)?,
Adapter::Msbuild => MsbuildAdapter.edit_dependency(
&target_text,
&dependency_name,
&dependency_release.new_version,
)?,
Adapter::Go => GoAdapter.edit_dependency(
&target_text,
&dependency_name,
&dependency_release.new_version,
)?,
_ => unreachable!(),
};
tree.set(&target_path, edited)?;
}
}
}
Ok(())
}
pub(crate) fn workspace_manifest(root: &Path, member: &Path) -> Result<PathBuf> {
let member = normalized(member);
let mut directory = member.parent();
while let Some(candidate) = directory {
let relative = candidate.join("Cargo.toml");
let manifest = contained(root, &relative)?;
if manifest.exists() {
let text =
std::fs::read_to_string(&manifest).map_err(|error| Error::io(&manifest, error))?;
if text
.parse::<toml_edit::DocumentMut>()
.ok()
.and_then(|document| document.get("workspace").cloned())
.is_some()
{
return Ok(normalized(&relative));
}
}
directory = candidate.parent();
}
Err(Error::Validation(format!(
"could not find Cargo workspace manifest for {}",
member.display()
)))
}
fn update_changelog(
existing: Option<&str>,
section: &str,
version: &str,
final_release: bool,
) -> String {
let existing = existing.unwrap_or("# Changelog\n");
let mut boundaries = existing
.match_indices("\n## ")
.map(|(index, _)| index + 1)
.collect::<Vec<_>>();
if existing.starts_with("## ") {
boundaries.insert(0, 0);
}
let preamble_end = boundaries.first().copied().unwrap_or(existing.len());
let mut kept = Vec::new();
for (index, start) in boundaries.iter().enumerate() {
let end = boundaries.get(index + 1).copied().unwrap_or(existing.len());
let candidate = &existing[*start..end];
let heading = candidate.lines().next().unwrap_or_default();
let exact = heading == format!("## {version}");
let prerelease = final_release && heading.starts_with(&format!("## {version}-"));
if !exact && !prerelease {
kept.push(candidate.trim());
}
}
let mut output = String::new();
output.push_str(existing[..preamble_end].trim_end());
output.push_str("\n\n");
output.push_str(section.trim());
output.push('\n');
for candidate in kept {
output.push('\n');
output.push_str(candidate);
output.push('\n');
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn final_changelog_replaces_channel_sections() {
let existing = "# Changelog\n\n## 1.2.0-beta.2\n\nOld beta.\n\n## 1.1.0\n\nPrior.\n";
let output = update_changelog(
Some(existing),
"## 1.2.0\n\n### Features\n\n- Final.",
"1.2.0",
true,
);
assert!(output.contains("## 1.2.0\n"));
assert!(!output.contains("beta.2"));
assert!(output.contains("## 1.1.0"));
}
#[test]
fn new_changelog_has_lintable_spacing() {
let output = update_changelog(
None,
"## 1.0.1\n\n### Fixes\n\n- Correct a defect.",
"1.0.1",
true,
);
assert_eq!(
output,
"# Changelog\n\n## 1.0.1\n\n### Fixes\n\n- Correct a defect.\n"
);
}
}