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::{Component, 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 let relative = &normalized(relative);
170 if let Some(text) = self.current.get(relative) {
171 return Ok(text.clone());
172 }
173 let path = self.root.join(relative);
174 let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
175 self.original
176 .entry(relative.to_owned())
177 .or_insert_with(|| Some(text.clone()));
178 self.current.insert(relative.to_owned(), text.clone());
179 Ok(text)
180 }
181
182 fn read_optional(&mut self, relative: &Path) -> Result<Option<String>> {
183 let relative = &normalized(relative);
184 if let Some(text) = self.current.get(relative) {
185 return Ok(Some(text.clone()));
186 }
187 let path = self.root.join(relative);
188 match std::fs::read_to_string(&path) {
189 Ok(text) => {
190 self.original
191 .entry(relative.to_owned())
192 .or_insert_with(|| Some(text.clone()));
193 self.current.insert(relative.to_owned(), text.clone());
194 Ok(Some(text))
195 }
196 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
197 self.original.entry(relative.to_owned()).or_insert(None);
198 Ok(None)
199 }
200 Err(error) => Err(Error::io(path, error)),
201 }
202 }
203
204 fn set(&mut self, relative: PathBuf, contents: String) {
205 let relative = normalized(&relative);
206 self.original.entry(relative.clone()).or_insert(None);
207 self.current.insert(relative, contents);
208 }
209
210 fn finish(self) -> Vec<FileWrite> {
211 self.current
212 .into_iter()
213 .filter(|(path, contents)| {
214 self.original.get(path).and_then(Option::as_ref) != Some(contents)
215 })
216 .map(|(path, contents)| FileWrite { path, contents })
217 .collect()
218 }
219}
220
221fn normalized(relative: &Path) -> PathBuf {
223 relative
224 .components()
225 .filter(|component| !matches!(component, Component::CurDir))
226 .collect()
227}
228
229fn edit_projection_version(
230 tree: &mut WorkingTree<'_>,
231 root: &Path,
232 release_unit: &ReleaseUnitConfig,
233 projection: &Projection,
234 version: &str,
235 workspace_versions: &mut BTreeMap<PathBuf, String>,
236) -> Result<()> {
237 let relative = release_unit.path.join(&projection.file);
238 let text = tree.read(&relative)?;
239 let edited = match projection.adapter {
240 Adapter::Npm => NpmAdapter.edit_version(&text, version)?,
241 Adapter::Cargo => match CargoAdapter.version(&text)? {
242 Some(_) => CargoAdapter.edit_version(&text, version)?,
243 None => {
244 let workspace_manifest = workspace_manifest(root, &relative)?;
245 if let Some(existing) = workspace_versions.get(&workspace_manifest) {
246 if existing != version {
247 return Err(Error::Validation(format!(
248 "Cargo workspace members require conflicting versions {existing} and {version}"
249 )));
250 }
251 }
252 workspace_versions.insert(workspace_manifest.clone(), version.to_owned());
253 let workspace_text = tree.read(&workspace_manifest)?;
254 let workspace_edited =
255 CargoAdapter.edit_workspace_version(&workspace_text, version)?;
256 tree.set(workspace_manifest, workspace_edited);
257 text
258 }
259 },
260 Adapter::Json => JsonFormat.edit_text(
261 &text,
262 projection.pointer.as_deref().expect("validated pointer"),
263 version,
264 )?,
265 Adapter::Toml => TomlFormat.edit_text(
266 &text,
267 projection.pointer.as_deref().expect("validated pointer"),
268 version,
269 )?,
270 Adapter::Yaml => YamlFormat.edit_text(
271 &text,
272 projection.pointer.as_deref().expect("validated pointer"),
273 version,
274 )?,
275 Adapter::Pub => PubAdapter.edit_version(&text, version)?,
276 Adapter::Python => PythonAdapter.edit_version(&text, version)?,
277 Adapter::Msbuild => MsbuildAdapter.edit_version(&text, version)?,
278 Adapter::Go => GoAdapter.edit_major_module_path(&text, version)?,
279 };
280 tree.set(relative, edited);
281 Ok(())
282}
283
284fn rewrite_internal_dependencies(
285 tree: &mut WorkingTree<'_>,
286 root: &Path,
287 config: &Config,
288 released: &BTreeMap<&str, &PlanReleaseUnit>,
289) -> Result<()> {
290 for (dependent_id, dependent) in &config.release_units {
291 if !released.contains_key(dependent_id.as_str()) {
292 continue;
293 }
294 for dependency_id in &dependent.depends_on {
295 let Some(dependency_release) = released.get(dependency_id.as_str()) else {
296 continue;
297 };
298 let dependency = &config.release_units[dependency_id];
299 for adapter in [
300 Adapter::Npm,
301 Adapter::Cargo,
302 Adapter::Pub,
303 Adapter::Python,
304 Adapter::Msbuild,
305 Adapter::Go,
306 ] {
307 let Some(source_projection) = dependency
308 .projections
309 .iter()
310 .find(|projection| projection.adapter == adapter)
311 else {
312 continue;
313 };
314 let Some(target_projection) = dependent.projections.iter().find(|projection| {
315 projection.adapter == adapter && projection.mode == ProjectionMode::Committed
316 }) else {
317 continue;
318 };
319 let source_path = dependency.path.join(&source_projection.file);
320 let source_text = tree.read(&source_path)?;
321 let dependency_name = match adapter {
322 Adapter::Npm => NpmAdapter.name(&source_text)?,
323 Adapter::Cargo => CargoAdapter.name(&source_text)?,
324 Adapter::Pub => PubAdapter.name(&source_text)?,
325 Adapter::Python => PythonAdapter.name(&source_text)?,
326 Adapter::Msbuild => MsbuildAdapter
327 .name(&source_text)
328 .unwrap_or_else(|_| dependency_id.clone()),
329 Adapter::Go => GoAdapter.name(&source_text)?,
330 _ => unreachable!(),
331 };
332 let target_path = dependent.path.join(&target_projection.file);
333 let target_text = tree.read(&target_path)?;
334 let edited = match adapter {
335 Adapter::Npm => NpmAdapter.edit_dependency(
336 &target_text,
337 &dependency_name,
338 &dependency_release.new_version,
339 )?,
340 Adapter::Cargo => {
341 if CargoAdapter.dependency_is_inherited(&target_text, &dependency_name)? {
342 let workspace = workspace_manifest(root, &target_path)?;
343 let workspace_text = tree.read(&workspace)?;
344 let workspace_edited = CargoAdapter.edit_dependency(
345 &workspace_text,
346 &dependency_name,
347 &dependency_release.new_version,
348 )?;
349 tree.set(workspace, workspace_edited);
350 target_text
351 } else {
352 CargoAdapter.edit_dependency(
353 &target_text,
354 &dependency_name,
355 &dependency_release.new_version,
356 )?
357 }
358 }
359 Adapter::Pub => PubAdapter.edit_dependency(
360 &target_text,
361 &dependency_name,
362 &dependency_release.new_version,
363 )?,
364 Adapter::Python => PythonAdapter.edit_dependency(
365 &target_text,
366 &dependency_name,
367 &dependency_release.new_version,
368 )?,
369 Adapter::Msbuild => MsbuildAdapter.edit_dependency(
370 &target_text,
371 &dependency_name,
372 &dependency_release.new_version,
373 )?,
374 Adapter::Go => GoAdapter.edit_dependency(
375 &target_text,
376 &dependency_name,
377 &dependency_release.new_version,
378 )?,
379 _ => unreachable!(),
380 };
381 tree.set(target_path, edited);
382 }
383 }
384 }
385 Ok(())
386}
387
388pub(crate) fn workspace_manifest(root: &Path, member: &Path) -> Result<PathBuf> {
389 let absolute_member = root.join(member);
390 let mut directory = absolute_member.parent();
391 while let Some(candidate) = directory {
392 let manifest = candidate.join("Cargo.toml");
393 if manifest.exists() {
394 let text =
395 std::fs::read_to_string(&manifest).map_err(|error| Error::io(&manifest, error))?;
396 if text
397 .parse::<toml_edit::DocumentMut>()
398 .ok()
399 .and_then(|document| document.get("workspace").cloned())
400 .is_some()
401 {
402 return manifest
403 .strip_prefix(root)
404 .map(Path::to_owned)
405 .map_err(|error| Error::Validation(error.to_string()));
406 }
407 }
408 if candidate == root {
409 break;
410 }
411 directory = candidate.parent();
412 }
413 Err(Error::Validation(format!(
414 "could not find Cargo workspace manifest for {}",
415 member.display()
416 )))
417}
418
419fn update_changelog(
420 existing: Option<&str>,
421 section: &str,
422 version: &str,
423 final_release: bool,
424) -> String {
425 let existing = existing.unwrap_or("# Changelog\n");
426 let mut boundaries = existing
427 .match_indices("\n## ")
428 .map(|(index, _)| index + 1)
429 .collect::<Vec<_>>();
430 if existing.starts_with("## ") {
431 boundaries.insert(0, 0);
432 }
433 let preamble_end = boundaries.first().copied().unwrap_or(existing.len());
434 let mut kept = Vec::new();
435 for (index, start) in boundaries.iter().enumerate() {
436 let end = boundaries.get(index + 1).copied().unwrap_or(existing.len());
437 let candidate = &existing[*start..end];
438 let heading = candidate.lines().next().unwrap_or_default();
439 let exact = heading == format!("## {version}");
440 let prerelease = final_release && heading.starts_with(&format!("## {version}-"));
441 if !exact && !prerelease {
442 kept.push(candidate.trim());
443 }
444 }
445 let mut output = String::new();
446 output.push_str(existing[..preamble_end].trim_end());
447 output.push_str("\n\n");
448 output.push_str(section.trim());
449 output.push('\n');
450 for candidate in kept {
451 output.push('\n');
452 output.push_str(candidate);
453 output.push('\n');
454 }
455 output
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn final_changelog_replaces_channel_sections() {
464 let existing = "# Changelog\n\n## 1.2.0-beta.2\n\nOld beta.\n\n## 1.1.0\n\nPrior.\n";
465 let output = update_changelog(
466 Some(existing),
467 "## 1.2.0\n\n### Features\n\n- Final.",
468 "1.2.0",
469 true,
470 );
471 assert!(output.contains("## 1.2.0\n"));
472 assert!(!output.contains("beta.2"));
473 assert!(output.contains("## 1.1.0"));
474 }
475
476 #[test]
477 fn new_changelog_has_lintable_spacing() {
478 let output = update_changelog(
479 None,
480 "## 1.0.1\n\n### Fixes\n\n- Correct a defect.",
481 "1.0.1",
482 true,
483 );
484 assert_eq!(
485 output,
486 "# Changelog\n\n## 1.0.1\n\n### Fixes\n\n- Correct a defect.\n"
487 );
488 }
489
490 #[test]
491 fn working_tree_shares_edits_across_equivalent_relative_paths() {
492 let mut tree = WorkingTree::new(Path::new("/nonexistent-workspace"));
493 tree.set(PathBuf::from("./docs/docs.yml"), "a: 1\nb: 0\n".to_owned());
494 let shared = tree.read(Path::new("docs/docs.yml")).expect("shared edit");
495 tree.set(
496 PathBuf::from("docs/docs.yml"),
497 shared.replace("b: 0", "b: 1"),
498 );
499 assert_eq!(
500 tree.finish(),
501 [FileWrite {
502 path: PathBuf::from("docs/docs.yml"),
503 contents: "a: 1\nb: 1\n".to_owned(),
504 }]
505 );
506 }
507}