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            && 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            // Remove dev-dependencies to prevent them from effecting cargo's version resolution.
453            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}