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