1use std::collections::{BTreeMap, BTreeSet, HashSet};
2use std::fmt::Write;
3use std::io::Read;
4use std::process::Stdio;
5
6use anyhow::Context;
7use cargo_metadata::camino::{Utf8Path, Utf8PathBuf};
8use cargo_metadata::{DependencyKind, semver};
9
10use super::utils::Package;
11use crate::cmd::IGNORED_PACKAGES;
12use crate::cmd::release::update::{Fragment, PackageChangeLog};
13use crate::cmd::release::utils::{
14 GitReleaseArtifact, LicenseKind, PackageError, PackageErrorMissing, PackageFile, VersionBump, WorkspaceReleaseMetadata,
15 dep_kind_to_name,
16};
17use crate::utils::{self, Command, DropRunner, cargo_cmd, concurrently, git_workdir_clean, relative_to};
18
19#[derive(Debug, Clone, clap::Parser)]
20pub struct Check {
21 #[arg(long, short = 'n')]
23 pr_number: Option<u64>,
24 #[arg(long, default_value = "origin/main")]
27 base_branch: String,
28 #[arg(long)]
31 all: bool,
32 #[arg(long = "package", short = 'p')]
35 packages: Vec<String>,
36 #[arg(long)]
38 allow_dirty: bool,
39 #[arg(long)]
41 version_change_error: bool,
42 #[arg(long, requires = "pr_number")]
44 fix: bool,
45 #[arg(long)]
47 exit_status: bool,
48 #[arg(long, default_value_t = num_cpus::get())]
50 concurrency: usize,
51 #[arg(long = "author")]
53 authors: Vec<String>,
54}
55
56impl Check {
57 pub fn run(mut self) -> anyhow::Result<()> {
58 if !self.allow_dirty {
59 git_workdir_clean()?;
60 }
61
62 self.authors.iter_mut().for_each(|author| {
63 if !author.starts_with("@") {
64 *author = format!("@{author}");
65 }
66 });
67
68 let metadata = utils::metadata().context("metadata")?;
69 let check_run = CheckRun::new(&metadata, &self.packages).context("check run")?;
70 check_run.process(
71 self.concurrency,
72 &metadata.workspace_root,
73 if self.all { None } else { Some(&self.base_branch) },
74 )?;
75
76 if self.fix && self.pr_number.is_none() {
77 anyhow::bail!("--fix needs --pr-number to be provided");
78 }
79
80 let mut package_changes_markdown = Vec::new();
81 let mut errors_markdown = Vec::new();
82
83 let mut fragment = if let Some(pr_number) = self.pr_number {
84 let fragment = Fragment::new(pr_number, &metadata.workspace_root)?;
85
86 let mut unknown_packages = Vec::new();
87
88 for (package, logs) in fragment.items().context("fragment items")? {
89 let Some(pkg) = check_run.get_package(&package) else {
90 unknown_packages.push(package);
91 continue;
92 };
93
94 pkg.report_change();
95 if logs.iter().any(|l| l.breaking) {
96 pkg.report_breaking_change();
97 }
98 }
99
100 if !unknown_packages.is_empty() {
101 errors_markdown.push("### Changelog Entry\n".into());
102 for package in unknown_packages {
103 errors_markdown.push(format!("* unknown package entry `{package}`"))
104 }
105 }
106
107 Some(fragment)
108 } else {
109 None
110 };
111
112 let base_package_versions = if !self.fix {
113 let git_rev_parse = Command::new("git")
114 .arg("rev-parse")
115 .arg(&self.base_branch)
116 .output()
117 .context("git rev-parse")?;
118
119 if !git_rev_parse.status.success() {
120 anyhow::bail!("git rev-parse failed: {}", String::from_utf8_lossy(&git_rev_parse.stderr));
121 }
122
123 let base_branch_commit = String::from_utf8_lossy(&git_rev_parse.stdout);
124 let base_branch_commit = base_branch_commit.trim();
125
126 let worktree_path = metadata
127 .workspace_root
128 .join("target")
129 .join("release-checks")
130 .join("base-worktree");
131
132 let git_worktree_add = Command::new("git")
133 .arg("worktree")
134 .arg("add")
135 .arg(&worktree_path)
136 .arg(base_branch_commit)
137 .output()
138 .context("git worktree add")?;
139
140 if !git_worktree_add.status.success() {
141 anyhow::bail!(
142 "git worktree add failed: {}",
143 String::from_utf8_lossy(&git_worktree_add.stderr)
144 );
145 }
146
147 let _work_tree_cleanup = DropRunner::new(|| {
148 match Command::new("git")
149 .arg("worktree")
150 .arg("remove")
151 .arg("-f")
152 .arg(&worktree_path)
153 .output()
154 {
155 Ok(output) if output.status.success() => {}
156 Ok(output) => {
157 tracing::error!(path = %worktree_path, "failed to cleanup worktree: {}", String::from_utf8_lossy(&output.stderr));
158 }
159 Err(err) => {
160 tracing::error!(path = %worktree_path, "failed to cleanup worktree: {err}");
161 }
162 }
163 });
164
165 let metadata = utils::metadata_for_manifest(Some(&worktree_path.join("Cargo.toml"))).context("base metadata")?;
166
167 let base_package_versions = metadata
168 .workspace_packages()
169 .into_iter()
170 .filter(|p| !IGNORED_PACKAGES.contains(&p.name.as_ref()))
171 .map(|p| (p.name.as_str().to_owned(), p.version.clone()))
172 .collect::<BTreeMap<_, _>>();
173
174 for (package, version) in &base_package_versions {
175 if let Some(package) = check_run.get_package(package) {
176 if self.version_change_error && &package.version != version {
177 package.report_issue(PackageError::version_changed(version.clone(), package.version.clone()));
178 }
179 } else {
180 tracing::info!("{package} was removed");
181 package_changes_markdown.push(format!("* `{package}`: **removed**"))
182 }
183 }
184
185 Some(base_package_versions)
186 } else {
187 None
188 };
189
190 for package in check_run.groups().flatten() {
191 let _span = tracing::info_span!("check", package = %package.name).entered();
192 if let Some(base_package_versions) = &base_package_versions {
193 package
194 .report(
195 base_package_versions.get(package.name.as_str()),
196 &mut package_changes_markdown,
197 &mut errors_markdown,
198 fragment.as_mut(),
199 )
200 .with_context(|| format!("report {}", package.name.clone()))?;
201 } else {
202 let logs = package
203 .fix(&check_run, &metadata.workspace_root)
204 .with_context(|| format!("fix {}", package.name.clone()))?;
205
206 if let Some(fragment) = fragment.as_mut() {
207 for mut log in logs {
208 log.authors = self.authors.clone();
209 fragment.add_log(&package.name, &log);
210 }
211 }
212 }
213 }
214
215 if let Some(mut fragment) = fragment {
216 if fragment.changed() {
217 tracing::info!(
218 "{} {}",
219 if fragment.deleted() { "creating" } else { "updating" },
220 relative_to(fragment.path(), &metadata.workspace_root),
221 );
222 fragment.save().context("save changelog")?;
223 }
224 }
225
226 if !self.fix {
227 print!(
228 "{}",
229 fmtools::fmt(|f| {
230 if errors_markdown.is_empty() {
231 f.write_str("# ✅ Release Checks Passed\n")?;
232 } else {
233 f.write_str("# ❌ Release Checks Failed\n")?;
234 }
235
236 if !package_changes_markdown.is_empty() {
237 f.write_str("\n## ⭐ Package Changes\n\n")?;
238 for line in &package_changes_markdown {
239 f.write_str(line.trim())?;
240 f.write_char('\n')?;
241 }
242 }
243
244 if !errors_markdown.is_empty() {
245 f.write_str("\n## 💥 Errors \n\n")?;
246 for line in &errors_markdown {
247 f.write_str(line.trim())?;
248 f.write_char('\n')?;
249 }
250 }
251
252 f.write_char('\n')?;
253
254 Ok(())
255 })
256 );
257 }
258
259 if self.exit_status && !errors_markdown.is_empty() {
260 anyhow::bail!("exit requested at any error");
261 }
262
263 tracing::info!("complete");
264
265 Ok(())
266 }
267}
268
269impl Package {
270 #[tracing::instrument(skip_all, fields(package = %self.name))]
271 fn check(
272 &self,
273 packages: &BTreeMap<String, Self>,
274 workspace_root: &Utf8Path,
275 base_branch: Option<&str>,
276 ) -> anyhow::Result<()> {
277 if !base_branch.is_none_or(|branch| self.has_branch_changes(branch)) {
278 tracing::debug!("skipping due to no changes run with --all to check this package");
279 return Ok(());
280 }
281
282 let start = std::time::Instant::now();
283 tracing::debug!("starting validating");
284
285 let license = if self.license.is_none() && self.license_file.is_none() {
286 self.report_issue(PackageErrorMissing::License);
287 LicenseKind::from_text(LicenseKind::MIT_OR_APACHE2)
288 } else if let Some(license) = &self.license {
289 LicenseKind::from_text(license)
290 } else {
291 None
292 };
293
294 if let Some(license) = license {
295 for kind in license {
296 if !self
297 .manifest_path
298 .with_file_name(PackageFile::License(kind).to_string())
299 .exists()
300 {
301 self.report_issue(PackageFile::License(kind));
302 }
303 }
304 }
305
306 if self.should_release() && !self.manifest_path.with_file_name(PackageFile::Readme.to_string()).exists() {
307 self.report_issue(PackageFile::Readme);
308 }
309
310 if self.changelog_path().is_some_and(|path| !path.exists()) {
311 self.report_issue(PackageFile::Changelog);
312 }
313
314 if self.should_release() && self.description.is_none() {
315 self.report_issue(PackageErrorMissing::Description);
316 }
317
318 if self.should_release() && self.readme.is_none() {
319 self.report_issue(PackageErrorMissing::Readme);
320 }
321
322 if self.should_release() && self.repository.is_none() {
323 self.report_issue(PackageErrorMissing::Repopository);
324 }
325
326 if self.should_release() && self.authors.is_empty() {
327 self.report_issue(PackageErrorMissing::Author);
328 }
329
330 if self.should_release() && self.documentation.is_none() {
331 self.report_issue(PackageErrorMissing::Documentation);
332 }
333
334 match self.git_release() {
335 Ok(Some(release)) => {
336 for artifact in &release.artifacts {
337 match artifact {
338 GitReleaseArtifact::File { path, .. } => {
339 if !self.manifest_path.parent().unwrap().join(path).exists() {
340 self.report_issue(PackageError::GitReleaseArtifactFileMissing { path: path.to_string() });
341 }
342 }
343 }
344 }
345 }
346 Ok(None) => {}
347 Err(err) => {
348 self.report_issue(PackageError::GitRelease {
349 error: format!("{err:#}"),
350 });
351 }
352 }
353
354 for dep in &self.dependencies {
355 match &dep.kind {
356 DependencyKind::Build | DependencyKind::Normal => {
357 if let Some(Some(pkg)) = dep.path.is_some().then(|| packages.get(&dep.name)) {
358 if dep.req.comparators.is_empty() && self.should_publish() {
359 self.report_issue(PackageError::missing_version(dep));
360 } else if pkg.group() == self.group()
361 && dep.req.comparators
362 != [semver::Comparator {
363 major: self.version.major,
364 minor: Some(self.version.minor),
365 patch: Some(self.version.patch),
366 op: semver::Op::Exact,
367 pre: self.version.pre.clone(),
368 }]
369 {
370 self.report_issue(PackageError::grouped_version(dep));
371 }
372 } else if self.should_publish() {
373 if dep.registry.is_some()
374 || dep.req.comparators.is_empty()
375 || dep.source.as_ref().is_some_and(|s| !s.is_crates_io())
376 {
377 self.report_issue(PackageError::not_publish(dep));
378 }
379 }
380 }
381 DependencyKind::Development => {
382 if !dep.req.comparators.is_empty() && dep.path.is_some() && packages.contains_key(&dep.name) {
383 self.report_issue(PackageError::has_version(dep));
384 }
385 }
386 _ => continue,
387 }
388 }
389
390 if self.has_changed_since_publish().context("lookup commit")? {
391 tracing::debug!("found git diff since last publish");
392 self.report_change();
393 } else if base_branch.is_some() {
394 tracing::debug!("no released package change, but a branch diff");
395 self.report_change();
396 }
397
398 static SINGLE_THREAD: std::sync::Mutex<()> = std::sync::Mutex::new(());
399
400 if self.should_semver_checks() {
401 match self.last_published_version() {
402 Some(version) if version.vers == self.version => {
403 static ONCE: std::sync::Once = std::sync::Once::new();
404 ONCE.call_once(|| {
405 std::thread::spawn(move || {
406 tracing::info!("running cargo-semver-checks");
407 });
408 });
409
410 tracing::debug!("running semver-checks");
411
412 let _guard = SINGLE_THREAD.lock().unwrap();
413
414 let semver_checks = cargo_cmd()
415 .env("CARGO_TERM_COLOR", "never")
416 .arg("semver-checks")
417 .arg("-p")
418 .arg(self.name.as_ref())
419 .arg("--baseline-version")
420 .arg(version.vers.to_string())
421 .stderr(Stdio::piped())
422 .stdout(Stdio::piped())
423 .output()
424 .context("semver-checks")?;
425
426 let stdout = String::from_utf8_lossy(&semver_checks.stdout);
427 let stdout = stdout.trim().replace(workspace_root.as_str(), ".");
428 if !semver_checks.status.success() {
429 let stderr = String::from_utf8_lossy(&semver_checks.stderr);
430 let stderr = stderr.trim().replace(workspace_root.as_str(), ".");
431 if stdout.is_empty() {
432 anyhow::bail!("semver-checks failed\n{stderr}");
433 } else {
434 self.set_semver_output(stderr.contains("requires new major version"), stdout.to_owned());
435 }
436 } else {
437 self.set_semver_output(false, stdout.to_owned());
438 }
439 }
440 _ => {
441 tracing::info!(
442 "skipping semver-checks because local version ({}) is not published.",
443 self.version
444 );
445 }
446 }
447 }
448
449 if self.should_min_version_check() {
450 let cargo_toml_str = std::fs::read_to_string(&self.manifest_path).context("read Cargo.toml")?;
451 let mut cargo_toml_edit = cargo_toml_str.parse::<toml_edit::DocumentMut>().context("parse Cargo.toml")?;
452
453 cargo_toml_edit.remove("dev-dependencies");
455 if let Some(target) = cargo_toml_edit.get_mut("target").and_then(|t| t.as_table_like_mut()) {
456 for (_, item) in target.iter_mut() {
457 if let Some(table) = item.as_table_like_mut() {
458 table.remove("dev-dependencies");
459 }
460 }
461 }
462
463 let mut dep_packages_stack = Vec::new();
464 let slated_for_release = self.slated_for_release();
465
466 for dep in &self.dependencies {
467 if dep.path.is_none() {
468 continue;
469 }
470
471 let kind = match dep.kind {
472 DependencyKind::Build => "build-dependencies",
473 DependencyKind::Normal => "dependencies",
474 _ => continue,
475 };
476
477 let Some(pkg) = packages.get(&dep.name) else {
478 continue;
479 };
480
481 if let Some(Some(version)) = (dep.req != pkg.unreleased_req()).then(|| {
482 pkg.published_versions()
483 .into_iter()
484 .find(|v| dep.req.matches(&v.vers))
485 .map(|v| v.vers)
486 }) {
487 let root = if let Some(target) = &dep.target {
488 &mut cargo_toml_edit["target"][&target.to_string()]
489 } else {
490 cargo_toml_edit.as_item_mut()
491 };
492
493 let item = root[kind][&dep.name].as_table_like_mut().unwrap();
494
495 let pinned = semver::VersionReq {
496 comparators: vec![semver::Comparator {
497 op: semver::Op::Exact,
498 major: version.major,
499 minor: Some(version.minor),
500 patch: Some(version.patch),
501 pre: version.pre,
502 }],
503 };
504
505 item.remove("path");
506 item.insert("version", pinned.to_string().into());
507 } else {
508 dep_packages_stack.push(pkg);
509 }
510 }
511
512 let mut dep_packages = BTreeSet::new();
513 while let Some(dep_pkg) = dep_packages_stack.pop() {
514 if slated_for_release && !dep_pkg.slated_for_release() {
515 tracing::warn!("depends on {} however that package isnt slated for release", dep_pkg.name);
516 continue;
517 }
518
519 if dep_packages.insert(&dep_pkg.name) {
520 for dep in &dep_pkg.dependencies {
521 if dep.path.is_none() {
522 continue;
523 }
524
525 match dep.kind {
526 DependencyKind::Build | DependencyKind::Normal => {}
527 _ => continue,
528 };
529
530 let Some(pkg) = packages.get(&dep.name) else {
531 continue;
532 };
533
534 if dep.req == pkg.unreleased_req()
535 || pkg
536 .published_versions()
537 .into_iter()
538 .find(|v| dep.req.matches(&v.vers))
539 .map(|v| v.vers)
540 .is_none()
541 {
542 dep_packages_stack.push(pkg);
543 }
544 }
545 }
546 }
547
548 static ONCE: std::sync::Once = std::sync::Once::new();
549 ONCE.call_once(|| {
550 std::thread::spawn(move || {
551 tracing::info!("running min versions check");
552 });
553 });
554
555 let cargo_toml_edit = cargo_toml_edit.to_string();
556 let _guard = SINGLE_THREAD.lock().unwrap();
557 let _guard = if cargo_toml_str != cargo_toml_edit {
558 Some(WriteUndo::new(
559 &self.manifest_path,
560 cargo_toml_edit.as_bytes(),
561 cargo_toml_str.into_bytes(),
562 )?)
563 } else {
564 None
565 };
566
567 let (mut read, write) = std::io::pipe()?;
568
569 let release_checks_dir = workspace_root.join("target").join("release-checks");
570 if release_checks_dir.join("package").exists() {
571 std::fs::remove_dir_all(release_checks_dir.join("package")).context("remove previous package run")?;
572 }
573
574 let mut cmd = cargo_cmd();
575 cmd.env("RUSTC_BOOTSTRAP", "1")
576 .env("CARGO_TERM_COLOR", "never")
577 .stderr(write.try_clone()?)
578 .stdout(write)
579 .arg("-Zunstable-options")
580 .arg("-Zpackage-workspace")
581 .arg("publish")
582 .arg("--dry-run")
583 .arg("--allow-dirty")
584 .arg("--all-features")
585 .arg("--lockfile-path")
586 .arg(release_checks_dir.join("Cargo.lock"))
587 .arg("--target-dir")
588 .arg(release_checks_dir)
589 .arg("-p")
590 .arg(self.name.as_ref());
591
592 for package in &dep_packages {
593 cmd.arg("-p").arg(package.as_str());
594 }
595
596 let mut child = cmd.spawn().context("spawn")?;
597
598 drop(cmd);
599
600 let mut output = String::new();
601 read.read_to_string(&mut output).context("invalid read")?;
602
603 let result = child.wait().context("wait")?;
604 if !result.success() {
605 self.set_min_versions_output(output);
606 }
607 }
608
609 tracing::debug!(after = ?start.elapsed(), "validation finished");
610
611 Ok(())
612 }
613
614 fn fix(&self, check_run: &CheckRun, workspace_root: &Utf8Path) -> anyhow::Result<Vec<PackageChangeLog>> {
615 let cargo_toml_raw = std::fs::read_to_string(&self.manifest_path).context("read cargo toml")?;
616 let mut cargo_toml = cargo_toml_raw.parse::<toml_edit::DocumentMut>().context("parse toml")?;
617 if let Some(min_versions_output) = self.min_versions_output() {
618 tracing::error!("min version error cannot be automatically fixed.");
619 eprintln!("{min_versions_output}");
620 }
621
622 #[derive(PartialEq, PartialOrd, Eq, Ord)]
623 enum ChangelogEntryType {
624 DevDeps,
625 Deps,
626 CargoToml,
627 }
628
629 let mut changelogs = BTreeSet::new();
630
631 for error in self.errors() {
632 match error {
633 PackageError::DevDependencyHasVersion { name, target } => {
634 let deps = if let Some(target) = target {
635 &mut cargo_toml["target"][target.to_string()]
636 } else {
637 cargo_toml.as_item_mut()
638 };
639
640 if deps["dev-dependencies"][&name]
641 .as_table_like_mut()
642 .expect("table like")
643 .remove("version")
644 .is_some()
645 {
646 changelogs.insert(ChangelogEntryType::DevDeps);
647 }
648 }
649 PackageError::DependencyMissingVersion { .. } => {}
650 PackageError::DependencyGroupedVersion { .. } => {}
651 PackageError::DependencyNotPublishable { .. } => {}
652 PackageError::Missing(PackageErrorMissing::Author) => {
653 cargo_toml["package"]["authors"] =
654 toml_edit::Array::from_iter(["Scuffle <[email protected]>"]).into();
655 changelogs.insert(ChangelogEntryType::CargoToml);
656 }
657 PackageError::Missing(PackageErrorMissing::Description) => {
658 cargo_toml["package"]["description"] = format!("{} is a work-in-progress!", self.name).into();
659 changelogs.insert(ChangelogEntryType::CargoToml);
660 }
661 PackageError::Missing(PackageErrorMissing::Documentation) => {
662 cargo_toml["package"]["documentation"] = format!("https://docs.rs/{}", self.name).into();
663 changelogs.insert(ChangelogEntryType::CargoToml);
664 }
665 PackageError::Missing(PackageErrorMissing::License) => {
666 cargo_toml["package"]["license"] = "MIT OR Apache-2.0".into();
667 for file in [
668 PackageFile::License(LicenseKind::Mit),
669 PackageFile::License(LicenseKind::Apache2),
670 ] {
671 let path = self.manifest_path.with_file_name(file.to_string());
672 let file_path = workspace_root.join(file.to_string());
673 let relative_path = relative_to(&file_path, path.parent().unwrap());
674 #[cfg(unix)]
675 {
676 tracing::info!("creating {path}");
677 std::os::unix::fs::symlink(relative_path, path).context("license symlink")?;
678 }
679 #[cfg(not(unix))]
680 {
681 tracing::warn!("cannot symlink {path} to {relative_path}");
682 }
683 }
684 changelogs.insert(ChangelogEntryType::CargoToml);
685 }
686 PackageError::Missing(PackageErrorMissing::ChangelogEntry) => {}
687 PackageError::Missing(PackageErrorMissing::Readme) => {
688 cargo_toml["package"]["readme"] = "README.md".into();
689 changelogs.insert(ChangelogEntryType::CargoToml);
690 }
691 PackageError::Missing(PackageErrorMissing::Repopository) => {
692 cargo_toml["package"]["repository"] = "https://github.com/scufflecloud/scuffle".into();
693 changelogs.insert(ChangelogEntryType::CargoToml);
694 }
695 PackageError::MissingFile(file @ PackageFile::Changelog) => {
696 const CHANGELOG_TEMPLATE: &str = include_str!("./changelog_template.md");
697 let path = self.manifest_path.with_file_name(file.to_string());
698 tracing::info!("creating {}", relative_to(&path, workspace_root));
699 std::fs::write(path, CHANGELOG_TEMPLATE).context("changelog write")?;
700 changelogs.insert(ChangelogEntryType::CargoToml);
701 }
702 PackageError::MissingFile(file @ PackageFile::Readme) => {
703 const README_TEMPLATE: &str = include_str!("./readme_template.md");
704 let path = self.manifest_path.with_file_name(file.to_string());
705 tracing::info!("creating {}", relative_to(&path, workspace_root));
706 std::fs::write(path, README_TEMPLATE).context("readme write")?;
707 changelogs.insert(ChangelogEntryType::CargoToml);
708 }
709 PackageError::MissingFile(file @ PackageFile::License(_)) => {
710 let path = self.manifest_path.with_file_name(file.to_string());
711 let file_path = workspace_root.join(file.to_string());
712 let relative_path = relative_to(&file_path, path.parent().unwrap());
713 #[cfg(unix)]
714 {
715 tracing::info!("creating {path}");
716 std::os::unix::fs::symlink(relative_path, path).context("license symlink")?;
717 }
718 #[cfg(not(unix))]
719 {
720 tracing::warn!("cannot symlink {path} to {relative_path}");
721 }
722 changelogs.insert(ChangelogEntryType::CargoToml);
723 }
724 PackageError::GitRelease { .. } => {}
725 PackageError::GitReleaseArtifactFileMissing { .. } => {}
726 PackageError::VersionChanged { .. } => {}
727 }
728 }
729
730 for dep in &self.dependencies {
731 if !matches!(dep.kind, DependencyKind::Normal | DependencyKind::Build) {
732 continue;
733 }
734
735 let Some(dep_pkg) = check_run.get_package(&dep.name) else {
736 continue;
737 };
738
739 let version = dep_pkg.version.clone();
740 let req = if dep_pkg.group() == self.group() {
741 semver::VersionReq {
742 comparators: vec![semver::Comparator {
743 major: version.major,
744 minor: Some(version.minor),
745 patch: Some(version.patch),
746 pre: version.pre.clone(),
747 op: semver::Op::Exact,
748 }],
749 }
750 } else if !dep.req.matches(&version) {
751 semver::VersionReq {
752 comparators: vec![semver::Comparator {
753 major: version.major,
754 minor: Some(version.minor),
755 patch: Some(version.patch),
756 pre: version.pre.clone(),
757 op: semver::Op::Caret,
758 }],
759 }
760 } else {
761 continue;
762 };
763
764 if req == dep.req {
765 continue;
766 }
767
768 let table = if let Some(target) = &dep.target {
769 &mut cargo_toml["target"][target.to_string()][dep_kind_to_name(&dep.kind)]
770 } else {
771 &mut cargo_toml[dep_kind_to_name(&dep.kind)]
772 };
773
774 changelogs.insert(ChangelogEntryType::Deps);
775 table[&dep.name]["version"] = req.to_string().into();
776 }
777
778 let cargo_toml_updated = cargo_toml.to_string();
779 if cargo_toml_updated != cargo_toml_raw {
780 tracing::info!(
781 "{}",
782 fmtools::fmt(|f| {
783 f.write_str("updating ")?;
784 f.write_str(relative_to(&self.manifest_path, workspace_root).as_str())?;
785 Ok(())
786 })
787 );
788 std::fs::write(&self.manifest_path, cargo_toml.to_string()).context("manifest write")?;
789 }
790
791 Ok(if self.changelog_path().is_some() {
792 changelogs
793 .into_iter()
794 .map(|log| match log {
795 ChangelogEntryType::CargoToml => PackageChangeLog::new("docs", "cleaned up documentation"),
796 ChangelogEntryType::Deps => PackageChangeLog::new("chore", "cleaned up grouped dependencies"),
797 ChangelogEntryType::DevDeps => PackageChangeLog::new("chore", "cleaned up dev-dependencies"),
798 })
799 .collect()
800 } else {
801 Vec::new()
802 })
803 }
804
805 fn report(
806 &self,
807 base_package_version: Option<&semver::Version>,
808 package_changes: &mut Vec<String>,
809 errors_markdown: &mut Vec<String>,
810 fragment: Option<&mut Fragment>,
811 ) -> anyhow::Result<()> {
812 let semver_output = self.semver_output();
813
814 let version_bump = self.version_bump();
815 let slated_for_release = self.slated_for_release();
816
817 let version_changed = base_package_version.is_none_or(|v| v != &self.version);
818
819 if version_bump.is_some() || slated_for_release || version_changed {
820 package_changes.push(
821 fmtools::fmt(|f| {
822 f.write_str("* ")?;
823 if !self.should_release() {
824 f.write_str("🔒 ")?;
825 }
826 write!(f, "`{}`:", self.name)?;
827
828 if base_package_version.is_none() {
829 f.write_str(" 📦 **New crate**")?;
830 } else if let Some(bump) = &version_bump {
831 f.write_str(match bump {
832 VersionBump::Major => " ⚠️ **Breaking Change**",
833 VersionBump::Minor => " 🛠️ **Compatiable Change**",
834 })?;
835 }
836
837 if slated_for_release {
838 f.write_str(" 🚀 **Releasing on merge**")?;
839 }
840
841 let mut f = indent_write::fmt::IndentWriter::new(" ", f);
842
843 match base_package_version {
844 Some(base) if base != &self.version => {
845 write!(f, "\n* Version: **`{base}`** ➡️ **`{}`**", self.version)?
846 }
847 None => write!(f, "\n* Version: **`{}`**", self.version)?,
848 Some(_) => {}
849 }
850
851 if version_changed && self.group() != self.name.as_str() {
852 write!(f, " (group: **`{}`**)", self.group())?;
853 }
854
855 if let Some((true, logs)) = &semver_output {
856 f.write_str("\n\n")?;
857 f.write_str("<details><summary>Cargo semver-checks details</summary>\n\n````\n")?;
858 f.write_str(logs)?;
859 f.write_str("\n````\n\n</details>\n")?;
860 }
861
862 Ok(())
863 })
864 .to_string(),
865 );
866 }
867
868 let mut errors = self.errors();
869 if let Some(fragment) = &fragment {
870 if !fragment.has_package(&self.name) && self.version_bump().is_some() && self.changelog_path().is_some() {
871 tracing::warn!(package = %self.name, "changelog entry must be provided");
872 errors.insert(0, PackageError::Missing(PackageErrorMissing::ChangelogEntry));
873 }
874 }
875
876 let min_versions_output = self.min_versions_output();
877
878 if !errors.is_empty() || min_versions_output.is_some() {
879 errors_markdown.push(
880 fmtools::fmt(|f| {
881 writeln!(f, "### {}", self.name)?;
882 for error in errors.iter() {
883 writeln!(f, "* {error}")?;
884 }
885 if let Some(min_versions_output) = &min_versions_output {
886 let mut f = indent_write::fmt::IndentWriter::new(" ", f);
887 f.write_str("<details><summary>min package versions</summary>\n\n````\n")?;
888 f.write_str(min_versions_output)?;
889 f.write_str("\n````\n\n</details>\n")?;
890 }
891 Ok(())
892 })
893 .to_string(),
894 );
895 }
896
897 Ok(())
898 }
899}
900
901pub struct CheckRun {
902 packages: BTreeMap<String, Package>,
903 accepted_groups: HashSet<String>,
904 groups: BTreeMap<String, Vec<Package>>,
905}
906
907impl CheckRun {
908 pub fn new(metadata: &cargo_metadata::Metadata, allowed_packages: &[String]) -> anyhow::Result<Self> {
909 let workspace_metadata = WorkspaceReleaseMetadata::from_metadadata(metadata).context("workspace metadata")?;
910 let members = metadata.workspace_members.iter().cloned().collect::<HashSet<_>>();
911 let packages = metadata
912 .packages
913 .iter()
914 .filter(|p| members.contains(&p.id) && !IGNORED_PACKAGES.contains(&p.name.as_ref()))
915 .map(|p| Ok((p.name.as_ref().to_owned(), Package::new(&workspace_metadata, p.clone())?)))
916 .collect::<anyhow::Result<BTreeMap<_, _>>>()?;
917
918 let accepted_groups = packages
919 .values()
920 .filter(|p| allowed_packages.contains(&p.name) || allowed_packages.is_empty())
921 .map(|p| p.group().to_owned())
922 .collect::<HashSet<_>>();
923
924 let groups = packages
925 .values()
926 .cloned()
927 .fold(BTreeMap::<_, Vec<_>>::new(), |mut groups, package| {
928 let entry = groups.entry(package.group().to_owned()).or_default();
929 if package.name.as_ref() == package.group() {
930 entry.insert(0, package);
931 } else {
932 entry.push(package);
933 }
934
935 groups
936 });
937
938 Ok(Self {
939 accepted_groups,
940 groups,
941 packages,
942 })
943 }
944
945 pub fn process(&self, concurrency: usize, workspace_root: &Utf8Path, base_branch: Option<&str>) -> anyhow::Result<()> {
946 let clean_target = || {
947 let release_check_path = workspace_root.join("target").join("release-checks").join("package");
948 if release_check_path.exists() {
949 if let Err(err) = std::fs::remove_dir_all(release_check_path) {
950 tracing::error!("failed to cleanup release-checks package folder: {err}")
951 }
952 }
953
954 let release_check_path = workspace_root.join("target").join("semver-checks");
955 if release_check_path.exists() {
956 let input = || {
957 let dir = release_check_path.read_dir_utf8()?;
958
959 for file in dir {
960 let file = file?;
961 if file.file_name().starts_with("local-") {
962 std::fs::remove_dir_all(file.path())?;
963 }
964 }
965
966 std::io::Result::Ok(())
967 };
968 if let Err(err) = input() {
969 tracing::error!("failed to cleanup semver-checks package folder: {err}")
970 }
971 }
972 };
973
974 clean_target();
975 let _drop_runner = DropRunner::new(clean_target);
976
977 concurrently::<_, _, anyhow::Result<()>>(concurrency, self.all_packages(), |p| p.fetch_published())?;
978
979 concurrently::<_, _, anyhow::Result<()>>(concurrency, self.groups().flatten(), |p| {
980 p.check(&self.packages, workspace_root, base_branch)
981 })?;
982
983 Ok(())
984 }
985
986 pub fn get_package(&self, name: impl AsRef<str>) -> Option<&Package> {
987 self.packages.get(name.as_ref())
988 }
989
990 pub fn is_accepted_group(&self, group: impl AsRef<str>) -> bool {
991 self.accepted_groups.contains(group.as_ref())
992 }
993
994 pub fn all_packages(&self) -> impl Iterator<Item = &'_ Package> {
995 self.packages.values()
996 }
997
998 pub fn groups(&self) -> impl Iterator<Item = &'_ [Package]> {
999 self.groups
1000 .iter()
1001 .filter_map(|(name, group)| self.is_accepted_group(name).then_some(group))
1002 .map(|g| g.as_slice())
1003 }
1004
1005 pub fn all_groups(&self) -> impl Iterator<Item = &'_ [Package]> {
1006 self.groups.values().map(|g| g.as_slice())
1007 }
1008}
1009
1010struct WriteUndo {
1011 og: Vec<u8>,
1012 path: Utf8PathBuf,
1013}
1014
1015impl WriteUndo {
1016 fn new(path: &Utf8Path, content: &[u8], og: Vec<u8>) -> anyhow::Result<Self> {
1017 std::fs::write(path, content).context("write")?;
1018 Ok(Self {
1019 og,
1020 path: path.to_path_buf(),
1021 })
1022 }
1023}
1024
1025impl Drop for WriteUndo {
1026 fn drop(&mut self) {
1027 if let Err(err) = std::fs::write(&self.path, &self.og) {
1028 tracing::error!(path = %self.path, "failed to undo write: {err}");
1029 }
1030 }
1031}