1use crate::adapters::{
9 CargoAdapter, FormatAdapter, GoAdapter, JsonFormat, MsbuildAdapter, NpmAdapter, PubAdapter,
10 PythonAdapter, TomlFormat, YamlFormat,
11};
12use crate::config::{Config, Projection, ReleaseUnitConfig};
13use crate::error::{Error, Result};
14use crate::intent::Intent;
15use crate::model::{Adapter, ProjectionMode};
16use crate::ownership::check_physical_ownership;
17use crate::plan::{PlanReleaseUnit, ReleasePlan};
18use crate::projection_file::normalized;
19use crate::workspace_files::{contained, write_contained, EditTree, SharedVersions};
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct FileWrite {
26 pub path: PathBuf,
28 pub contents: String,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ApplyResult {
35 pub writes: Vec<FileWrite>,
37 pub deletes: Vec<PathBuf>,
39 pub notices: Vec<String>,
41 pub plan: ReleasePlan,
43}
44
45impl ApplyResult {
46 pub fn build(root: &Path, channel: Option<&str>) -> Result<Self> {
48 let config = Config::load(root)?;
49 check_physical_ownership(root, &config)?;
50 let intents = Intent::load_all(root, &config)?;
51 let plan = ReleasePlan::from_inputs(root, &config, &intents, channel)?;
52 let mut tree = EditTree::new(root);
53 let by_id: BTreeMap<_, _> = plan
54 .release_units
55 .iter()
56 .map(|release_unit| (release_unit.id.as_str(), release_unit))
57 .collect();
58 let mut workspace_versions = SharedVersions::default();
59 let mut notices = Vec::new();
60
61 for release_unit in &plan.release_units {
62 let config_release_unit = &config.release_units[&release_unit.id];
63 for projection in &config_release_unit.projections {
64 let go_major = projection.adapter == Adapter::Go
65 && release_unit.bump == crate::model::Bump::Major;
66 if projection.mode != ProjectionMode::Committed && !go_major {
67 continue;
68 }
69 edit_projection_version(
70 &mut tree,
71 root,
72 config_release_unit,
73 projection,
74 &release_unit.new_version,
75 &mut workspace_versions,
76 )?;
77 if go_major {
78 notices.push(format!(
79 "rewrite Go module major path {}",
80 projection
81 .file
82 .workspace_path(&config_release_unit.path)
83 .display()
84 ));
85 }
86 }
87 let changelog = config_release_unit.path.join("CHANGELOG.md");
88 let current = tree.read_optional(&changelog)?;
89 let next = update_changelog(
90 current.as_deref(),
91 &release_unit.release_notes,
92 &release_unit.new_version,
93 channel.is_none(),
94 );
95 tree.set(&changelog, next)?;
96 }
97
98 rewrite_internal_dependencies(&mut tree, root, &config, &by_id)?;
99
100 let deletes = if channel.is_none() {
101 intents
102 .iter()
103 .map(|intent| {
104 intent
105 .path
106 .strip_prefix(root)
107 .unwrap_or(&intent.path)
108 .to_owned()
109 })
110 .collect()
111 } else {
112 Vec::new()
113 };
114 Ok(Self {
115 writes: tree.finish(),
116 deletes,
117 notices,
118 plan,
119 })
120 }
121
122 pub fn operations(&self) -> Vec<String> {
124 self.notices
125 .iter()
126 .cloned()
127 .chain(
128 self.writes
129 .iter()
130 .map(|write| format!("write {}", write.path.display())),
131 )
132 .chain(
133 self.deletes
134 .iter()
135 .map(|path| format!("delete {}", path.display())),
136 )
137 .collect()
138 }
139
140 pub fn apply(&self, root: &Path, dry_run: bool) -> Result<()> {
142 if dry_run {
143 return Ok(());
144 }
145 write_contained(root, &self.writes)?;
146 for relative in &self.deletes {
147 let path = root.join(relative);
148 std::fs::remove_file(&path).map_err(|error| Error::io(&path, error))?;
149 }
150 Ok(())
151 }
152}
153
154fn edit_projection_version(
155 tree: &mut EditTree<'_>,
156 root: &Path,
157 release_unit: &ReleaseUnitConfig,
158 projection: &Projection,
159 version: &str,
160 workspace_versions: &mut SharedVersions,
161) -> Result<()> {
162 let relative = projection.file.workspace_path(&release_unit.path);
163 let text = tree.read(&relative)?;
164 let edited = match projection.adapter {
165 Adapter::Npm => NpmAdapter.edit_version(&text, version)?,
166 Adapter::Cargo => match CargoAdapter.version(&text)? {
167 Some(_) => CargoAdapter.edit_version(&text, version)?,
168 None => {
169 let workspace_manifest = workspace_manifest(root, &relative)?;
170 if let Some(existing) =
171 workspace_versions.claim(root, &workspace_manifest, version)?
172 {
173 return Err(Error::Validation(format!(
174 "Cargo workspace members require conflicting versions {existing} and {version}"
175 )));
176 }
177 let workspace_text = tree.read(&workspace_manifest)?;
178 let workspace_edited =
179 CargoAdapter.edit_workspace_version(&workspace_text, version)?;
180 tree.set(&workspace_manifest, workspace_edited)?;
181 text
182 }
183 },
184 Adapter::Json => JsonFormat.edit_text(
185 &text,
186 projection.pointer.as_deref().expect("validated pointer"),
187 version,
188 )?,
189 Adapter::Toml => TomlFormat.edit_text(
190 &text,
191 projection.pointer.as_deref().expect("validated pointer"),
192 version,
193 )?,
194 Adapter::Yaml => YamlFormat.edit_text(
195 &text,
196 projection.pointer.as_deref().expect("validated pointer"),
197 version,
198 )?,
199 Adapter::Pub => PubAdapter.edit_version(&text, version)?,
200 Adapter::Python => PythonAdapter.edit_version(&text, version)?,
201 Adapter::Msbuild => MsbuildAdapter.edit_version(&text, version)?,
202 Adapter::Go => GoAdapter.edit_major_module_path(&text, version)?,
203 };
204 tree.set(&relative, edited)?;
205 Ok(())
206}
207
208fn rewrite_internal_dependencies(
209 tree: &mut EditTree<'_>,
210 root: &Path,
211 config: &Config,
212 released: &BTreeMap<&str, &PlanReleaseUnit>,
213) -> Result<()> {
214 for (dependent_id, dependent) in &config.release_units {
215 if !released.contains_key(dependent_id.as_str()) {
216 continue;
217 }
218 for dependency_id in &dependent.depends_on {
219 let Some(dependency_release) = released.get(dependency_id.as_str()) else {
220 continue;
221 };
222 let dependency = &config.release_units[dependency_id];
223 for adapter in [
224 Adapter::Npm,
225 Adapter::Cargo,
226 Adapter::Pub,
227 Adapter::Python,
228 Adapter::Msbuild,
229 Adapter::Go,
230 ] {
231 let Some(source_projection) = dependency
232 .projections
233 .iter()
234 .find(|projection| projection.adapter == adapter)
235 else {
236 continue;
237 };
238 let Some(target_projection) = dependent.projections.iter().find(|projection| {
239 projection.adapter == adapter && projection.mode == ProjectionMode::Committed
240 }) else {
241 continue;
242 };
243 let source_path = source_projection.file.workspace_path(&dependency.path);
244 let source_text = tree.read(&source_path)?;
245 let dependency_name = match adapter {
246 Adapter::Npm => NpmAdapter.name(&source_text)?,
247 Adapter::Cargo => CargoAdapter.name(&source_text)?,
248 Adapter::Pub => PubAdapter.name(&source_text)?,
249 Adapter::Python => PythonAdapter.name(&source_text)?,
250 Adapter::Msbuild => MsbuildAdapter
251 .name(&source_text)
252 .unwrap_or_else(|_| dependency_id.clone()),
253 Adapter::Go => GoAdapter.name(&source_text)?,
254 _ => unreachable!(),
255 };
256 let target_path = target_projection.file.workspace_path(&dependent.path);
257 let target_text = tree.read(&target_path)?;
258 let edited = match adapter {
259 Adapter::Npm => NpmAdapter.edit_dependency(
260 &target_text,
261 &dependency_name,
262 &dependency_release.new_version,
263 )?,
264 Adapter::Cargo => {
265 if CargoAdapter.dependency_is_inherited(&target_text, &dependency_name)? {
266 let workspace = workspace_manifest(root, &target_path)?;
267 let workspace_text = tree.read(&workspace)?;
268 let workspace_edited = CargoAdapter.edit_dependency(
269 &workspace_text,
270 &dependency_name,
271 &dependency_release.new_version,
272 )?;
273 tree.set(&workspace, workspace_edited)?;
274 target_text
275 } else {
276 CargoAdapter.edit_dependency(
277 &target_text,
278 &dependency_name,
279 &dependency_release.new_version,
280 )?
281 }
282 }
283 Adapter::Pub => PubAdapter.edit_dependency(
284 &target_text,
285 &dependency_name,
286 &dependency_release.new_version,
287 )?,
288 Adapter::Python => PythonAdapter.edit_dependency(
289 &target_text,
290 &dependency_name,
291 &dependency_release.new_version,
292 )?,
293 Adapter::Msbuild => MsbuildAdapter.edit_dependency(
294 &target_text,
295 &dependency_name,
296 &dependency_release.new_version,
297 )?,
298 Adapter::Go => GoAdapter.edit_dependency(
299 &target_text,
300 &dependency_name,
301 &dependency_release.new_version,
302 )?,
303 _ => unreachable!(),
304 };
305 tree.set(&target_path, edited)?;
306 }
307 }
308 }
309 Ok(())
310}
311
312pub(crate) fn workspace_manifest(root: &Path, member: &Path) -> Result<PathBuf> {
317 let member = normalized(member);
318 let mut directory = member.parent();
319 while let Some(candidate) = directory {
320 let relative = candidate.join("Cargo.toml");
321 let manifest = contained(root, &relative)?;
322 if manifest.exists() {
323 let text =
324 std::fs::read_to_string(&manifest).map_err(|error| Error::io(&manifest, error))?;
325 if text
326 .parse::<toml_edit::DocumentMut>()
327 .ok()
328 .and_then(|document| document.get("workspace").cloned())
329 .is_some()
330 {
331 return Ok(normalized(&relative));
332 }
333 }
334 directory = candidate.parent();
335 }
336 Err(Error::Validation(format!(
337 "could not find Cargo workspace manifest for {}",
338 member.display()
339 )))
340}
341
342fn update_changelog(
343 existing: Option<&str>,
344 section: &str,
345 version: &str,
346 final_release: bool,
347) -> String {
348 let existing = existing.unwrap_or("# Changelog\n");
349 let mut boundaries = existing
350 .match_indices("\n## ")
351 .map(|(index, _)| index + 1)
352 .collect::<Vec<_>>();
353 if existing.starts_with("## ") {
354 boundaries.insert(0, 0);
355 }
356 let preamble_end = boundaries.first().copied().unwrap_or(existing.len());
357 let mut kept = Vec::new();
358 for (index, start) in boundaries.iter().enumerate() {
359 let end = boundaries.get(index + 1).copied().unwrap_or(existing.len());
360 let candidate = &existing[*start..end];
361 let heading = candidate.lines().next().unwrap_or_default();
362 let exact = heading == format!("## {version}");
363 let prerelease = final_release && heading.starts_with(&format!("## {version}-"));
364 if !exact && !prerelease {
365 kept.push(candidate.trim());
366 }
367 }
368 let mut output = String::new();
369 output.push_str(existing[..preamble_end].trim_end());
370 output.push_str("\n\n");
371 output.push_str(section.trim());
372 output.push('\n');
373 for candidate in kept {
374 output.push('\n');
375 output.push_str(candidate);
376 output.push('\n');
377 }
378 output
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
386 fn final_changelog_replaces_channel_sections() {
387 let existing = "# Changelog\n\n## 1.2.0-beta.2\n\nOld beta.\n\n## 1.1.0\n\nPrior.\n";
388 let output = update_changelog(
389 Some(existing),
390 "## 1.2.0\n\n### Features\n\n- Final.",
391 "1.2.0",
392 true,
393 );
394 assert!(output.contains("## 1.2.0\n"));
395 assert!(!output.contains("beta.2"));
396 assert!(output.contains("## 1.1.0"));
397 }
398
399 #[test]
400 fn new_changelog_has_lintable_spacing() {
401 let output = update_changelog(
402 None,
403 "## 1.0.1\n\n### Fixes\n\n- Correct a defect.",
404 "1.0.1",
405 true,
406 );
407 assert_eq!(
408 output,
409 "# Changelog\n\n## 1.0.1\n\n### Fixes\n\n- Correct a defect.\n"
410 );
411 }
412}