ingredients 0.1.0

Check ingredients of published Rust crates
Documentation
//! # Check ingredients of published Rust crates
//!
//! This crate implements two modes for checking ingredients of Rust crates:
//!
//! * `report`: Comparing published crate sources from [crates.io] with the associated contents of
//!   the project's version control system.
//! * `diff`: Compare contents of two different, published versions of the same crate.
//!
//! ## `report` mode
//!
//! This comparison mode reads crate metadata (from `Cargo.toml` and from the `.cargo_vcs_info.json`
//! file that is embedded by cargo during the publishing process), fetches the corresponding `git`
//! repository, and compares the contents of the published crate with the contents of the project
//! repository at the `ref` that is recorded in `.cargo_vcs_info.json`.
//!
//! Any actual differences in file contents or crate metadata are considered to be *errors*. Issues
//! that prevent checking for differences (like missing metadata or an invalid git repository) are
//! considered *fatal*.
//!
//! Some differences are "expected" and are not reported:
//!
//! * The `Cargo.toml` file is processed and rewritten during the publishing process. Instead, the
//!   `Cargo.toml` contents from the repository is compared to `Cargo.toml.orig`, which contains the
//!   original, unmodified file contents.
//! * Instead, crate metadata is compared by parsing `Cargo.toml` contents and checking for
//!   semantic equivalence instead of byte-for-byte equivalence.
//! * Dependencies that are "path"-based are stripped by cargo during the publishing process.
//!   Differences in crate dependencies that are solely due to "path"-based dependencies having been
//!   stripped are ignored and not reported.
//! * Symbolic links present in the project repository are resolved during the publishing process,
//!   cargo includes actual files in published crates instead.
//!
//! ## `diff` mode
//!
//! This mode compares crate metadata and contents between two source archives that were published
//! to [crates.io]. It is much less strict than the "report" mode (because differences are expected
//! when comparing two different versions of a crate), but will report differences with more
//! granularity than just reporting an error on *any* difference. As such, the only difference that
//! actually triggers a lint with "error" severity is if the crate *name* is different. This should
//! only ever happen when comparing two *different* crates (but which might be useful to do in cases
//! where a crate is "renamed", i.e. new versions are published under a different name).
//!
//! ## Features
//!
//! Almost all functionality from the crate API is also available from the command-line interface.
//! If the CLI functionality is not needed, it can be disabled by using `default-features = false`.
//! 
//! [crates.io]: https://crates.io

mod compare;
mod diff;
mod error;
mod format;
mod krate;
mod metadata;
mod report;
mod severity;
mod upstream;
mod utils;
mod vcsinfo;
mod workarounds;

pub use crate::diff::{Diff, DiffItem};
pub use crate::error::Error;
pub use crate::krate::Crate;
pub use crate::report::{Report, ReportItem};
pub use crate::severity::Severity;

use crate::compare::{CrateComparator, RepoComparator};
use crate::upstream::Repository;
use crate::vcsinfo::vcs_info_from_root;
use crate::workarounds::sanitize_repo_url;

// part of the public API
pub use serde_json::Value;

/// Additional options for report generation
///
/// ```rust
/// use ingredients::ReportOptions;
///
/// let mut options = ReportOptions::default();
/// options.repository = Some(String::from("https://example.com/foo/bar"));
/// ```
#[non_exhaustive]
#[derive(Clone, Default)]
pub struct ReportOptions {
    /// Repository URL
    ///
    /// This is useful if published crates do not contain a correct repository URL.
    pub repository: Option<String>,
    /// Path in VCS
    ///
    /// This is useful for crates that were published with old versions of cargo
    /// that did not include the `"path_in_vcs"` property in `.cargo_vcs_info.json`
    /// files.
    pub path_in_vcs: Option<String>,
    /// VCS ref
    ///
    /// This is useful for crates that were publsihed with old versions of cargo
    /// that did not include a `.cargo_vcs_info.json` file on upload to crates.io.
    pub vcs_ref: Option<String>,
}

impl ReportOptions {
    /// Initialize new options.
    #[must_use]
    pub const fn new() -> Self {
        ReportOptions {
            repository: None,
            path_in_vcs: None,
            vcs_ref: None,
        }
    }

    /// Check if the options are empty / the defaults.
    #[must_use]
    pub const fn is_empty(&self) -> bool {
        self.repository.is_none() && self.path_in_vcs.is_none() && self.vcs_ref.is_none()
    }
}

impl Crate {
    /// Compare crate against the contents of the project's version control system.
    ///
    /// ```rust,no_run
    /// use ingredients::Crate;
    /// # use tokio::runtime::Runtime;
    ///
    /// # let rt = Runtime::new().unwrap();
    /// # rt.block_on(async {
    /// let krate = Crate::download("ingredients", "0.1.0").await.unwrap();
    /// let report = krate.report().await.unwrap();
    /// println!("{report}");
    /// # })
    /// ```
    ///
    /// # Errors
    ///
    /// * The repository URL is invalid and fails to parse.
    /// * Directory contents cannot be read from disk.
    pub async fn report(&self) -> Result<Report, Error> {
        let mut items = Vec::new();

        let Some(vcs_info) = vcs_info_from_root(&self.root)? else {
            items.push(ReportItem::MissingVcsInfo);
            return Ok(Report::from_items(items));
        };

        if vcs_info.git.dirty {
            items.push(ReportItem::DirtyRepository);
        }

        let Some(path_in_vcs) = vcs_info.path_in_vcs.as_ref() else {
            items.push(ReportItem::NoPathInVcsInfo);
            return Ok(Report::from_items(items));
        };

        let Some(url) = &self.metadata.inner.repository else {
            items.push(ReportItem::MissingRepositoryUrl);
            return Ok(Report::from_items(items));
        };

        let sanitized_url = sanitize_repo_url(url)?;
        let upstream = match Repository::clone(
            &sanitized_url,
            &vcs_info.git.sha1,
            path_in_vcs,
            &self.metadata.inner.name,
        )
        .await
        {
            Ok(repo) => repo,
            Err(err) => match err {
                Error::InvalidGitRef { repo, rev } => {
                    items.push(ReportItem::InvalidGitRef { repo, rev });
                    return Ok(Report::from_items(items));
                },
                Error::InvalidRepoUrl { repo } => {
                    items.push(ReportItem::InvalidRepoUrl { repo });
                    return Ok(Report::from_items(items));
                },
                _ => return Err(err),
            },
        };

        let comparator = RepoComparator::new(self, &upstream);
        items.extend(comparator.compare()?);

        Ok(Report::from_items(items))
    }

    /// Compare crate against the contents of the project's version control system (with custom
    /// options).
    ///
    /// # Errors
    ///
    /// * The repository URL is invalid and fails to parse.
    /// * Directory contents cannot be read from disk.
    pub async fn report_with_options(&self, options: ReportOptions) -> Result<Report, Error> {
        let mut items = Vec::new();

        let (path_in_vcs, vcs_ref) = if let Some(vcs_info) = vcs_info_from_root(&self.root)? {
            if vcs_info.git.dirty {
                items.push(ReportItem::DirtyRepository);
            }

            let Some(path_in_vcs) = options.path_in_vcs.or(vcs_info.path_in_vcs) else {
                items.push(ReportItem::NoPathInVcsInfo);
                return Ok(Report::from_items(items));
            };
            let vcs_ref = options.vcs_ref.unwrap_or(vcs_info.git.sha1);

            (path_in_vcs, vcs_ref)
        } else {
            items.push(ReportItem::MissingVcsInfo);

            let Some(path_in_vcs) = options.path_in_vcs else {
                items.push(ReportItem::NoPathInVcsInfo);
                return Ok(Report::from_items(items));
            };

            let Some(vcs_ref) = options.vcs_ref else {
                return Ok(Report::from_items(items));
            };

            (path_in_vcs, vcs_ref)
        };

        let Some(url) = options.repository.or(self.metadata.inner.repository.clone()) else {
            items.push(ReportItem::MissingRepositoryUrl);
            return Ok(Report::from_items(items));
        };

        let sanitized_url = sanitize_repo_url(&url)?;
        let upstream = match Repository::clone(&sanitized_url, &vcs_ref, &path_in_vcs, &self.metadata.inner.name).await
        {
            Ok(repo) => repo,
            Err(err) => match err {
                Error::InvalidGitRef { repo, rev } => {
                    items.push(ReportItem::InvalidGitRef { repo, rev });
                    return Ok(Report::from_items(items));
                },
                Error::InvalidRepoUrl { repo } => {
                    items.push(ReportItem::InvalidRepoUrl { repo });
                    return Ok(Report::from_items(items));
                },
                _ => return Err(err),
            },
        };

        let comparator = RepoComparator::new(self, &upstream);
        items.extend(comparator.compare()?);

        Ok(Report::from_items(items))
    }

    /// Compare crate against a different version of the same crate.
    ///
    /// ```rust,no_run
    /// use ingredients::Crate;
    /// # use tokio::runtime::Runtime;
    ///
    /// # let rt = Runtime::new().unwrap();
    /// # rt.block_on(async {
    /// let krate1 = Crate::download("syn", "2.0.110").await.unwrap();
    /// let krate2 = Crate::download("syn", "2.0.111").await.unwrap();
    /// let diff = krate1.diff(&krate2).unwrap();
    /// println!("{diff}");
    /// # })
    /// ```
    ///
    /// # Errors
    ///
    /// * Directory contents cannot be read from disk.
    pub fn diff(&self, other: &Crate) -> Result<Diff, Error> {
        let comparator = CrateComparator::new(self, other);
        Ok(Diff::from_items(comparator.compare()?))
    }
}