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 if is_accepted_group {
308 pr_body.push_str(
309 &fmtools::fmt(|mut f| {
310 let mut f = indent_write::fmt::IndentWriter::new(" ", &mut f);
311 write!(f, "* `{}`: ", package.name)?;
312 let last_published = package.last_published_version();
313 f.write_str(match &last_published {
314 None => " 📦 **New Crate**",
315 Some(v) if vers_to_comp(v.vers.clone()).matches(version) => " ✨ **Minor**",
316 Some(_) => " 🚀 **Major**",
317 })?;
318
319 let mut f = indent_write::fmt::IndentWriter::new(" ", f);
320 match &last_published {
321 Some(base) => write!(f, "\n* Version: **`{}`** ➡️ **`{version}`**", base.vers)?,
322 None => write!(f, "\n* Version: **`{version}`**")?,
323 }
324
325 if package.group() != package.name.as_str() {
326 write!(f, " (group: **`{}`**)", package.group())?;
327 }
328 f.write_str("\n")?;
329 Ok(())
330 })
331 .to_string(),
332 );
333
334 if let Some(workspace_metadata_packages) = &mut workspace_metadata_packages {
335 workspace_metadata_packages.insert(package.name.as_str(), version.to_string().into());
336 }
337
338 cargo_toml_edit["package"]["version"] = version.to_string().into();
339 }
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 {
391 if let Some((_, changelogs)) = changelogs.as_mut() {
392 let mut log =
393 PackageChangeLog::new("chore", format!("bump {} to `{pkg_version}`", dep.name));
394 log.breaking = package.is_dep_public(&dep.name);
395 changelogs.push(log)
396 }
397 }
398
399 pkg_version.to_string()
400 };
401
402 item.insert("version", version.into());
403 }
404
405 let cargo_toml = cargo_toml_edit.to_string();
406 if cargo_toml != cargo_toml_raw {
407 if !self.dry_run {
408 tracing::debug!("updating {}", package.manifest_path);
409 std::fs::write(&package.manifest_path, cargo_toml).context("write cargo toml")?;
410 } else {
411 tracing::warn!("not modifying {} because dry-run", package.manifest_path);
412 }
413 }
414 }
415
416 if let Some((change_log_path_md, changelogs)) = changelogs {
417 update_change_log(
418 &changelogs,
419 &change_log_path_md,
420 &package.name,
421 version.as_ref(),
422 package.last_published_version().map(|v| v.vers).as_ref(),
423 self.dry_run,
424 )
425 .context("update")?;
426 if !self.dry_run {
427 save_change_fragments(&mut change_fragments).context("save")?;
428 }
429 tracing::info!(package = %package.name, "updated change logs");
430 }
431 }
432
433 if let Some((workspace_manifest, workspace_manifest_str)) = workspace_manifest {
434 let workspace_manifest = workspace_manifest.to_string();
435 if workspace_manifest != workspace_manifest_str {
436 if self.dry_run {
437 tracing::warn!("skipping write of {workspace_manifest_path}")
438 } else {
439 std::fs::write(&workspace_manifest_path, workspace_manifest).context("write workspace metadata")?;
440 }
441 }
442 }
443
444 if release_count != 0 {
445 println!("{}", pr_body.trim());
446 } else {
447 tracing::info!("no packages to release!");
448 }
449
450 Ok(())
451 }
452}
453
454fn update_change_log(
455 logs: &[PackageChangeLog],
456 change_log_path_md: &Utf8Path,
457 name: &str,
458 version: Option<&Version>,
459 previous_version: Option<&Version>,
460 dry_run: bool,
461) -> anyhow::Result<()> {
462 let mut change_log = std::fs::read_to_string(change_log_path_md).context("failed to read CHANGELOG.md")?;
463
464 let (mut breaking_changes, mut other_changes) = logs.iter().partition::<Vec<_>, _>(|log| log.breaking);
467 breaking_changes.sort_by_key(|log| &log.category);
468 other_changes.sort_by_key(|log| &log.category);
469
470 fn make_logs(logs: &[&PackageChangeLog]) -> String {
471 fmtools::fmt(|f| {
472 let mut first = true;
473 for log in logs {
474 if !first {
475 f.write_char('\n')?;
476 }
477 first = false;
478
479 let (tag, desc) = log.description.split_once('\n').unwrap_or((&log.description, ""));
480 write!(f, "- {category}: {tag}", category = log.category, tag = tag.trim(),)?;
481
482 if !log.pr_numbers.is_empty() {
483 f.write_str(" (")?;
484 let mut first = true;
485 for pr_number in &log.pr_numbers {
486 if !first {
487 f.write_str(", ")?;
488 }
489 first = false;
490 write!(f, "[#{pr_number}](https://github.com/scufflecloud/scuffle/pull/{pr_number})")?;
491 }
492 f.write_str(")")?;
493 }
494
495 if !log.authors.is_empty() {
496 f.write_str(" (")?;
497 let mut first = true;
498 let mut seen = HashSet::new();
499 for author in &log.authors {
500 let author = author.trim().trim_start_matches('@').trim();
501 if !seen.insert(author.to_lowercase()) {
502 continue;
503 }
504
505 if !first {
506 f.write_str(", ")?;
507 }
508 first = false;
509 f.write_char('@')?;
510 f.write_str(author)?;
511 }
512 f.write_char(')')?;
513 }
514
515 let desc = desc.trim();
516
517 if !desc.is_empty() {
518 f.write_str("\n\n")?;
519 f.write_str(desc)?;
520 f.write_char('\n')?;
521 }
522 }
523
524 Ok(())
525 })
526 .to_string()
527 }
528
529 let breaking_changes = make_logs(&breaking_changes);
530 let other_changes = make_logs(&other_changes);
531
532 let mut replaced = String::new();
533
534 replaced.push_str("## [Unreleased]\n");
535
536 if let Some(version) = version {
537 replaced.push_str(&format!(
538 "\n## [{version}](https://github.com/ScuffleCloud/scuffle/releases/tag/{name}-v{version}) - {date}\n\n",
539 date = chrono::Utc::now().date_naive().format("%Y-%m-%d")
540 ));
541
542 if let Some(previous_version) = &previous_version {
543 replaced.push_str(&format!(
544 "[View diff on diff.rs](https://diff.rs/{name}/{previous_version}/{name}/{version}/Cargo.toml)\n",
545 ));
546 }
547 }
548
549 if !breaking_changes.is_empty() {
550 replaced.push_str("\n### ⚠️ Breaking changes\n\n");
551 replaced.push_str(&breaking_changes);
552 replaced.push('\n');
553 }
554
555 if !other_changes.is_empty() {
556 replaced.push_str("\n### 🛠️ Non-breaking changes\n\n");
557 replaced.push_str(&other_changes);
558 replaced.push('\n');
559 }
560
561 change_log = change_log.replace("## [Unreleased]", replaced.trim());
562
563 if !dry_run {
564 std::fs::write(change_log_path_md, change_log).context("failed to write CHANGELOG.md")?;
565 } else {
566 tracing::warn!("not modifying {change_log_path_md} because dry-run");
567 }
568
569 Ok(())
570}
571
572fn generate_change_logs(
573 package: &str,
574 change_fragments: &mut BTreeMap<u64, Fragment>,
575) -> anyhow::Result<Vec<PackageChangeLog>> {
576 let mut logs = Vec::new();
577 let mut seen_logs = HashMap::new();
578
579 for fragment in change_fragments.values_mut() {
580 for log in fragment.remove_package(package).context("parse")? {
581 let key = (log.category.clone(), log.description.clone());
582 match seen_logs.entry(key) {
583 std::collections::hash_map::Entry::Vacant(v) => {
584 v.insert(logs.len());
585 logs.push(log);
586 }
587 std::collections::hash_map::Entry::Occupied(o) => {
588 let old_log = &mut logs[*o.get()];
589 old_log.pr_numbers.extend(log.pr_numbers);
590 old_log.authors.extend(log.authors);
591 old_log.breaking |= log.breaking;
592 }
593 }
594 }
595 }
596
597 Ok(logs)
598}
599
600fn save_change_fragments(fragments: &mut BTreeMap<u64, Fragment>) -> anyhow::Result<()> {
601 fragments
602 .values_mut()
603 .filter(|fragment| fragment.changed())
604 .try_for_each(|fragment| fragment.save().context("save"))?;
605
606 fragments.retain(|_, fragment| !fragment.deleted());
607
608 Ok(())
609}
610
611#[derive(Debug, Clone)]
612pub struct Fragment {
613 path: Utf8PathBuf,
614 pr_number: u64,
615 toml: toml_edit::DocumentMut,
616 changed: bool,
617 deleted: bool,
618}
619
620#[derive(Debug, Clone, Deserialize, Serialize)]
621#[serde(deny_unknown_fields)]
622pub struct PackageChangeLog {
623 #[serde(skip, default)]
624 pub pr_numbers: BTreeSet<u64>,
625 #[serde(alias = "cat")]
626 pub category: String,
627 #[serde(alias = "desc")]
628 pub description: String,
629 #[serde(default, skip_serializing_if = "Vec::is_empty")]
630 #[serde(alias = "author")]
631 pub authors: Vec<String>,
632 #[serde(default, skip_serializing_if = "is_false")]
633 #[serde(alias = "break", alias = "major")]
634 pub breaking: bool,
635}
636
637fn is_false(input: &bool) -> bool {
638 !*input
639}
640
641impl PackageChangeLog {
642 pub fn new(category: impl std::fmt::Display, desc: impl std::fmt::Display) -> Self {
643 Self {
644 pr_numbers: BTreeSet::new(),
645 authors: Vec::new(),
646 breaking: false,
647 category: category.to_string(),
648 description: desc.to_string(),
649 }
650 }
651}
652
653impl Fragment {
654 pub fn new(pr_number: u64, root: &Utf8Path) -> anyhow::Result<Self> {
655 let path = root.join("changes.d").join(format!("pr-{pr_number}.toml"));
656 if path.exists() {
657 let content = std::fs::read_to_string(&path).context("read")?;
658 Ok(Fragment {
659 pr_number,
660 path: path.to_path_buf(),
661 toml: content
662 .parse::<toml_edit::DocumentMut>()
663 .context("change log is not valid toml")?,
664 changed: false,
665 deleted: false,
666 })
667 } else {
668 Ok(Fragment {
669 changed: false,
670 deleted: true,
671 path: path.to_path_buf(),
672 pr_number,
673 toml: DocumentMut::new(),
674 })
675 }
676 }
677
678 pub fn changed(&self) -> bool {
679 self.changed
680 }
681
682 pub fn deleted(&self) -> bool {
683 self.deleted
684 }
685
686 pub fn path(&self) -> &Utf8Path {
687 &self.path
688 }
689
690 pub fn has_package(&self, package: &str) -> bool {
691 self.toml.contains_key(package)
692 }
693
694 pub fn items(&self) -> anyhow::Result<BTreeMap<String, Vec<PackageChangeLog>>> {
695 self.toml
696 .iter()
697 .map(|(package, item)| package_to_logs(self.pr_number, item.clone()).map(|logs| (package.to_owned(), logs)))
698 .collect()
699 }
700
701 pub fn add_log(&mut self, package: &str, log: &PackageChangeLog) {
702 if !self.toml.contains_key(package) {
703 self.toml.insert(package, toml_edit::Item::ArrayOfTables(Default::default()));
704 }
705
706 self.changed = true;
707
708 self.toml[package]
709 .as_array_of_tables_mut()
710 .unwrap()
711 .push(toml_edit::ser::to_document(log).expect("invalid log").as_table().clone())
712 }
713
714 pub fn remove_package(&mut self, package: &str) -> anyhow::Result<Vec<PackageChangeLog>> {
715 let Some(items) = self.toml.remove(package) else {
716 return Ok(Vec::new());
717 };
718
719 self.changed = true;
720
721 package_to_logs(self.pr_number, items)
722 }
723
724 pub fn save(&mut self) -> anyhow::Result<()> {
725 if !self.changed {
726 return Ok(());
727 }
728
729 if self.toml.is_empty() {
730 if !self.deleted {
731 tracing::debug!(path = %self.path, "removing change fragment cause empty");
732 std::fs::remove_file(&self.path).context("remove")?;
733 self.deleted = true;
734 }
735 } else {
736 tracing::debug!(path = %self.path, "saving change fragment");
737 std::fs::write(&self.path, self.toml.to_string()).context("write")?;
738 self.deleted = false;
739 }
740
741 self.changed = false;
742
743 Ok(())
744 }
745}
746
747fn package_to_logs(pr_number: u64, items: toml_edit::Item) -> anyhow::Result<Vec<PackageChangeLog>> {
748 let value = items.into_value().expect("items must be a value").into_deserializer();
749 let mut logs = Vec::<PackageChangeLog>::deserialize(value).context("deserialize")?;
750
751 logs.iter_mut().for_each(|log| {
752 log.category = log.category.to_lowercase();
753 log.pr_numbers = BTreeSet::from_iter([pr_number]);
754 });
755
756 Ok(logs)
757}