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