xtask/cmd/release/
check.rs

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    /// The pull request number
22    #[arg(long, short = 'n')]
23    pr_number: Option<u64>,
24    /// The base branch to compare against to determine
25    /// if something has changed.
26    #[arg(long, default_value = "origin/main")]
27    base_branch: String,
28    /// Check everything, even if there are no changes
29    /// from this branch to the base branch.
30    #[arg(long)]
31    all: bool,
32    /// Packages to include in the check
33    /// by default all packages are included
34    #[arg(long = "package", short = 'p')]
35    packages: Vec<String>,
36    /// Allow the command to execute even if there are uncomitted changes in the workspace
37    #[arg(long)]
38    allow_dirty: bool,
39    /// Report version changes as an error.
40    #[arg(long)]
41    version_change_error: bool,
42    /// Attempts to fix some of the issues.
43    #[arg(long, requires = "pr_number")]
44    fix: bool,
45    /// Return a non-zero exit status at the end if a check failed.
46    #[arg(long)]
47    exit_status: bool,
48    /// Concurrency to run at. By default, this is the total number of cpus on the host.
49    #[arg(long, default_value_t = num_cpus::get())]
50    concurrency: usize,
51    /// Author to use for the changelog entries
52    #[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            // Remove dev-dependencies to prevent them from effecting cargo's version resolution.
454            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}