1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
2use std::fmt::Write;
3
4use anyhow::Context;
5use cargo_metadata::camino::{Utf8Path, Utf8PathBuf};
6use cargo_metadata::semver::Version;
7use cargo_metadata::{DependencyKind, semver};
8use serde::Deserialize as _;
9use serde::de::IntoDeserializer;
10use serde_derive::{Deserialize, Serialize};
11use toml_edit::{DocumentMut, Table};
12
13use super::check::CheckRun;
14use super::utils::VersionBump;
15use crate::cmd::release::utils::vers_to_comp;
16use crate::utils::git_workdir_clean;
17
18#[derive(Debug, Clone, clap::Parser)]
19pub struct Update {
20 #[arg(long, default_value_t = num_cpus::get())]
22 concurrency: usize,
23 #[arg(long)]
25 dry_run: bool,
26 #[arg(long)]
28 allow_dirty: bool,
29 #[arg(long = "package", short = 'p')]
32 packages: Vec<String>,
33 #[arg(long)]
35 changelogs_only: bool,
36}
37
38impl Update {
39 pub fn run(self) -> anyhow::Result<()> {
40 if !self.allow_dirty {
41 git_workdir_clean()?;
42 }
43
44 let metadata = crate::utils::metadata()?;
45
46 let check_run = CheckRun::new(&metadata, &self.packages).context("check run")?;
47
48 let mut change_fragments = std::fs::read_dir(metadata.workspace_root.join("changes.d"))?
49 .filter_map(|entry| entry.ok())
50 .filter_map(|entry| {
51 let entry_path = entry.path();
52 if entry_path.is_file() {
53 let file_name = entry_path.file_name()?.to_str()?;
54 file_name.strip_prefix("pr-")?.strip_suffix(".toml")?.parse().ok()
55 } else {
56 None
57 }
58 })
59 .try_fold(BTreeMap::new(), |mut fragments, pr_number| {
60 let fragment = Fragment::new(pr_number, &metadata.workspace_root)?;
61
62 fragments.insert(pr_number, fragment);
63
64 anyhow::Ok(fragments)
65 })?;
66
67 let dep_graph = check_run
68 .all_packages()
69 .map(|package| {
70 (
71 package.name.as_str(),
72 package
73 .dependencies
74 .iter()
75 .filter(|dep| {
76 dep.path.is_some() && matches!(dep.kind, DependencyKind::Build | DependencyKind::Normal)
77 })
78 .map(|dep| (dep.name.as_str(), dep))
79 .collect::<BTreeMap<_, _>>(),
80 )
81 })
82 .collect::<HashMap<_, _>>();
83
84 let inverted_dep_graph = dep_graph
85 .iter()
86 .fold(HashMap::<_, Vec<_>>::new(), |mut inverted, (package, deps)| {
87 deps.iter().for_each(|(name, dep)| {
88 inverted.entry(*name).or_default().push((*package, *dep));
89 });
90 inverted
91 });
92
93 let flattened_dep_public_graph = dep_graph
94 .iter()
95 .map(|(package, deps)| {
96 let mut seen = HashSet::new();
97 let pkg = check_run.get_package(package).unwrap();
98 (
99 *package,
100 deps.iter().fold(HashMap::<_, Vec<_>>::new(), |mut deps, (name, dep)| {
101 let mut stack = vec![(pkg, check_run.get_package(name).unwrap(), *dep)];
102
103 while let Some((pkg, dep_pkg, dep)) = stack.pop() {
104 if pkg.is_dep_public(&dep.name) {
105 deps.entry(dep_pkg.name.as_str()).or_default().push(dep);
106 if seen.insert(&dep_pkg.name) {
107 stack.extend(
108 dep_graph
109 .get(dep_pkg.name.as_str())
110 .into_iter()
111 .flatten()
112 .map(|(name, dep)| (pkg, check_run.get_package(name).unwrap(), *dep)),
113 );
114 }
115 }
116 }
117
118 deps
119 }),
120 )
121 })
122 .collect::<HashMap<_, _>>();
123
124 if !self.changelogs_only {
125 check_run.process(self.concurrency, &metadata.workspace_root, None)?;
126
127 for fragment in change_fragments.values() {
128 for (package, logs) in fragment.items().context("fragment items")? {
129 let Some(pkg) = check_run.get_package(&package) else {
130 tracing::warn!("unknown package: {package}");
131 continue;
132 };
133
134 pkg.report_change();
135 if logs.iter().any(|l| l.breaking) {
136 pkg.report_breaking_change();
137 }
138 }
139 }
140
141 let mut found = false;
142 for iter in 0..10 {
143 let mut has_changes = false;
144 for group in check_run.all_groups() {
145 let max_bump_version = group
146 .iter()
147 .map(|p| {
148 match (p.last_published_version(), p.version_bump()) {
149 (None, _) | (_, None) => p.version.clone(),
152 (Some(last_published), Some(bump)) if last_published.vers == p.version => {
154 bump.next_semver(p.version.clone())
155 }
156 (Some(last_published), Some(bump)) => {
158 if bump == VersionBump::Major
160 && !vers_to_comp(last_published.vers.clone()).matches(&p.version)
161 {
162 bump.next_semver(last_published.vers)
163 } else {
164 p.version.clone()
165 }
166 }
167 }
168 })
169 .max()
170 .unwrap();
171
172 group
173 .iter()
174 .filter(|package| package.version != max_bump_version)
175 .for_each(|package| {
176 inverted_dep_graph
177 .get(package.name.as_ref())
178 .into_iter()
179 .flatten()
180 .for_each(|(pkg, dep)| {
181 if !dep.req.matches(&max_bump_version) || dep.req == package.unreleased_req() {
182 let pkg = check_run.get_package(pkg).unwrap();
183 if pkg.is_dep_public(&dep.name) && pkg.group() != package.group() {
184 pkg.report_breaking_change();
185 } else {
186 pkg.report_change();
187 }
188 }
189 });
190 });
191
192 group.iter().for_each(|p| {
193 if p.version != max_bump_version && p.next_version().is_none_or(|v| v != max_bump_version) {
194 tracing::debug!("{} to {} -> {max_bump_version}", p.name, p.version);
195 p.set_next_version(max_bump_version.clone());
196 has_changes = true;
197 }
198 });
199 }
200
201 if !has_changes {
202 tracing::debug!("satisfied version constraints after {} iterations", iter + 1);
203 found = true;
204 break;
205 }
206 }
207
208 if !found {
209 anyhow::bail!("could not satisfy version constraints after 10 attempts");
210 }
211
212 for package in check_run.groups().flatten() {
213 let deps = dep_graph.get(package.name.as_str()).unwrap();
214 for dep in deps.values() {
215 let dep_pkg = check_run.get_package(&dep.name).unwrap();
216
217 let depends_on = dep.req == dep_pkg.unreleased_req()
218 || dep_pkg.last_published_version().is_none_or(|v| !dep.req.matches(&v.vers))
219 || flattened_dep_public_graph.get(dep.name.as_str()).unwrap().iter().any(|(inner_dep_name, reqs)| {
222 let inner_dep_pkg = check_run.get_package(inner_dep_name).unwrap();
223 deps.contains_key(inner_dep_name) && package.is_dep_public(inner_dep_name) && check_run.is_accepted_group(inner_dep_pkg.group()) && inner_dep_pkg.next_version().is_some_and(|vers| reqs.iter().any(|dep_req| !dep_req.req.matches(&vers)))
227 });
228
229 if depends_on && !check_run.is_accepted_group(dep_pkg.group()) {
230 anyhow::bail!(
231 "could not update: `{}` because it depends on `{}` which is not part of the packages to be updated.",
232 package.name,
233 dep_pkg.name
234 );
235 }
236 }
237 }
238 }
239
240 let mut pr_body = String::from("## 🤖 New release\n\n");
241 let mut release_count = 0;
242 let workspace_manifest_path = metadata.workspace_root.join("Cargo.toml");
243
244 let mut workspace_manifest = if !self.changelogs_only {
245 let workspace_manifest = std::fs::read_to_string(&workspace_manifest_path).context("workspace manifest read")?;
246 Some((
247 workspace_manifest
248 .parse::<toml_edit::DocumentMut>()
249 .context("workspace manifest parse")?,
250 workspace_manifest,
251 ))
252 } else {
253 None
254 };
255
256 let mut workspace_metadata_packages = if let Some((workspace_manifest, _)) = &mut workspace_manifest {
257 let mut item = workspace_manifest.as_item_mut().as_table_like_mut().expect("table");
258 for key in ["workspace", "metadata", "xtask", "release", "packages"] {
259 item = item
260 .entry(key)
261 .or_insert(toml_edit::Item::Table({
262 let mut table = Table::new();
263 table.set_implicit(true);
264 table
265 }))
266 .as_table_like_mut()
267 .expect("table");
268 }
269
270 Some(item)
271 } else {
272 None
273 };
274
275 for package in check_run.all_packages() {
276 let _span = tracing::info_span!("update", package = %package.name).entered();
277 let version = package.next_version().or_else(|| {
278 if package.should_publish() && package.last_published_version().is_none() && !self.changelogs_only {
279 Some(package.version.clone())
280 } else {
281 None
282 }
283 });
284
285 release_count += 1;
286
287 let is_accepted_group = check_run.is_accepted_group(package.group());
288
289 let mut changelogs = if is_accepted_group {
290 if let Some(change_log_path_md) = package.changelog_path() {
291 Some((
292 change_log_path_md,
293 generate_change_logs(&package.name, &mut change_fragments).context("generate")?,
294 ))
295 } else {
296 None
297 }
298 } else {
299 None
300 };
301
302 if !self.changelogs_only {
303 let cargo_toml_raw = std::fs::read_to_string(&package.manifest_path).context("read cargo toml")?;
304 let mut cargo_toml_edit = cargo_toml_raw.parse::<toml_edit::DocumentMut>().context("parse toml")?;
305
306 if let Some(version) = version.as_ref()
307 && is_accepted_group
308 {
309 pr_body.push_str(
310 &fmtools::fmt(|mut f| {
311 let mut f = indent_write::fmt::IndentWriter::new(" ", &mut f);
312 write!(f, "* `{}`: ", package.name)?;
313 let last_published = package.last_published_version();
314 f.write_str(match &last_published {
315 None => " 📦 **New Crate**",
316 Some(v) if vers_to_comp(v.vers.clone()).matches(version) => " ✨ **Minor**",
317 Some(_) => " 🚀 **Major**",
318 })?;
319
320 let mut f = indent_write::fmt::IndentWriter::new(" ", f);
321 match &last_published {
322 Some(base) => write!(f, "\n* Version: **`{}`** ➡️ **`{version}`**", base.vers)?,
323 None => write!(f, "\n* Version: **`{version}`**")?,
324 }
325
326 if package.group() != package.name.as_str() {
327 write!(f, " (group: **`{}`**)", package.group())?;
328 }
329 f.write_str("\n")?;
330 Ok(())
331 })
332 .to_string(),
333 );
334
335 if let Some(workspace_metadata_packages) = &mut workspace_metadata_packages {
336 workspace_metadata_packages.insert(package.name.as_str(), version.to_string().into());
337 }
338
339 cargo_toml_edit["package"]["version"] = version.to_string().into();
340 }
341
342 tracing::debug!("checking deps");
343
344 for dep in &package.dependencies {
345 if dep.path.is_none() {
346 continue;
347 }
348
349 let kind = match dep.kind {
350 DependencyKind::Build => "build-dependencies",
351 DependencyKind::Normal => "dependencies",
352 _ => continue,
353 };
354
355 let Some(pkg) = check_run.get_package(&dep.name) else {
356 continue;
357 };
358
359 if !check_run.is_accepted_group(pkg.group()) {
360 continue;
361 }
362
363 let depends_on = dep.req == pkg.unreleased_req();
364 if !depends_on && pkg.next_version().is_none_or(|vers| dep.req.matches(&vers)) {
365 tracing::debug!("skipping version update on {}", dep.name);
366 continue;
367 }
368
369 let root = if let Some(target) = &dep.target {
370 &mut cargo_toml_edit["target"][&target.to_string()]
371 } else {
372 cargo_toml_edit.as_item_mut()
373 };
374
375 let item = root[kind][&dep.name].as_table_like_mut().unwrap();
376 let pkg_version = pkg.next_version().unwrap_or_else(|| pkg.version.clone());
377
378 let version = if pkg.group() == package.group() {
379 semver::VersionReq {
380 comparators: vec![semver::Comparator {
381 op: semver::Op::Exact,
382 major: pkg_version.major,
383 minor: Some(pkg_version.minor),
384 patch: Some(pkg_version.patch),
385 pre: pkg_version.pre.clone(),
386 }],
387 }
388 .to_string()
389 } else {
390 if !depends_on && let Some((_, changelogs)) = changelogs.as_mut() {
391 let mut log = PackageChangeLog::new("chore", format!("bump {} to `{pkg_version}`", dep.name));
392 log.breaking = package.is_dep_public(&dep.name);
393 changelogs.push(log)
394 }
395
396 pkg_version.to_string()
397 };
398
399 item.insert("version", version.into());
400 }
401
402 let cargo_toml = cargo_toml_edit.to_string();
403 if cargo_toml != cargo_toml_raw {
404 if !self.dry_run {
405 tracing::debug!("updating {}", package.manifest_path);
406 std::fs::write(&package.manifest_path, cargo_toml).context("write cargo toml")?;
407 } else {
408 tracing::warn!("not modifying {} because dry-run", package.manifest_path);
409 }
410 }
411 }
412
413 if let Some((change_log_path_md, changelogs)) = changelogs {
414 update_change_log(
415 &changelogs,
416 &change_log_path_md,
417 &package.name,
418 version.as_ref(),
419 package.last_published_version().map(|v| v.vers).as_ref(),
420 self.dry_run,
421 )
422 .context("update")?;
423 if !self.dry_run {
424 save_change_fragments(&mut change_fragments).context("save")?;
425 }
426 tracing::info!(package = %package.name, "updated change logs");
427 }
428 }
429
430 if let Some((workspace_manifest, workspace_manifest_str)) = workspace_manifest {
431 let workspace_manifest = workspace_manifest.to_string();
432 if workspace_manifest != workspace_manifest_str {
433 if self.dry_run {
434 tracing::warn!("skipping write of {workspace_manifest_path}")
435 } else {
436 std::fs::write(&workspace_manifest_path, workspace_manifest).context("write workspace metadata")?;
437 }
438 }
439 }
440
441 if release_count != 0 {
442 println!("{}", pr_body.trim());
443 } else {
444 tracing::info!("no packages to release!");
445 }
446
447 Ok(())
448 }
449}
450
451fn update_change_log(
452 logs: &[PackageChangeLog],
453 change_log_path_md: &Utf8Path,
454 name: &str,
455 version: Option<&Version>,
456 previous_version: Option<&Version>,
457 dry_run: bool,
458) -> anyhow::Result<()> {
459 let mut change_log = std::fs::read_to_string(change_log_path_md).context("failed to read CHANGELOG.md")?;
460
461 let (mut breaking_changes, mut other_changes) = logs.iter().partition::<Vec<_>, _>(|log| log.breaking);
464 breaking_changes.sort_by_key(|log| &log.category);
465 other_changes.sort_by_key(|log| &log.category);
466
467 fn make_logs(logs: &[&PackageChangeLog]) -> String {
468 fmtools::fmt(|f| {
469 let mut first = true;
470 for log in logs {
471 if !first {
472 f.write_char('\n')?;
473 }
474 first = false;
475
476 let (tag, desc) = log.description.split_once('\n').unwrap_or((&log.description, ""));
477 write!(f, "- {category}: {tag}", category = log.category, tag = tag.trim(),)?;
478
479 if !log.pr_numbers.is_empty() {
480 f.write_str(" (")?;
481 let mut first = true;
482 for pr_number in &log.pr_numbers {
483 if !first {
484 f.write_str(", ")?;
485 }
486 first = false;
487 write!(f, "[#{pr_number}](https://github.com/scufflecloud/scuffle/pull/{pr_number})")?;
488 }
489 f.write_str(")")?;
490 }
491
492 if !log.authors.is_empty() {
493 f.write_str(" (")?;
494 let mut first = true;
495 let mut seen = HashSet::new();
496 for author in &log.authors {
497 let author = author.trim().trim_start_matches('@').trim();
498 if !seen.insert(author.to_lowercase()) {
499 continue;
500 }
501
502 if !first {
503 f.write_str(", ")?;
504 }
505 first = false;
506 f.write_char('@')?;
507 f.write_str(author)?;
508 }
509 f.write_char(')')?;
510 }
511
512 let desc = desc.trim();
513
514 if !desc.is_empty() {
515 f.write_str("\n\n")?;
516 f.write_str(desc)?;
517 f.write_char('\n')?;
518 }
519 }
520
521 Ok(())
522 })
523 .to_string()
524 }
525
526 let breaking_changes = make_logs(&breaking_changes);
527 let other_changes = make_logs(&other_changes);
528
529 let mut replaced = String::new();
530
531 replaced.push_str("## [Unreleased]\n");
532
533 if let Some(version) = version {
534 replaced.push_str(&format!(
535 "\n## [{version}](https://github.com/ScuffleCloud/scuffle/releases/tag/{name}-v{version}) - {date}\n\n",
536 date = chrono::Utc::now().date_naive().format("%Y-%m-%d")
537 ));
538
539 if let Some(previous_version) = &previous_version {
540 replaced.push_str(&format!(
541 "[View diff on diff.rs](https://diff.rs/{name}/{previous_version}/{name}/{version}/Cargo.toml)\n",
542 ));
543 }
544 }
545
546 if !breaking_changes.is_empty() {
547 replaced.push_str("\n### ⚠️ Breaking changes\n\n");
548 replaced.push_str(&breaking_changes);
549 replaced.push('\n');
550 }
551
552 if !other_changes.is_empty() {
553 replaced.push_str("\n### 🛠️ Non-breaking changes\n\n");
554 replaced.push_str(&other_changes);
555 replaced.push('\n');
556 }
557
558 change_log = change_log.replace("## [Unreleased]", replaced.trim());
559
560 if !dry_run {
561 std::fs::write(change_log_path_md, change_log).context("failed to write CHANGELOG.md")?;
562 } else {
563 tracing::warn!("not modifying {change_log_path_md} because dry-run");
564 }
565
566 Ok(())
567}
568
569fn generate_change_logs(
570 package: &str,
571 change_fragments: &mut BTreeMap<u64, Fragment>,
572) -> anyhow::Result<Vec<PackageChangeLog>> {
573 let mut logs = Vec::new();
574 let mut seen_logs = HashMap::new();
575
576 for fragment in change_fragments.values_mut() {
577 for log in fragment.remove_package(package).context("parse")? {
578 let key = (log.category.clone(), log.description.clone());
579 match seen_logs.entry(key) {
580 std::collections::hash_map::Entry::Vacant(v) => {
581 v.insert(logs.len());
582 logs.push(log);
583 }
584 std::collections::hash_map::Entry::Occupied(o) => {
585 let old_log = &mut logs[*o.get()];
586 old_log.pr_numbers.extend(log.pr_numbers);
587 old_log.authors.extend(log.authors);
588 old_log.breaking |= log.breaking;
589 }
590 }
591 }
592 }
593
594 Ok(logs)
595}
596
597fn save_change_fragments(fragments: &mut BTreeMap<u64, Fragment>) -> anyhow::Result<()> {
598 fragments
599 .values_mut()
600 .filter(|fragment| fragment.changed())
601 .try_for_each(|fragment| fragment.save().context("save"))?;
602
603 fragments.retain(|_, fragment| !fragment.deleted());
604
605 Ok(())
606}
607
608#[derive(Debug, Clone)]
609pub struct Fragment {
610 path: Utf8PathBuf,
611 pr_number: u64,
612 toml: toml_edit::DocumentMut,
613 changed: bool,
614 deleted: bool,
615}
616
617#[derive(Debug, Clone, Deserialize, Serialize)]
618#[serde(deny_unknown_fields)]
619pub struct PackageChangeLog {
620 #[serde(skip, default)]
621 pub pr_numbers: BTreeSet<u64>,
622 #[serde(alias = "cat")]
623 pub category: String,
624 #[serde(alias = "desc")]
625 pub description: String,
626 #[serde(default, skip_serializing_if = "Vec::is_empty")]
627 #[serde(alias = "author")]
628 pub authors: Vec<String>,
629 #[serde(default, skip_serializing_if = "is_false")]
630 #[serde(alias = "break", alias = "major")]
631 pub breaking: bool,
632}
633
634fn is_false(input: &bool) -> bool {
635 !*input
636}
637
638impl PackageChangeLog {
639 pub fn new(category: impl std::fmt::Display, desc: impl std::fmt::Display) -> Self {
640 Self {
641 pr_numbers: BTreeSet::new(),
642 authors: Vec::new(),
643 breaking: false,
644 category: category.to_string(),
645 description: desc.to_string(),
646 }
647 }
648}
649
650impl Fragment {
651 pub fn new(pr_number: u64, root: &Utf8Path) -> anyhow::Result<Self> {
652 let path = root.join("changes.d").join(format!("pr-{pr_number}.toml"));
653 if path.exists() {
654 let content = std::fs::read_to_string(&path).context("read")?;
655 Ok(Fragment {
656 pr_number,
657 path: path.to_path_buf(),
658 toml: content
659 .parse::<toml_edit::DocumentMut>()
660 .context("change log is not valid toml")?,
661 changed: false,
662 deleted: false,
663 })
664 } else {
665 Ok(Fragment {
666 changed: false,
667 deleted: true,
668 path: path.to_path_buf(),
669 pr_number,
670 toml: DocumentMut::new(),
671 })
672 }
673 }
674
675 pub fn changed(&self) -> bool {
676 self.changed
677 }
678
679 pub fn deleted(&self) -> bool {
680 self.deleted
681 }
682
683 pub fn path(&self) -> &Utf8Path {
684 &self.path
685 }
686
687 pub fn has_package(&self, package: &str) -> bool {
688 self.toml.contains_key(package)
689 }
690
691 pub fn items(&self) -> anyhow::Result<BTreeMap<String, Vec<PackageChangeLog>>> {
692 self.toml
693 .iter()
694 .map(|(package, item)| package_to_logs(self.pr_number, item.clone()).map(|logs| (package.to_owned(), logs)))
695 .collect()
696 }
697
698 pub fn add_log(&mut self, package: &str, log: &PackageChangeLog) {
699 if !self.toml.contains_key(package) {
700 self.toml.insert(package, toml_edit::Item::ArrayOfTables(Default::default()));
701 }
702
703 self.changed = true;
704
705 self.toml[package]
706 .as_array_of_tables_mut()
707 .unwrap()
708 .push(toml_edit::ser::to_document(log).expect("invalid log").as_table().clone())
709 }
710
711 pub fn remove_package(&mut self, package: &str) -> anyhow::Result<Vec<PackageChangeLog>> {
712 let Some(items) = self.toml.remove(package) else {
713 return Ok(Vec::new());
714 };
715
716 self.changed = true;
717
718 package_to_logs(self.pr_number, items)
719 }
720
721 pub fn save(&mut self) -> anyhow::Result<()> {
722 if !self.changed {
723 return Ok(());
724 }
725
726 if self.toml.is_empty() {
727 if !self.deleted {
728 tracing::debug!(path = %self.path, "removing change fragment cause empty");
729 std::fs::remove_file(&self.path).context("remove")?;
730 self.deleted = true;
731 }
732 } else {
733 tracing::debug!(path = %self.path, "saving change fragment");
734 std::fs::write(&self.path, self.toml.to_string()).context("write")?;
735 self.deleted = false;
736 }
737
738 self.changed = false;
739
740 Ok(())
741 }
742}
743
744fn package_to_logs(pr_number: u64, items: toml_edit::Item) -> anyhow::Result<Vec<PackageChangeLog>> {
745 let value = items.into_value().expect("items must be a value").into_deserializer();
746 let mut logs = Vec::<PackageChangeLog>::deserialize(value).context("deserialize")?;
747
748 logs.iter_mut().for_each(|log| {
749 log.category = log.category.to_lowercase();
750 log.pr_numbers = BTreeSet::from_iter([pr_number]);
751 });
752
753 Ok(logs)
754}