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 && fragment.changed()
217 {
218 tracing::info!(
219 "{} {}",
220 if fragment.deleted() { "creating" } else { "updating" },
221 relative_to(fragment.path(), &metadata.workspace_root),
222 );
223 fragment.save().context("save changelog")?;
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 && (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 DependencyKind::Development => {
381 if !dep.req.comparators.is_empty() && dep.path.is_some() && packages.contains_key(&dep.name) {
382 self.report_issue(PackageError::has_version(dep));
383 }
384 }
385 _ => continue,
386 }
387 }
388
389 if self.has_changed_since_publish().context("lookup commit")? {
390 tracing::debug!("found git diff since last publish");
391 self.report_change();
392 } else if base_branch.is_some() {
393 tracing::debug!("no released package change, but a branch diff");
394 self.report_change();
395 }
396
397 static SINGLE_THREAD: std::sync::Mutex<()> = std::sync::Mutex::new(());
398
399 if self.should_semver_checks() {
400 match self.last_published_version() {
401 Some(version) if version.vers == self.version => {
402 static ONCE: std::sync::Once = std::sync::Once::new();
403 ONCE.call_once(|| {
404 std::thread::spawn(move || {
405 tracing::info!("running cargo-semver-checks");
406 });
407 });
408
409 tracing::debug!("running semver-checks");
410
411 let _guard = SINGLE_THREAD.lock().unwrap();
412
413 let semver_checks = cargo_cmd()
414 .env("CARGO_TERM_COLOR", "never")
415 .arg("semver-checks")
416 .arg("-p")
417 .arg(self.name.as_ref())
418 .arg("--baseline-version")
419 .arg(version.vers.to_string())
420 .stderr(Stdio::piped())
421 .stdout(Stdio::piped())
422 .output()
423 .context("semver-checks")?;
424
425 let stdout = String::from_utf8_lossy(&semver_checks.stdout);
426 let stdout = stdout.trim().replace(workspace_root.as_str(), ".");
427 if !semver_checks.status.success() {
428 let stderr = String::from_utf8_lossy(&semver_checks.stderr);
429 let stderr = stderr.trim().replace(workspace_root.as_str(), ".");
430 if stdout.is_empty() {
431 anyhow::bail!("semver-checks failed\n{stderr}");
432 } else {
433 self.set_semver_output(stderr.contains("requires new major version"), stdout.to_owned());
434 }
435 } else {
436 self.set_semver_output(false, stdout.to_owned());
437 }
438 }
439 _ => {
440 tracing::info!(
441 "skipping semver-checks because local version ({}) is not published.",
442 self.version
443 );
444 }
445 }
446 }
447
448 if self.should_min_version_check() {
449 let cargo_toml_str = std::fs::read_to_string(&self.manifest_path).context("read Cargo.toml")?;
450 let mut cargo_toml_edit = cargo_toml_str.parse::<toml_edit::DocumentMut>().context("parse Cargo.toml")?;
451
452 cargo_toml_edit.remove("dev-dependencies");
454 if let Some(target) = cargo_toml_edit.get_mut("target").and_then(|t| t.as_table_like_mut()) {
455 for (_, item) in target.iter_mut() {
456 if let Some(table) = item.as_table_like_mut() {
457 table.remove("dev-dependencies");
458 }
459 }
460 }
461
462 let mut dep_packages_stack = Vec::new();
463 let slated_for_release = self.slated_for_release();
464
465 for dep in &self.dependencies {
466 if dep.path.is_none() {
467 continue;
468 }
469
470 let kind = match dep.kind {
471 DependencyKind::Build => "build-dependencies",
472 DependencyKind::Normal => "dependencies",
473 _ => continue,
474 };
475
476 let Some(pkg) = packages.get(&dep.name) else {
477 continue;
478 };
479
480 if let Some(Some(version)) = (dep.req != pkg.unreleased_req() && pkg.group() != self.group()).then(|| {
481 pkg.published_versions()
482 .into_iter()
483 .find(|v| dep.req.matches(&v.vers))
484 .map(|v| v.vers)
485 }) {
486 let root = if let Some(target) = &dep.target {
487 &mut cargo_toml_edit["target"][&target.to_string()]
488 } else {
489 cargo_toml_edit.as_item_mut()
490 };
491
492 let item = root[kind][&dep.name].as_table_like_mut().unwrap();
493
494 let pinned = semver::VersionReq {
495 comparators: vec![semver::Comparator {
496 op: semver::Op::Exact,
497 major: version.major,
498 minor: Some(version.minor),
499 patch: Some(version.patch),
500 pre: version.pre,
501 }],
502 };
503
504 item.remove("path");
505 item.insert("version", pinned.to_string().into());
506 } else {
507 dep_packages_stack.push(pkg);
508 }
509 }
510
511 let mut dep_packages = BTreeSet::new();
512 while let Some(dep_pkg) = dep_packages_stack.pop() {
513 if slated_for_release && !dep_pkg.slated_for_release() {
514 tracing::warn!("depends on {} however that package isnt slated for release", dep_pkg.name);
515 continue;
516 }
517
518 if dep_packages.insert(&dep_pkg.name) {
519 for dep in &dep_pkg.dependencies {
520 if dep.path.is_none() {
521 continue;
522 }
523
524 match dep.kind {
525 DependencyKind::Build | DependencyKind::Normal => {}
526 _ => continue,
527 };
528
529 let Some(pkg) = packages.get(&dep.name) else {
530 continue;
531 };
532
533 if dep.req == pkg.unreleased_req()
534 || pkg
535 .published_versions()
536 .into_iter()
537 .find(|v| dep.req.matches(&v.vers))
538 .map(|v| v.vers)
539 .is_none()
540 {
541 dep_packages_stack.push(pkg);
542 }
543 }
544 }
545 }
546
547 static ONCE: std::sync::Once = std::sync::Once::new();
548 ONCE.call_once(|| {
549 std::thread::spawn(move || {
550 tracing::info!("running min versions check");
551 });
552 });
553
554 let cargo_toml_edit = cargo_toml_edit.to_string();
555 let _guard = SINGLE_THREAD.lock().unwrap();
556 let _guard = if cargo_toml_str != cargo_toml_edit {
557 Some(WriteUndo::new(
558 &self.manifest_path,
559 cargo_toml_edit.as_bytes(),
560 cargo_toml_str.into_bytes(),
561 )?)
562 } else {
563 None
564 };
565
566 let (mut read, write) = std::io::pipe()?;
567
568 let release_checks_dir = workspace_root.join("target").join("release-checks");
569 if release_checks_dir.join("package").exists() {
570 std::fs::remove_dir_all(release_checks_dir.join("package")).context("remove previous package run")?;
571 }
572
573 let mut cmd = cargo_cmd();
574 cmd.env("RUSTC_BOOTSTRAP", "1")
575 .env("CARGO_TERM_COLOR", "never")
576 .stderr(write.try_clone()?)
577 .stdout(write)
578 .arg("-Zunstable-options")
579 .arg("-Zpackage-workspace")
580 .arg("publish")
581 .arg("--dry-run")
582 .arg("--allow-dirty")
583 .arg("--all-features")
584 .arg("--lockfile-path")
585 .arg(release_checks_dir.join("Cargo.lock"))
586 .arg("--target-dir")
587 .arg(release_checks_dir)
588 .arg("-p")
589 .arg(self.name.as_ref());
590
591 for package in &dep_packages {
592 cmd.arg("-p").arg(package.as_str());
593 }
594
595 let mut child = cmd.spawn().context("spawn")?;
596
597 drop(cmd);
598
599 let mut output = String::new();
600 read.read_to_string(&mut output).context("invalid read")?;
601
602 let result = child.wait().context("wait")?;
603 if !result.success() {
604 self.set_min_versions_output(output);
605 }
606 }
607
608 tracing::debug!(after = ?start.elapsed(), "validation finished");
609
610 Ok(())
611 }
612
613 fn fix(&self, check_run: &CheckRun, workspace_root: &Utf8Path) -> anyhow::Result<Vec<PackageChangeLog>> {
614 let cargo_toml_raw = std::fs::read_to_string(&self.manifest_path).context("read cargo toml")?;
615 let mut cargo_toml = cargo_toml_raw.parse::<toml_edit::DocumentMut>().context("parse toml")?;
616 if let Some(min_versions_output) = self.min_versions_output() {
617 tracing::error!("min version error cannot be automatically fixed.");
618 eprintln!("{min_versions_output}");
619 }
620
621 #[derive(PartialEq, PartialOrd, Eq, Ord)]
622 enum ChangelogEntryType {
623 DevDeps,
624 Deps,
625 CargoToml,
626 }
627
628 let mut changelogs = BTreeSet::new();
629
630 for error in self.errors() {
631 match error {
632 PackageError::DevDependencyHasVersion { name, target } => {
633 let deps = if let Some(target) = target {
634 &mut cargo_toml["target"][target.to_string()]
635 } else {
636 cargo_toml.as_item_mut()
637 };
638
639 if deps["dev-dependencies"][&name]
640 .as_table_like_mut()
641 .expect("table like")
642 .remove("version")
643 .is_some()
644 {
645 changelogs.insert(ChangelogEntryType::DevDeps);
646 }
647 }
648 PackageError::DependencyMissingVersion { .. } => {}
649 PackageError::DependencyGroupedVersion { .. } => {}
650 PackageError::DependencyNotPublishable { .. } => {}
651 PackageError::Missing(PackageErrorMissing::Author) => {
652 cargo_toml["package"]["authors"] =
653 toml_edit::Array::from_iter(["Scuffle <opensource@scuffle.cloud>"]).into();
654 changelogs.insert(ChangelogEntryType::CargoToml);
655 }
656 PackageError::Missing(PackageErrorMissing::Description) => {
657 cargo_toml["package"]["description"] = format!("{} is a work-in-progress!", self.name).into();
658 changelogs.insert(ChangelogEntryType::CargoToml);
659 }
660 PackageError::Missing(PackageErrorMissing::Documentation) => {
661 cargo_toml["package"]["documentation"] = format!("https://docs.rs/{}", self.name).into();
662 changelogs.insert(ChangelogEntryType::CargoToml);
663 }
664 PackageError::Missing(PackageErrorMissing::License) => {
665 cargo_toml["package"]["license"] = "MIT OR Apache-2.0".into();
666 for file in [
667 PackageFile::License(LicenseKind::Mit),
668 PackageFile::License(LicenseKind::Apache2),
669 ] {
670 let path = self.manifest_path.with_file_name(file.to_string());
671 let file_path = workspace_root.join(file.to_string());
672 let relative_path = relative_to(&file_path, path.parent().unwrap());
673 #[cfg(unix)]
674 {
675 tracing::info!("creating {path}");
676 std::os::unix::fs::symlink(relative_path, path).context("license symlink")?;
677 }
678 #[cfg(not(unix))]
679 {
680 tracing::warn!("cannot symlink {path} to {relative_path}");
681 }
682 }
683 changelogs.insert(ChangelogEntryType::CargoToml);
684 }
685 PackageError::Missing(PackageErrorMissing::ChangelogEntry) => {}
686 PackageError::Missing(PackageErrorMissing::Readme) => {
687 cargo_toml["package"]["readme"] = "README.md".into();
688 changelogs.insert(ChangelogEntryType::CargoToml);
689 }
690 PackageError::Missing(PackageErrorMissing::Repopository) => {
691 cargo_toml["package"]["repository"] = "https://github.com/scufflecloud/scuffle".into();
692 changelogs.insert(ChangelogEntryType::CargoToml);
693 }
694 PackageError::MissingFile(file @ PackageFile::Changelog) => {
695 const CHANGELOG_TEMPLATE: &str = include_str!("./changelog_template.md");
696 let path = self.manifest_path.with_file_name(file.to_string());
697 tracing::info!("creating {}", relative_to(&path, workspace_root));
698 std::fs::write(path, CHANGELOG_TEMPLATE).context("changelog write")?;
699 changelogs.insert(ChangelogEntryType::CargoToml);
700 }
701 PackageError::MissingFile(file @ PackageFile::Readme) => {
702 const README_TEMPLATE: &str = include_str!("./readme_template.md");
703 let path = self.manifest_path.with_file_name(file.to_string());
704 tracing::info!("creating {}", relative_to(&path, workspace_root));
705 std::fs::write(path, README_TEMPLATE).context("readme write")?;
706 changelogs.insert(ChangelogEntryType::CargoToml);
707 }
708 PackageError::MissingFile(file @ PackageFile::License(_)) => {
709 let path = self.manifest_path.with_file_name(file.to_string());
710 let file_path = workspace_root.join(file.to_string());
711 let relative_path = relative_to(&file_path, path.parent().unwrap());
712 #[cfg(unix)]
713 {
714 tracing::info!("creating {path}");
715 std::os::unix::fs::symlink(relative_path, path).context("license symlink")?;
716 }
717 #[cfg(not(unix))]
718 {
719 tracing::warn!("cannot symlink {path} to {relative_path}");
720 }
721 changelogs.insert(ChangelogEntryType::CargoToml);
722 }
723 PackageError::GitRelease { .. } => {}
724 PackageError::GitReleaseArtifactFileMissing { .. } => {}
725 PackageError::VersionChanged { .. } => {}
726 }
727 }
728
729 for dep in &self.dependencies {
730 if !matches!(dep.kind, DependencyKind::Normal | DependencyKind::Build) {
731 continue;
732 }
733
734 let Some(dep_pkg) = check_run.get_package(&dep.name) else {
735 continue;
736 };
737
738 let version = dep_pkg.version.clone();
739 let req = if dep_pkg.group() == self.group() {
740 semver::VersionReq {
741 comparators: vec![semver::Comparator {
742 major: version.major,
743 minor: Some(version.minor),
744 patch: Some(version.patch),
745 pre: version.pre.clone(),
746 op: semver::Op::Exact,
747 }],
748 }
749 } else if !dep.req.matches(&version) {
750 semver::VersionReq {
751 comparators: vec![semver::Comparator {
752 major: version.major,
753 minor: Some(version.minor),
754 patch: Some(version.patch),
755 pre: version.pre.clone(),
756 op: semver::Op::Caret,
757 }],
758 }
759 } else {
760 continue;
761 };
762
763 if req == dep.req {
764 continue;
765 }
766
767 let table = if let Some(target) = &dep.target {
768 &mut cargo_toml["target"][target.to_string()][dep_kind_to_name(&dep.kind)]
769 } else {
770 &mut cargo_toml[dep_kind_to_name(&dep.kind)]
771 };
772
773 changelogs.insert(ChangelogEntryType::Deps);
774 table[&dep.name]["version"] = req.to_string().into();
775 }
776
777 let cargo_toml_updated = cargo_toml.to_string();
778 if cargo_toml_updated != cargo_toml_raw {
779 tracing::info!(
780 "{}",
781 fmtools::fmt(|f| {
782 f.write_str("updating ")?;
783 f.write_str(relative_to(&self.manifest_path, workspace_root).as_str())?;
784 Ok(())
785 })
786 );
787 std::fs::write(&self.manifest_path, cargo_toml.to_string()).context("manifest write")?;
788 }
789
790 Ok(if self.changelog_path().is_some() {
791 changelogs
792 .into_iter()
793 .map(|log| match log {
794 ChangelogEntryType::CargoToml => PackageChangeLog::new("docs", "cleaned up documentation"),
795 ChangelogEntryType::Deps => PackageChangeLog::new("chore", "cleaned up grouped dependencies"),
796 ChangelogEntryType::DevDeps => PackageChangeLog::new("chore", "cleaned up dev-dependencies"),
797 })
798 .collect()
799 } else {
800 Vec::new()
801 })
802 }
803
804 fn report(
805 &self,
806 base_package_version: Option<&semver::Version>,
807 package_changes: &mut Vec<String>,
808 errors_markdown: &mut Vec<String>,
809 fragment: Option<&mut Fragment>,
810 ) -> anyhow::Result<()> {
811 let semver_output = self.semver_output();
812
813 let version_bump = self.version_bump();
814 let slated_for_release = self.slated_for_release();
815
816 let version_changed = base_package_version.is_none_or(|v| v != &self.version);
817
818 if version_bump.is_some() || slated_for_release || version_changed {
819 package_changes.push(
820 fmtools::fmt(|f| {
821 f.write_str("* ")?;
822 if !self.should_release() {
823 f.write_str("🔒 ")?;
824 }
825 write!(f, "`{}`:", self.name)?;
826
827 if base_package_version.is_none() {
828 f.write_str(" 📦 **New crate**")?;
829 } else if let Some(bump) = &version_bump {
830 f.write_str(match bump {
831 VersionBump::Major => " ⚠️ **Breaking Change**",
832 VersionBump::Minor => " 🛠️ **Compatiable Change**",
833 })?;
834 }
835
836 if slated_for_release {
837 f.write_str(" 🚀 **Releasing on merge**")?;
838 }
839
840 let mut f = indent_write::fmt::IndentWriter::new(" ", f);
841
842 match base_package_version {
843 Some(base) if base != &self.version => {
844 write!(f, "\n* Version: **`{base}`** ➡️ **`{}`**", self.version)?
845 }
846 None => write!(f, "\n* Version: **`{}`**", self.version)?,
847 Some(_) => {}
848 }
849
850 if version_changed && self.group() != self.name.as_str() {
851 write!(f, " (group: **`{}`**)", self.group())?;
852 }
853
854 if let Some((true, logs)) = &semver_output {
855 f.write_str("\n\n")?;
856 f.write_str("<details><summary>Cargo semver-checks details</summary>\n\n````\n")?;
857 f.write_str(logs)?;
858 f.write_str("\n````\n\n</details>\n")?;
859 }
860
861 Ok(())
862 })
863 .to_string(),
864 );
865 }
866
867 let mut errors = self.errors();
868 if let Some(fragment) = &fragment
869 && !fragment.has_package(&self.name)
870 && self.version_bump().is_some()
871 && self.changelog_path().is_some()
872 {
873 tracing::warn!(package = %self.name, "changelog entry must be provided");
874 errors.insert(0, PackageError::Missing(PackageErrorMissing::ChangelogEntry));
875 }
876
877 let min_versions_output = self.min_versions_output();
878
879 if !errors.is_empty() || min_versions_output.is_some() {
880 errors_markdown.push(
881 fmtools::fmt(|f| {
882 writeln!(f, "### {}", self.name)?;
883 for error in errors.iter() {
884 writeln!(f, "* {error}")?;
885 }
886 if let Some(min_versions_output) = &min_versions_output {
887 let mut f = indent_write::fmt::IndentWriter::new(" ", f);
888 f.write_str("<details><summary>min package versions</summary>\n\n````\n")?;
889 f.write_str(min_versions_output)?;
890 f.write_str("\n````\n\n</details>\n")?;
891 }
892 Ok(())
893 })
894 .to_string(),
895 );
896 }
897
898 Ok(())
899 }
900}
901
902pub struct CheckRun {
903 packages: BTreeMap<String, Package>,
904 accepted_groups: HashSet<String>,
905 groups: BTreeMap<String, Vec<Package>>,
906}
907
908impl CheckRun {
909 pub fn new(metadata: &cargo_metadata::Metadata, allowed_packages: &[String]) -> anyhow::Result<Self> {
910 let workspace_metadata = WorkspaceReleaseMetadata::from_metadadata(metadata).context("workspace metadata")?;
911 let members = metadata.workspace_members.iter().cloned().collect::<HashSet<_>>();
912 let packages = metadata
913 .packages
914 .iter()
915 .filter(|p| members.contains(&p.id) && !IGNORED_PACKAGES.contains(&p.name.as_ref()))
916 .map(|p| Ok((p.name.as_ref().to_owned(), Package::new(&workspace_metadata, p.clone())?)))
917 .collect::<anyhow::Result<BTreeMap<_, _>>>()?;
918
919 let accepted_groups = packages
920 .values()
921 .filter(|p| allowed_packages.contains(&p.name) || allowed_packages.is_empty())
922 .map(|p| p.group().to_owned())
923 .collect::<HashSet<_>>();
924
925 let groups = packages
926 .values()
927 .cloned()
928 .fold(BTreeMap::<_, Vec<_>>::new(), |mut groups, package| {
929 let entry = groups.entry(package.group().to_owned()).or_default();
930 if package.name.as_ref() == package.group() {
931 entry.insert(0, package);
932 } else {
933 entry.push(package);
934 }
935
936 groups
937 });
938
939 Ok(Self {
940 accepted_groups,
941 groups,
942 packages,
943 })
944 }
945
946 pub fn process(&self, concurrency: usize, workspace_root: &Utf8Path, base_branch: Option<&str>) -> anyhow::Result<()> {
947 let clean_target = || {
948 let release_check_path = workspace_root.join("target").join("release-checks").join("package");
949 if release_check_path.exists()
950 && let Err(err) = std::fs::remove_dir_all(release_check_path)
951 {
952 tracing::error!("failed to cleanup release-checks package folder: {err}")
953 }
954
955 let release_check_path = workspace_root.join("target").join("semver-checks");
956 if release_check_path.exists() {
957 let input = || {
958 let dir = release_check_path.read_dir_utf8()?;
959
960 for file in dir {
961 let file = file?;
962 if file.file_name().starts_with("local-") {
963 std::fs::remove_dir_all(file.path())?;
964 }
965 }
966
967 std::io::Result::Ok(())
968 };
969 if let Err(err) = input() {
970 tracing::error!("failed to cleanup semver-checks package folder: {err}")
971 }
972 }
973 };
974
975 clean_target();
976 let _drop_runner = DropRunner::new(clean_target);
977
978 concurrently::<_, _, anyhow::Result<()>>(concurrency, self.all_packages(), |p| p.fetch_published())?;
979
980 concurrently::<_, _, anyhow::Result<()>>(concurrency, self.groups().flatten(), |p| {
981 p.check(&self.packages, workspace_root, base_branch)
982 })?;
983
984 Ok(())
985 }
986
987 pub fn get_package(&self, name: impl AsRef<str>) -> Option<&Package> {
988 self.packages.get(name.as_ref())
989 }
990
991 pub fn is_accepted_group(&self, group: impl AsRef<str>) -> bool {
992 self.accepted_groups.contains(group.as_ref())
993 }
994
995 pub fn all_packages(&self) -> impl Iterator<Item = &'_ Package> {
996 self.packages.values()
997 }
998
999 pub fn groups(&self) -> impl Iterator<Item = &'_ [Package]> {
1000 self.groups
1001 .iter()
1002 .filter_map(|(name, group)| self.is_accepted_group(name).then_some(group))
1003 .map(|g| g.as_slice())
1004 }
1005
1006 pub fn all_groups(&self) -> impl Iterator<Item = &'_ [Package]> {
1007 self.groups.values().map(|g| g.as_slice())
1008 }
1009}
1010
1011struct WriteUndo {
1012 og: Vec<u8>,
1013 path: Utf8PathBuf,
1014}
1015
1016impl WriteUndo {
1017 fn new(path: &Utf8Path, content: &[u8], og: Vec<u8>) -> anyhow::Result<Self> {
1018 std::fs::write(path, content).context("write")?;
1019 Ok(Self {
1020 og,
1021 path: path.to_path_buf(),
1022 })
1023 }
1024}
1025
1026impl Drop for WriteUndo {
1027 fn drop(&mut self) {
1028 if let Err(err) = std::fs::write(&self.path, &self.og) {
1029 tracing::error!(path = %self.path, "failed to undo write: {err}");
1030 }
1031 }
1032}