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