Skip to main content

cargo_semver_checks/
lib.rs

1#![forbid(unsafe_code)]
2
3mod callbacks;
4mod check_release;
5mod config;
6mod data_generation;
7mod manifest;
8mod query;
9mod rustdoc_gen;
10mod templating;
11mod util;
12mod witness_gen;
13
14use anyhow::Context;
15use cargo_metadata::PackageId;
16use clap::ValueEnum;
17use data_generation::{DataStorage, IntoTerminalResult as _, TerminalError};
18use directories::ProjectDirs;
19use itertools::Itertools;
20use serde::Serialize;
21
22use std::collections::{BTreeMap, HashSet};
23use std::io::Write as _;
24use std::path::{Path, PathBuf};
25use std::time::Duration;
26
27use check_release::{CheckReleaseSettings, LintResult, PendingCrateReport, run_check_release};
28use rustdoc_gen::CrateDataForRustdoc;
29
30pub use config::{FeatureFlag, GlobalConfig};
31pub use query::{
32    ActualSemverUpdate, LintLevel, OverrideMap, OverrideStack, QueryOverride, RequiredSemverUpdate,
33    SemverQuery, Witness, WitnessPurpose,
34};
35
36/// Test a release for semver violations.
37#[non_exhaustive]
38#[derive(Debug, PartialEq, Eq, Serialize)]
39pub struct Check {
40    /// Which packages to analyze.
41    scope: Scope,
42    current: Rustdoc,
43    baseline: Rustdoc,
44
45    /// Whether we should consider stability attributes when determining public API status.
46    /// Stability attributes are not currently stable, and are only used internally
47    /// in the Rust standard library crates themselves.
48    #[serde(skip_serializing_if = "RustdocIndexingMode::is_ordinary")]
49    rustdoc_indexing_mode: RustdocIndexingMode,
50
51    release_type: Option<ReleaseType>,
52    current_feature_config: rustdoc_gen::FeatureConfig,
53    baseline_feature_config: rustdoc_gen::FeatureConfig,
54    /// Which `--target` to use, if unset pass no flag
55    build_target: Option<String>,
56    /// Options for generating [witnesses](Witness).
57    witness_generation: WitnessGeneration,
58}
59
60/// The kind of release we're making.
61///
62/// Affects which lints are executed.
63/// Non-exhaustive in case we want to add "pre-release" as an option in the future.
64#[non_exhaustive]
65#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Serialize)]
66pub enum ReleaseType {
67    Major,
68    Minor,
69    Patch,
70}
71
72#[non_exhaustive]
73#[derive(Debug, PartialEq, Eq, Serialize)]
74pub struct Rustdoc {
75    source: RustdocSource,
76}
77
78/// How rustdoc JSON should be indexed for semver checking.
79#[doc(hidden)]
80#[non_exhaustive]
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
82pub enum RustdocIndexingMode {
83    /// Index rustdoc JSON using ordinary crate public API rules.
84    #[default]
85    Ordinary,
86    /// Index rustdoc JSON while honoring structured stability metadata.
87    StabilityAware,
88}
89
90impl RustdocIndexingMode {
91    fn is_ordinary(&self) -> bool {
92        matches!(self, Self::Ordinary)
93    }
94}
95
96impl Rustdoc {
97    /// Use an existing rustdoc file.
98    pub fn from_path(rustdoc_path: impl Into<PathBuf>) -> Self {
99        Self {
100            source: RustdocSource::Rustdoc(rustdoc_path.into()),
101        }
102    }
103
104    /// Generate the rustdoc file from the project root directory,
105    /// i.e. the directory containing the crate source.
106    /// It can be a workspace or a single package.
107    /// Same as [`Rustdoc::from_git_revision()`], but with the current git revision.
108    pub fn from_root(project_root: impl Into<PathBuf>) -> Self {
109        Self {
110            source: RustdocSource::Root(project_root.into()),
111        }
112    }
113
114    /// Generate the rustdoc file from the project at a given git revision.
115    pub fn from_git_revision(
116        project_root: impl Into<PathBuf>,
117        revision: impl Into<String>,
118    ) -> Self {
119        Self {
120            source: RustdocSource::Revision(project_root.into(), revision.into()),
121        }
122    }
123
124    /// Generate the rustdoc file from the largest-numbered non-yanked non-prerelease version
125    /// published to the cargo registry. If no such version, uses
126    /// the largest-numbered version including yanked and prerelease versions.
127    pub fn from_registry_latest_crate_version() -> Self {
128        Self {
129            source: RustdocSource::VersionFromRegistry(None),
130        }
131    }
132
133    /// Generate the rustdoc file from a specific crate version.
134    pub fn from_registry(crate_version: impl Into<String>) -> Self {
135        Self {
136            source: RustdocSource::VersionFromRegistry(Some(crate_version.into())),
137        }
138    }
139}
140
141#[derive(Debug, PartialEq, Eq, Serialize)]
142enum RustdocSource {
143    /// Path to the Rustdoc json file.
144    /// Use this option when you have already generated the rustdoc file.
145    Rustdoc(PathBuf),
146    /// Project root directory, i.e. the directory containing the crate source.
147    /// It can be a workspace or a single package.
148    Root(PathBuf),
149    /// Project root directory and Git Revision.
150    Revision(PathBuf, String),
151    /// Version from cargo registry to lookup. E.g. "1.0.0".
152    /// If `None`, uses the largest-numbered non-yanked non-prerelease version
153    /// published to the cargo registry. If no such version, uses
154    /// the largest-numbered version including yanked and prerelease versions.
155    VersionFromRegistry(Option<String>),
156}
157
158/// Which packages to analyze.
159#[derive(Default, Debug, PartialEq, Eq, Serialize)]
160struct Scope {
161    mode: ScopeMode,
162}
163
164#[derive(Debug, PartialEq, Eq, Serialize)]
165enum ScopeMode {
166    /// All packages except the excluded ones.
167    DenyList(PackageSelection),
168    /// Packages to process (see `cargo help pkgid`)
169    AllowList(Vec<String>),
170}
171
172impl Default for ScopeMode {
173    fn default() -> Self {
174        Self::DenyList(PackageSelection::default())
175    }
176}
177
178#[non_exhaustive]
179#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize)]
180pub struct PackageSelection {
181    selection: ScopeSelection,
182    explicitly_included_packages: Vec<String>,
183    excluded_packages: Vec<String>,
184}
185
186impl PackageSelection {
187    pub fn new(selection: ScopeSelection) -> Self {
188        Self {
189            selection,
190            explicitly_included_packages: vec![],
191            excluded_packages: vec![],
192        }
193    }
194
195    pub fn set_explicitly_included_packages(&mut self, packages: Vec<String>) -> &mut Self {
196        self.explicitly_included_packages = packages;
197        self
198    }
199
200    pub fn set_excluded_packages(&mut self, packages: Vec<String>) -> &mut Self {
201        self.excluded_packages = packages;
202        self
203    }
204}
205
206#[non_exhaustive]
207#[derive(Default, Debug, PartialEq, Eq, Clone, Serialize)]
208pub enum ScopeSelection {
209    /// All packages in the workspace. Equivalent to `--workspace`.
210    Workspace,
211    /// Default members of the workspace.
212    #[default]
213    DefaultMembers,
214}
215
216impl Scope {
217    /// Returns `(selected, skipped)` packages
218    fn selected_packages<'m>(
219        &self,
220        meta: &'m cargo_metadata::Metadata,
221    ) -> (
222        Vec<&'m cargo_metadata::Package>,
223        Vec<&'m cargo_metadata::Package>,
224    ) {
225        let workspace_members: HashSet<&PackageId> = meta.workspace_members.iter().collect();
226        let base_ids: HashSet<&PackageId> = match &self.mode {
227            ScopeMode::DenyList(PackageSelection {
228                selection,
229                explicitly_included_packages: _,
230                excluded_packages,
231            }) => {
232                let packages = match selection {
233                    ScopeSelection::Workspace => workspace_members,
234                    ScopeSelection::DefaultMembers => {
235                        // Deviating from cargo because Metadata doesn't have default members
236                        let resolve = meta.resolve.as_ref().expect("no-deps is unsupported");
237                        match &resolve.root {
238                            Some(root) => {
239                                let mut base_ids = HashSet::new();
240                                base_ids.insert(root);
241                                base_ids
242                            }
243                            None => workspace_members,
244                        }
245                    }
246                };
247
248                packages
249                    .iter()
250                    .filter(|p| !excluded_packages.contains(&meta[p].name))
251                    .copied()
252                    .collect()
253            }
254            ScopeMode::AllowList(patterns) => {
255                meta.packages
256                    .iter()
257                    // Deviating from cargo by not supporting patterns
258                    // Deviating from cargo by only checking workspace members
259                    .filter(|p| workspace_members.contains(&p.id) && patterns.contains(&p.name))
260                    .map(|p| &p.id)
261                    .collect()
262            }
263        };
264
265        meta.packages
266            .iter()
267            .filter(|&p| {
268                // The package has to not have been explicitly excluded
269                base_ids.contains(&p.id)
270            })
271            .partition(|&p| p.targets.iter().any(is_lib_like_checkable_target))
272    }
273}
274
275struct CrateToCheck<'a> {
276    overrides: OverrideStack,
277    current_crate_data: CrateDataForRustdoc<'a>,
278    baseline_crate_data: CrateDataForRustdoc<'a>,
279}
280
281/// Is the specified target able to be semver-checked as a library, of any sort.
282///
283/// This is a broader definition than cargo's own "lib" definition, since we can also
284/// semver-check rlib, dylib, and staticlib targets as well.
285#[expect(
286    clippy::unneeded_struct_pattern,
287    reason = "we don't want a breaking change if the target variants change from unit variants to a different kind"
288)]
289fn is_lib_like_checkable_target(target: &cargo_metadata::Target) -> bool {
290    target.is_lib()
291        || target.kind.iter().any(|kind| {
292            matches!(
293                kind,
294                cargo_metadata::TargetKind::RLib { .. }
295                    | cargo_metadata::TargetKind::DyLib { .. }
296                    | cargo_metadata::TargetKind::CDyLib { .. }
297                    | cargo_metadata::TargetKind::StaticLib { .. }
298            )
299        })
300}
301
302impl Check {
303    pub fn new(current: Rustdoc) -> Self {
304        Self {
305            scope: Scope::default(),
306            current,
307            baseline: Rustdoc::from_registry_latest_crate_version(),
308            rustdoc_indexing_mode: RustdocIndexingMode::default(),
309            release_type: None,
310            current_feature_config: rustdoc_gen::FeatureConfig::default_for_current(),
311            baseline_feature_config: rustdoc_gen::FeatureConfig::default_for_baseline(),
312            build_target: None,
313            witness_generation: WitnessGeneration::default(),
314        }
315    }
316
317    pub fn set_package_selection(&mut self, selection: PackageSelection) -> &mut Self {
318        self.scope.mode = ScopeMode::DenyList(selection);
319        self
320    }
321
322    pub fn set_packages(&mut self, packages: Vec<String>) -> &mut Self {
323        self.scope.mode = ScopeMode::AllowList(packages);
324        self
325    }
326
327    pub fn set_baseline(&mut self, baseline: Rustdoc) -> &mut Self {
328        self.baseline = baseline;
329        self
330    }
331
332    pub fn set_release_type(&mut self, release_type: ReleaseType) -> &mut Self {
333        self.release_type = Some(release_type);
334        self
335    }
336
337    #[doc(hidden)]
338    pub fn set_rustdoc_indexing_mode(&mut self, mode: RustdocIndexingMode) -> &mut Self {
339        self.rustdoc_indexing_mode = mode;
340        self
341    }
342
343    pub fn with_only_explicit_features(&mut self) -> &mut Self {
344        self.current_feature_config.features_group = rustdoc_gen::FeaturesGroup::None;
345        self.baseline_feature_config.features_group = rustdoc_gen::FeaturesGroup::None;
346        self
347    }
348
349    pub fn with_default_features(&mut self) -> &mut Self {
350        self.current_feature_config.features_group = rustdoc_gen::FeaturesGroup::Default;
351        self.baseline_feature_config.features_group = rustdoc_gen::FeaturesGroup::Default;
352        self
353    }
354
355    pub fn with_heuristically_included_features(&mut self) -> &mut Self {
356        self.current_feature_config.features_group = rustdoc_gen::FeaturesGroup::Heuristic;
357        self.baseline_feature_config.features_group = rustdoc_gen::FeaturesGroup::Heuristic;
358        self
359    }
360
361    pub fn with_all_features(&mut self) -> &mut Self {
362        self.current_feature_config.features_group = rustdoc_gen::FeaturesGroup::All;
363        self.baseline_feature_config.features_group = rustdoc_gen::FeaturesGroup::All;
364        self
365    }
366
367    pub fn set_extra_features(
368        &mut self,
369        extra_current_features: Vec<String>,
370        extra_baseline_features: Vec<String>,
371    ) -> &mut Self {
372        self.current_feature_config.extra_features = extra_current_features;
373        self.baseline_feature_config.extra_features = extra_baseline_features;
374        self
375    }
376
377    /// Set what `--target` to build the documentation with, by default will not pass any flag
378    /// relying on the users cargo configuration.
379    pub fn set_build_target(&mut self, build_target: String) -> &mut Self {
380        self.build_target = Some(build_target);
381        self
382    }
383
384    /// Set the options for generating witness code.  See [`WitnessGeneration`] for more.
385    pub fn set_witness_generation(&mut self, witness_generation: WitnessGeneration) -> &mut Self {
386        self.witness_generation = witness_generation;
387        self
388    }
389
390    /// Some `RustdocSource`s don't contain a path to the project root,
391    /// so they don't have a target directory. We try to deduce the target directory
392    /// on a "best effort" basis -- when the source contains a target dir,
393    /// we use it, otherwise when the other source contains one, we use it,
394    /// otherwise we just use a standard cache folder as specified by XDG.
395    /// We cannot use a temporary directory, because the rustdocs from registry
396    /// are being cached in the target directory.
397    fn get_target_dir(&self, source: &RustdocSource) -> anyhow::Result<PathBuf> {
398        Ok(
399            if let Some(path) = get_target_dir_from_project_root(source)? {
400                path
401            } else if let Some(path) = get_target_dir_from_project_root(&self.current.source)? {
402                path
403            } else if let Some(path) = get_target_dir_from_project_root(&self.baseline.source)? {
404                path
405            } else {
406                get_cache_dir()?
407            },
408        )
409    }
410
411    fn get_rustdoc_generator(
412        &self,
413        config: &mut GlobalConfig,
414        source: &RustdocSource,
415    ) -> anyhow::Result<rustdoc_gen::RustdocGenerator> {
416        let target_dir = self.get_target_dir(source)?;
417        Ok(match source {
418            RustdocSource::Rustdoc(path) => {
419                rustdoc_gen::RustdocFromFile::new(path.to_owned()).into()
420            }
421            RustdocSource::Root(root) => {
422                rustdoc_gen::RustdocFromProjectRoot::new(root, &target_dir)?.into()
423            }
424            RustdocSource::Revision(root, rev) => {
425                let metadata = manifest_metadata_no_deps(root)?;
426                let source = metadata.workspace_root.as_std_path();
427                rustdoc_gen::RustdocFromGitRevision::with_rev(source, &target_dir, rev, config)?
428                    .into()
429            }
430            RustdocSource::VersionFromRegistry(version) => {
431                let mut registry = rustdoc_gen::RustdocFromRegistry::new(&target_dir, config)?;
432                if let Some(ver) = version {
433                    let semver = semver::Version::parse(ver)?;
434                    registry.set_version(semver);
435                }
436                registry.into()
437            }
438        })
439    }
440
441    pub fn check_release(&self, config: &mut GlobalConfig) -> anyhow::Result<Report> {
442        let generation_settings = data_generation::GenerationSettings {
443            use_color: config.err_color_choice(),
444            pass_through_stderr: config.is_verbose(),
445        };
446
447        // If both the current and baseline rustdoc are given explicitly as a file path,
448        // we don't need to use the installed rustc, and this check can be skipped.
449        if !(matches!(self.current.source, RustdocSource::Rustdoc(_))
450            && matches!(self.baseline.source, RustdocSource::Rustdoc(_)))
451        {
452            let rustc_version_needed = config.minimum_rustc_version();
453            match rustc_version::version() {
454                Ok(rustc_version) => {
455                    if rustc_version < *rustc_version_needed {
456                        let help = "HELP: to use the latest rustc, run `rustup update stable && cargo +stable semver-checks <args>`";
457                        anyhow::bail!(
458                            "rustc version is not high enough: >={rustc_version_needed} needed, got {rustc_version}\n\n{help}"
459                        );
460                    }
461                }
462                Err(error) => {
463                    let help = format!(
464                        "HELP: to avoid errors please ensure rustc >={rustc_version_needed} is used"
465                    );
466                    config.shell_warn(format_args!(
467                        "failed to determine the current rustc version: {error}\n\n{help}"
468                    ))?;
469                }
470            };
471        }
472
473        let crates_to_check: Vec<CrateToCheck<'_>> = match &self.current.source {
474            RustdocSource::Rustdoc(_)
475            | RustdocSource::Revision(_, _)
476            | RustdocSource::VersionFromRegistry(_) => {
477                let names = match &self.scope.mode {
478                    ScopeMode::DenyList(_) => match &self.current.source {
479                        RustdocSource::Rustdoc(_) => {
480                            // This is a user-facing string.
481                            // For example, it appears when two pre-generated rustdoc files
482                            // are semver-checked against each other.
483                            vec!["<unknown>".to_string()]
484                        }
485                        _ => anyhow::bail!(
486                            "couldn't deduce crate name, specify one through the package allow list"
487                        ),
488                    },
489                    ScopeMode::AllowList(lst) => lst.clone(),
490                };
491                names
492                    .into_iter()
493                    .map(|name| {
494                        let version = None;
495                        CrateToCheck {
496                            overrides: OverrideStack::new(),
497                            current_crate_data: CrateDataForRustdoc {
498                                crate_type: rustdoc_gen::CrateType::Current,
499                                name: name.clone(),
500                                feature_config: &self.current_feature_config,
501                                build_target: self.build_target.as_deref(),
502                            },
503                            baseline_crate_data: CrateDataForRustdoc {
504                                crate_type: rustdoc_gen::CrateType::Baseline {
505                                    highest_allowed_version: version,
506                                },
507                                name,
508                                feature_config: &self.baseline_feature_config,
509                                build_target: self.build_target.as_deref(),
510                            },
511                        }
512                    })
513                    .collect()
514            }
515            RustdocSource::Root(project_root) => {
516                let metadata = manifest_metadata(project_root)?;
517                let (selected, skipped) = self.scope.selected_packages(&metadata);
518                if selected.is_empty() {
519                    let help = if skipped.is_empty() {
520                        "".to_string()
521                    } else {
522                        let skipped = skipped.iter().map(|&p| &p.name).join(", ");
523                        format!(
524                            "
525note: only library targets contain an API surface that can be checked for semver
526note: skipped the following crates since they have no library target: {skipped}"
527                        )
528                    };
529                    anyhow::bail!(
530                        "no crates with library targets selected, nothing to semver-check{help}"
531                    );
532                }
533
534                let workspace_overrides =
535                    manifest::deserialize_lint_table(&metadata.workspace_metadata)
536                        .context("[workspace.metadata.cargo-semver-checks] table is invalid")?
537                        .map(|table| table.into_stack());
538
539                selected
540                    .iter()
541                    .map(|selected| {
542                        let crate_name = &selected.name;
543                        let version = &selected.version;
544
545                        // If the manifest we're using points to a workspace, then
546                        // ignore `publish = false` crates unless they are specifically selected.
547                        // If the manifest points to a specific crate, then check the crate
548                        // even if `publish = false` is set.
549                        let is_explicitly_included = match &self.scope.mode {
550                            ScopeMode::AllowList(packages) => packages.contains(&selected.name),
551                            ScopeMode::DenyList(PackageSelection {
552                                explicitly_included_packages,
553                                ..
554                            }) => explicitly_included_packages.contains(&selected.name),
555                        };
556                        let is_implied = !is_explicitly_included
557                            && metadata.workspace_members.len() > 1
558                            && selected.publish == Some(vec![]);
559                        if is_implied {
560                            config.log_verbose(|config| {
561                                config.shell_status(
562                                    "Skipping",
563                                    format_args!("{crate_name} v{version} (current)"),
564                                )
565                            })?;
566                            Ok(None)
567                        } else {
568                            let overrides = overrides_for_workspace_package(
569                                selected,
570                                workspace_overrides.as_deref(),
571                            )?;
572
573                            Ok(Some(CrateToCheck {
574                                overrides,
575                                current_crate_data: CrateDataForRustdoc {
576                                    crate_type: rustdoc_gen::CrateType::Current,
577                                    name: crate_name.to_string(),
578                                    feature_config: &self.current_feature_config,
579                                    build_target: self.build_target.as_deref(),
580                                },
581                                baseline_crate_data: CrateDataForRustdoc {
582                                    crate_type: rustdoc_gen::CrateType::Baseline {
583                                        highest_allowed_version: Some(version.clone()),
584                                    },
585                                    name: crate_name.to_string(),
586                                    feature_config: &self.baseline_feature_config,
587                                    build_target: self.build_target.as_deref(),
588                                },
589                            }))
590                        }
591                    })
592                    .filter_map(|res| res.transpose())
593                    .collect::<Result<Vec<_>, anyhow::Error>>()?
594            }
595        };
596
597        let current_loader = self.get_rustdoc_generator(config, &self.current.source)?;
598        let baseline_loader = self.get_rustdoc_generator(config, &self.baseline.source)?;
599        let witness_target_dir = self.get_target_dir(&self.current.source)?;
600
601        // Create a report for each crate.
602        // We want to run all the checks, even if one returns `Err`.
603        let all_outcomes: Vec<anyhow::Result<(String, PendingCrateReport)>> = crates_to_check
604            .into_iter()
605            .map(|selected| {
606                let start = std::time::Instant::now();
607                let name = selected.current_crate_data.name.clone();
608
609                let current_loader = rustdoc_gen::StatefulRustdocGenerator::couple_data(
610                    &current_loader,
611                    config,
612                    &selected.current_crate_data,
613                )
614                .map_err(|err| log_terminal_error(config, err))?;
615                let baseline_loader = rustdoc_gen::StatefulRustdocGenerator::couple_data(
616                    &baseline_loader,
617                    config,
618                    &selected.baseline_crate_data,
619                )
620                .map_err(|err| log_terminal_error(config, err))?;
621
622                let current_loader = current_loader
623                    .prepare_generator(config)
624                    .map_err(|err| log_terminal_error(config, err))?;
625                let baseline_loader = baseline_loader
626                    .prepare_generator(config)
627                    .map_err(|err| log_terminal_error(config, err))?;
628
629                let witness_data = witness_gen::WitnessGenerationData::new(
630                    baseline_loader.get_data_request(),
631                    current_loader.get_data_request(),
632                    witness_target_dir.clone(),
633                );
634
635                let data_storage = generate_crate_data(
636                    config,
637                    generation_settings,
638                    &current_loader,
639                    &baseline_loader,
640                )
641                .map_err(|err| log_terminal_error(config, err))?;
642
643                let report = run_check_release(
644                    config,
645                    &data_storage,
646                    &name,
647                    CheckReleaseSettings {
648                        release_type: self.release_type,
649                        rustdoc_indexing_mode: self.rustdoc_indexing_mode,
650                    },
651                    &selected.overrides,
652                    &self.witness_generation,
653                    witness_data,
654                )?;
655                config.shell_status(
656                    "Finished",
657                    format_args!("[{:>8.3}s] {name}", start.elapsed().as_secs_f32()),
658                )?;
659                Ok((name, report))
660            })
661            .collect();
662        let crate_reports: BTreeMap<String, CrateReport> = {
663            let mut reports = BTreeMap::new();
664            let mut witness_run_reports = Vec::new();
665            for outcome in all_outcomes {
666                let (name, outcome) = outcome?;
667                witness_run_reports.push(outcome.witness_run_report);
668                reports.insert(name, outcome.report);
669            }
670
671            match witness_gen::finalize_retained_artifacts(config.run_id(), &witness_run_reports) {
672                Ok(retained_artifact_dirs) => {
673                    if !retained_artifact_dirs.is_empty() {
674                        if retained_artifact_dirs.len() == 1 {
675                            config.shell_note(format_args!(
676                                "retained witness artifacts in {}",
677                                retained_artifact_dirs[0].display()
678                            ))?;
679                        } else {
680                            config.shell_note("retained witness artifacts in:")?;
681                            for dir in retained_artifact_dirs {
682                                writeln!(config.stderr(), "{:12}{}", "", dir.display())?;
683                            }
684                        }
685                    }
686                }
687                Err(error) => {
688                    config.shell_warn(format_args!(
689                        "failed to retain witness artifacts: {error:#}"
690                    ))?;
691                }
692            }
693            reports
694        };
695
696        Ok(Report { crate_reports })
697    }
698}
699
700fn overrides_for_workspace_package(
701    package: &cargo_metadata::Package,
702    workspace_overrides: Option<&[BTreeMap<String, QueryOverride>]>,
703) -> Result<OverrideStack, anyhow::Error> {
704    let lint_table = manifest::deserialize_lint_table(&package.metadata).with_context(|| {
705        format!(
706            "package `{}`'s [package.metadata.cargo-semver-checks] table is invalid (at {})",
707            package.name, package.manifest_path,
708        )
709    })?;
710    let selected_manifest =
711        manifest::Manifest::parse_standalone(package.manifest_path.clone().into_std_path_buf())?;
712
713    // N.B.: Do not use `==` here, because `==` is false for inherited values.
714    let use_workspace_lints = matches!(
715        selected_manifest.parsed.lints,
716        cargo_toml::Inheritable::Inherited
717    );
718    let metadata_workspace_key = lint_table.as_ref().is_some_and(|x| x.workspace);
719
720    let mut overrides = OverrideStack::new();
721    if (use_workspace_lints || metadata_workspace_key)
722        && let Some(workspace) = workspace_overrides
723    {
724        for level in workspace {
725            overrides.push(level);
726        }
727    }
728    if let Some(lint_table) = lint_table {
729        for level in lint_table.into_stack() {
730            overrides.push(&level);
731        }
732    }
733    Ok(overrides)
734}
735
736#[cold]
737fn log_terminal_error(config: &mut GlobalConfig, err: TerminalError) -> anyhow::Error {
738    match err {
739        TerminalError::WithAdvice(err, advice) => {
740            if let Err(err) = config.log_error(|config| {
741                writeln!(config.stderr(), "{advice}")?;
742                Ok(())
743            }) {
744                return err;
745            }
746            err
747        }
748        TerminalError::Other(err) => err,
749    }
750}
751
752/// Summary of version bumps from queries
753#[derive(Debug)]
754struct Bumps {
755    major: u32,
756    minor: u32,
757}
758
759impl Bumps {
760    /// Minimum bump required to respect semver.
761    /// For example, if the crate contains breaking changes, this is `Some(RequiredSemverUpdate::Major)`.
762    /// If no additional bump is required, this is [`Option::None`].
763    pub fn update_type(&self) -> Option<RequiredSemverUpdate> {
764        if self.major > 0 {
765            Some(RequiredSemverUpdate::Major)
766        } else if self.minor > 0 {
767            Some(RequiredSemverUpdate::Minor)
768        } else {
769            None
770        }
771    }
772}
773
774/// Report of semver check of one crate.
775#[non_exhaustive]
776#[derive(Debug)]
777pub struct CrateReport {
778    /// Bump between the current version and the baseline one.
779    detected_bump: ActualSemverUpdate,
780    /// Minimum additional bump (on top of `detected_bump`) required to respect semver.
781    required_bumps: Bumps,
782    /// Numbers of warning-level lints requiring minor and major bumps
783    suggested_bumps: Bumps,
784    /// Detailed information about individual lints
785    lint_results: Vec<LintResult>,
786    /// How long it took to run the selected queries
787    checks_duration: Duration,
788    /// Number of queries run
789    selected_checks: usize,
790    /// Number of ignored queries
791    skipped_checks: usize,
792    /// Witness statistics produced while evaluating this crate, if any.
793    witness_statistics: Option<WitnessStatistics>,
794}
795
796impl CrateReport {
797    /// Check whether this crate passed the semver check itself.
798    ///
799    /// This only reports whether the crate's authoritative lint results require
800    /// a larger semver bump than was detected. It does not include witness
801    /// execution failures; inspect [`Self::has_required_witness_errors`] or
802    /// [`Self::witness_statistics`] separately when those matter.
803    pub fn success(&self) -> bool {
804        match self.required_bumps.update_type().map(ReleaseType::from) {
805            // If `None`, no additional bump is required.
806            None => true,
807            // If `Some`, additional bump is required, so the report is not successful.
808            Some(required_bump) => {
809                // By design, `required_bump` should always be > `detected_bump`.
810                // Let's assert that.
811                match self.detected_bump {
812                    // If user bumped the major version, any breaking change is accepted.
813                    // So `required_bump` should be `None`.
814                    ActualSemverUpdate::Major => {
815                        panic!("detected_bump is major, while required_bump is {required_bump:?}")
816                    }
817                    ActualSemverUpdate::Minor => {
818                        assert_eq!(required_bump, ReleaseType::Major);
819                    }
820                    ActualSemverUpdate::Patch | ActualSemverUpdate::NotChanged => {
821                        assert!(matches!(
822                            required_bump,
823                            ReleaseType::Major | ReleaseType::Minor
824                        ));
825                    }
826                }
827                false
828            }
829        }
830    }
831
832    /// Whether required witness validation encountered an execution error.
833    pub fn has_required_witness_errors(&self) -> bool {
834        self.witness_statistics
835            .as_ref()
836            .is_some_and(|statistics| statistics.required_witness_errors() > 0)
837    }
838
839    /// Minimum bump required to respect semver.
840    /// It's [`Option::None`] if no bump is required beyond the already-detected bump.
841    pub fn required_bump(&self) -> Option<ReleaseType> {
842        self.required_bumps.update_type().map(ReleaseType::from)
843    }
844
845    /// Bump between the current version and the baseline one.
846    pub fn detected_bump(&self) -> ActualSemverUpdate {
847        self.detected_bump
848    }
849
850    /// Additional witness-related statistics for this crate, if any were produced.
851    pub fn witness_statistics(&self) -> Option<&WitnessStatistics> {
852        self.witness_statistics.as_ref()
853    }
854}
855
856/// Report of the whole analysis.
857/// Contains a report for each crate checked.
858#[non_exhaustive]
859#[derive(Debug)]
860pub struct Report {
861    /// Collection containing the name and the report of each crate checked.
862    crate_reports: BTreeMap<String, CrateReport>,
863}
864
865impl Report {
866    /// `true` if none of the crates violate SemVer according to authoritative
867    /// lint results.
868    pub fn success(&self) -> bool {
869        self.crate_reports.values().all(|report| report.success())
870    }
871
872    /// Whether any crate encountered a required witness execution error.
873    pub fn has_required_witness_errors(&self) -> bool {
874        self.crate_reports
875            .values()
876            .any(|report| report.has_required_witness_errors())
877    }
878
879    /// Reports of each crate checked, sorted by crate name.
880    pub fn crate_reports(&self) -> &BTreeMap<String, CrateReport> {
881        &self.crate_reports
882    }
883}
884
885/// Options for generating **witness code**.  A witness is a minimal buildable
886/// example of how downstream code could break for a specific breaking change.
887///
888/// See also: [`Witness`]
889#[non_exhaustive]
890#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
891pub struct WitnessGeneration {
892    /// Whether to print witness hints, short examples that show why a change is breaking,
893    /// while not necessarily buildable standalone programs.  See [`Witness::hint_template`].
894    pub show_hints: bool,
895    /// Whether to run witness-based consistency checks for lints whose witness purpose is
896    /// [`WitnessPurpose::ConsistencyCheck`]. Witnesses whose purpose is
897    /// [`WitnessPurpose::RequiredForCorrectness`] always run.
898    pub run_consistency_checks: bool,
899}
900
901impl WitnessGeneration {
902    /// Creates a new [`WitnessGeneration`] instance with all optional witness output disabled.
903    #[inline]
904    #[must_use]
905    pub const fn new() -> Self {
906        Self {
907            show_hints: false,
908            run_consistency_checks: false,
909        }
910    }
911}
912
913/// Witness-related statistics produced while checking one crate.
914#[non_exhaustive]
915#[derive(Debug, Clone, PartialEq, Eq)]
916pub struct WitnessStatistics {
917    not_confirmed_by_witness: usize,
918    consistency_check_mismatches: usize,
919    consistency_check_errors: usize,
920    required_witness_errors: usize,
921}
922
923impl WitnessStatistics {
924    pub(crate) const fn new(
925        not_confirmed_by_witness: usize,
926        consistency_check_mismatches: usize,
927        consistency_check_errors: usize,
928        required_witness_errors: usize,
929    ) -> Self {
930        Self {
931            not_confirmed_by_witness,
932            consistency_check_mismatches,
933            consistency_check_errors,
934            required_witness_errors,
935        }
936    }
937
938    pub(crate) fn is_empty(&self) -> bool {
939        self.not_confirmed_by_witness == 0
940            && self.consistency_check_mismatches == 0
941            && self.consistency_check_errors == 0
942            && self.required_witness_errors == 0
943    }
944
945    /// Number of `RequiredForCorrectness` candidate query results that were
946    /// suppressed because the witness also compiled on the current crate,
947    /// disproving the breakage.
948    pub fn not_confirmed_by_witness(&self) -> usize {
949        self.not_confirmed_by_witness
950    }
951
952    /// Number of `ConsistencyCheck` query results whose witness still compiled
953    /// on the current crate. This is unexpected and indicates likely false-positives.
954    pub fn consistency_check_mismatches(&self) -> usize {
955        self.consistency_check_mismatches
956    }
957
958    /// Number of witness execution failures encountered while running
959    /// `ConsistencyCheck` witnesses.
960    pub fn consistency_check_errors(&self) -> usize {
961        self.consistency_check_errors
962    }
963
964    /// Number of witness execution failures encountered while running
965    /// `RequiredForCorrectness` witnesses.
966    pub fn required_witness_errors(&self) -> usize {
967        self.required_witness_errors
968    }
969}
970
971fn generate_crate_data(
972    config: &mut GlobalConfig,
973    generation_settings: data_generation::GenerationSettings,
974    current_loader: &rustdoc_gen::StatefulRustdocGenerator<'_, rustdoc_gen::ReadyState<'_>>,
975    baseline_loader: &rustdoc_gen::StatefulRustdocGenerator<'_, rustdoc_gen::ReadyState<'_>>,
976) -> Result<DataStorage, TerminalError> {
977    let current_crate = current_loader.load_rustdoc(
978        config,
979        generation_settings,
980        data_generation::CacheSettings::ReadWrite(()),
981    )?;
982
983    let baseline_crate_name = &baseline_loader.get_crate_data().name;
984    let current_rustdoc_version = current_crate.version();
985
986    let baseline_crate = {
987        let mut baseline_crate = baseline_loader.load_rustdoc(
988            config,
989            generation_settings,
990            data_generation::CacheSettings::ReadWrite(()),
991        )?;
992
993        // The baseline rustdoc JSON may have been cached; ensure its rustdoc version matches
994        // the version emitted by the currently-installed toolchain.
995        //
996        // The baseline and current rustdoc JSONs should have the same version.
997        // If the baseline rustdoc version doesn't match, delete the cached baseline and rebuild it.
998        //
999        // Fix for: https://github.com/obi1kenobi/cargo-semver-checks/issues/415
1000        if baseline_crate.version() != current_rustdoc_version {
1001            let crate_name = baseline_crate_name;
1002            config
1003                .shell_status(
1004                    "Removing",
1005                    format_args!("stale cached baseline rustdoc for {crate_name}"),
1006                )
1007                .into_terminal_result()?;
1008
1009            baseline_crate = baseline_loader.load_rustdoc(
1010                config,
1011                generation_settings,
1012                data_generation::CacheSettings::WriteOnly(()),
1013            )?;
1014
1015            assert_eq!(
1016                baseline_crate.version(),
1017                current_rustdoc_version,
1018                "Deleting and regenerating the baseline JSON file did not resolve the rustdoc \
1019                 version mismatch."
1020            );
1021        }
1022
1023        baseline_crate
1024    };
1025
1026    Ok(DataStorage::new(current_crate, baseline_crate))
1027}
1028
1029fn manifest_path(project_root: &Path) -> anyhow::Result<PathBuf> {
1030    if project_root.is_dir() {
1031        let manifest_path = project_root.join("Cargo.toml");
1032        // Checking whether the file exists here is not necessary
1033        // (it will nevertheless be checked while parsing the manifest),
1034        // but it should give a nicer error message for the user.
1035        if manifest_path.exists() {
1036            Ok(manifest_path)
1037        } else {
1038            anyhow::bail!(
1039                "couldn't find Cargo.toml in directory {}",
1040                project_root.display()
1041            )
1042        }
1043    } else if project_root.ends_with("Cargo.toml") {
1044        // Even though the `project_root` should be a directory,
1045        // someone could by accident directly pass the path to the manifest
1046        // and we're kind enough to accept it.
1047        Ok(project_root.to_path_buf())
1048    } else {
1049        anyhow::bail!(
1050            "path '{}' is not a directory or a manifest",
1051            project_root.display()
1052        )
1053    }
1054}
1055
1056fn manifest_metadata(project_root: &Path) -> anyhow::Result<cargo_metadata::Metadata> {
1057    let manifest_path = manifest_path(project_root)?;
1058    let mut command = cargo_metadata::MetadataCommand::new();
1059    let metadata = command.manifest_path(manifest_path).exec()?;
1060    Ok(metadata)
1061}
1062
1063fn manifest_metadata_no_deps(project_root: &Path) -> anyhow::Result<cargo_metadata::Metadata> {
1064    let manifest_path = manifest_path(project_root)?;
1065    let mut command = cargo_metadata::MetadataCommand::new();
1066    let metadata = command.manifest_path(manifest_path).no_deps().exec()?;
1067    Ok(metadata)
1068}
1069
1070fn get_cache_dir() -> anyhow::Result<PathBuf> {
1071    let project_dirs =
1072        ProjectDirs::from("", "", "cargo-semver-checks").context("can't determine project dirs")?;
1073    let cache_dir = project_dirs.cache_dir();
1074    std::fs::create_dir_all(cache_dir).context("can't create cache dir")?;
1075    Ok(cache_dir.to_path_buf())
1076}
1077
1078fn get_target_dir_from_project_root(source: &RustdocSource) -> anyhow::Result<Option<PathBuf>> {
1079    Ok(match source {
1080        RustdocSource::Root(root) => {
1081            let metadata = manifest_metadata_no_deps(root)?;
1082            let target = metadata.target_directory.as_std_path().join(util::SCOPE);
1083            Some(target)
1084        }
1085        RustdocSource::Revision(root, rev) => {
1086            let metadata = manifest_metadata_no_deps(root)?;
1087            let target = metadata.target_directory.as_std_path().join(util::SCOPE);
1088            let target = target.join(format!("git-{}", util::slugify(rev)));
1089            Some(target)
1090        }
1091        RustdocSource::Rustdoc(_path) => None,
1092        RustdocSource::VersionFromRegistry(_version) => None,
1093    })
1094}