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