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