ingredients 0.1.0

Check ingredients of published Rust crates
Documentation
use std::borrow::Cow;
use std::fmt::{self, Display, Formatter};

use serde::Serialize;
use serde_json::Value;

use crate::severity::Severity;

/// List of differences between a published crate and the associated state of
/// the upstream version control system
#[derive(Debug, Default)]
pub struct Report {
    items: Vec<ReportItem>,
}

impl Report {
    pub(crate) const fn from_items(items: Vec<ReportItem>) -> Self {
        Report { items }
    }

    /// List of differences
    #[must_use]
    pub fn items(&self) -> &[ReportItem] {
        &self.items
    }

    /// Get list of differences in machine-readable JSON format
    ///
    /// # Panics
    ///
    /// This function panics if there are internal errors related to serializing
    /// data in JSON format.
    #[must_use]
    pub fn to_json(&self) -> String {
        let items: Vec<JsonReportItem> = self
            .items
            .iter()
            .map(|i| JsonReportItem {
                severity: i.severity().to_string(),
                kind: i.kind(),
                data: i.data(),
            })
            .collect();

        // if this fails, something is seriously wrong - just panic
        #[allow(clippy::expect_used)]
        serde_json::to_string_pretty(&items).expect("Failed to serialize report as JSON.")
    }
}

impl Display for Report {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        for item in self.items() {
            if f.alternate() {
                write!(f, "{item:#}")?;
            } else {
                write!(f, "{item}")?;
            }
        }
        Ok(())
    }
}

/// A specific difference between a published crate and the associated state of the upstream version
/// control system
#[derive(Clone, Debug)]
#[non_exhaustive]
// keep in sync with the Python enum
pub enum ReportItem {
    /// The crate metadata does not contain a repository URL.
    MissingRepositoryUrl,
    /// The crate metadata specifies an invalid repository URL.
    InvalidRepoUrl {
        /// Repository URL
        repo: String,
    },
    /// The git ref from `.cargo_vcs_info.json` could not be checked out.
    InvalidGitRef {
        /// Repository URL
        repo: String,
        /// Repository ref
        rev: String,
    },
    /// The `.cargo_vcs_info.json` file is missing from the published crate.
    MissingVcsInfo,
    /// The `"path_in_vcs"` property is missing from `.cargo_vcs_info.json`.
    NoPathInVcsInfo,
    /// The crate was published from a "dirty" repository according to `.cargo_vcs_info.json`.
    DirtyRepository,
    /// Crate metadata does not match metadata from VCS contents.
    MetadataMismatch {
        /// Field in Cargo.toml that does not match
        field: Cow<'static, str>,
        /// Value in the published crate
        krate: Option<String>,
        /// Value from VCS contents
        urepo: Option<String>,
    },
    /// The crate contains a broken symbolic link.
    BrokenSymlinkInCrate {
        /// Path of the symbolic link
        path: String,
    },
    /// The repository contains a broken symbolic link.
    BrokenSymlinkInRepo {
        /// Path of the symbolic link
        path: String,
    },
    /// The crate contains a symbolic link that points outside the source directory.
    InvalidSymlinkInCrate {
        /// Path of the symbolic link
        path: String,
    },
    /// The repository contains a symbolic link that points outside the source directory.
    InvalidSymlinkInRepo {
        /// Path of the symbolic link
        path: String,
    },
    /// The crate contains a file that is not present in the VCS.
    MissingFile {
        /// Path of the file
        path: String,
    },
    /// The crate contains a file that does not match file contents from the VCS.
    ContentMismatch {
        /// Path of the file
        path: String,
        /// Diff between the file in the published crate and the file in the VCS
        ///
        /// If this is `None` then the file is a binary file and / or not valid UTF-8.
        diff: Option<String>,
    },
    /// The crate contains a file that has different line endings than the file in the VCS.
    LineEndings {
        /// Path of the file
        path: String,
    },
    /// The crate contains a file that has different mode / permissions than the file in the VCS.
    Permissions {
        /// Path of the file
        path: String,
        /// Mode of the file in the published crate
        krate: String,
        /// Mode of the file in the VCS
        urepo: String,
    },
}

impl ReportItem {
    pub(crate) fn metadata_mismatch<F: Into<Cow<'static, str>>>(
        field: F,
        krate: Option<String>,
        urepo: Option<String>,
    ) -> Self {
        ReportItem::MetadataMismatch {
            field: field.into(),
            krate,
            urepo,
        }
    }
}

#[derive(Serialize)]
struct JsonReportItem {
    severity: String,
    kind: &'static str,
    data: Value,
}

impl ReportItem {
    /// Severity associated with this report item.
    #[must_use]
    pub const fn severity(&self) -> Severity {
        #[allow(clippy::match_same_arms)]
        match self {
            Self::MissingRepositoryUrl => Severity::Fatal,
            Self::InvalidRepoUrl { .. } => Severity::Fatal,
            Self::InvalidGitRef { .. } => Severity::Fatal,
            Self::MissingVcsInfo => Severity::Fatal,
            Self::NoPathInVcsInfo => Severity::Fatal,
            Self::DirtyRepository => Severity::Warning,
            Self::MetadataMismatch { .. } => Severity::Error,
            Self::BrokenSymlinkInCrate { .. } => Severity::Warning,
            Self::BrokenSymlinkInRepo { .. } => Severity::Warning,
            Self::InvalidSymlinkInCrate { .. } => Severity::Error,
            Self::InvalidSymlinkInRepo { .. } => Severity::Error,
            Self::MissingFile { .. } => Severity::Error,
            Self::ContentMismatch { .. } => Severity::Error,
            Self::LineEndings { .. } => Severity::Warning,
            Self::Permissions { .. } => Severity::Warning,
        }
    }

    /// String representation / ID of the report item kind.
    #[must_use]
    // keep in sync with the Python enum
    pub const fn kind(&self) -> &'static str {
        match self {
            Self::MissingRepositoryUrl => "MissingRepositoryUrl",
            Self::InvalidRepoUrl { .. } => "InvalidRepoUrl",
            Self::InvalidGitRef { .. } => "InvalidGitRef",
            Self::MissingVcsInfo => "MissingVcsInfo",
            Self::NoPathInVcsInfo => "NoPathInVcsInfo",
            Self::DirtyRepository => "DirtyRepository",
            Self::MetadataMismatch { .. } => "MetadataMismatch",
            Self::BrokenSymlinkInCrate { .. } => "BrokenSymlinkInCrate",
            Self::BrokenSymlinkInRepo { .. } => "BrokenSymlinkInRepo",
            Self::InvalidSymlinkInCrate { .. } => "InvalidSymlinkInCrate",
            Self::InvalidSymlinkInRepo { .. } => "InvalidSymlinkInRepo",
            Self::MissingFile { .. } => "MissingFile",
            Self::ContentMismatch { .. } => "ContentMismatch",
            Self::LineEndings { .. } => "LineEndings",
            Self::Permissions { .. } => "Permissions",
        }
    }

    /// Data associated with this report item.
    #[must_use]
    pub fn data(&self) -> Value {
        let mut data = serde_json::Map::new();

        #[allow(clippy::match_same_arms)]
        match self {
            Self::MissingRepositoryUrl => {},
            Self::InvalidRepoUrl { repo } => {
                data.insert(String::from("url"), Value::from(repo.clone()));
            },
            Self::InvalidGitRef { repo, rev } => {
                data.insert(String::from("url"), Value::from(repo.clone()));
                data.insert(String::from("ref"), Value::from(rev.clone()));
            },
            Self::MissingVcsInfo => {},
            Self::NoPathInVcsInfo => {},
            Self::DirtyRepository => {},
            Self::MetadataMismatch { field, krate, urepo } => {
                data.insert(String::from("field"), Value::from(String::from(field.as_ref())));
                data.insert(String::from("crate"), Value::from(krate.clone()));
                data.insert(String::from("urepo"), Value::from(urepo.clone()));
            },
            Self::BrokenSymlinkInCrate { path } => {
                data.insert(String::from("path"), Value::from(path.clone()));
            },
            Self::BrokenSymlinkInRepo { path } => {
                data.insert(String::from("path"), Value::from(path.clone()));
            },
            Self::InvalidSymlinkInCrate { path } => {
                data.insert(String::from("path"), Value::from(path.clone()));
            },
            Self::InvalidSymlinkInRepo { path } => {
                data.insert(String::from("path"), Value::from(path.clone()));
            },
            Self::MissingFile { path } => {
                data.insert(String::from("path"), Value::from(path.clone()));
            },
            Self::ContentMismatch { path, diff } => {
                data.insert(String::from("path"), Value::from(path.clone()));
                data.insert(String::from("diff"), Value::from(diff.clone()));
            },
            Self::LineEndings { path } => {
                data.insert(String::from("path"), Value::from(path.clone()));
            },
            Self::Permissions { path, krate, urepo } => {
                data.insert(String::from("path"), Value::from(path.clone()));
                data.insert(String::from("mode-in-crate"), Value::from(krate.clone()));
                data.insert(String::from("mode-in-repo"), Value::from(urepo.clone()));
            },
        }

        Value::Object(data)
    }
}

impl Display for ReportItem {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        let sev = self.severity();

        match self {
            ReportItem::MissingRepositoryUrl => {
                writeln!(f, "{sev}: missing repository URL in crate metadata")?;
            },
            ReportItem::InvalidRepoUrl { repo } => {
                writeln!(f, "{sev}: invalid repository URL: '{repo}'")?;
            },
            ReportItem::InvalidGitRef { repo, rev } => {
                writeln!(f, "{sev}: invalid git ref '{rev}' for repository at '{repo}'")?;
            },
            ReportItem::MissingVcsInfo => {
                writeln!(f, "{sev}: missing '.cargo_vcs_info.json' in published crate")?;
            },
            ReportItem::NoPathInVcsInfo => {
                writeln!(f, "{sev}: no path specified in '.cargo_vcs_info.json'")?;
            },
            ReportItem::DirtyRepository => {
                writeln!(f, "{sev}: crate was published from a \"dirty\" repository")?;
            },
            ReportItem::MetadataMismatch { field, krate, urepo } => {
                writeln!(f, "{sev}: metadata mismatch between crate and repository at '{field}':")?;
                writeln!(f, "    crate: {}", krate.as_ref().map_or("(none)", String::as_str))?;
                writeln!(f, "     repo: {}", urepo.as_ref().map_or("(none)", String::as_str))?;
            },
            ReportItem::BrokenSymlinkInCrate { path } => {
                writeln!(f, "{sev}: broken symbolic link in crate at path '{path}'")?;
            },
            ReportItem::BrokenSymlinkInRepo { path } => {
                writeln!(f, "{sev}: broken symbolic link in repository at path '{path}'")?;
            },
            ReportItem::InvalidSymlinkInCrate { path } => {
                writeln!(f, "{sev}: invalid symbolic link in crate at path '{path}'")?;
            },
            ReportItem::InvalidSymlinkInRepo { path } => {
                writeln!(f, "{sev}: invalid symbolic link in repository at path '{path}'")?;
            },
            ReportItem::MissingFile { path } => {
                writeln!(
                    f,
                    "{sev}: file present in crate missing from repository at path '{path}'"
                )?;
            },
            ReportItem::ContentMismatch { path, diff } => {
                writeln!(
                    f,
                    "{sev}: contents of file at path '{path}' differ between crate and repository"
                )?;
                if let Some(diff) = diff
                    && f.alternate()
                {
                    for line in diff.lines() {
                        writeln!(f, "    {line}")?;
                    }
                }
            },
            ReportItem::LineEndings { path } => {
                writeln!(
                    f,
                    "{sev}: contents of file at path '{path}' use different line endings (CRLF / LF)"
                )?;
            },
            ReportItem::Permissions { path, krate, urepo } => {
                writeln!(
                    f,
                    "{sev}: file at path '{path}' has different modes in crate ({krate}) and repository ({urepo})"
                )?;
            },
        }

        Ok(())
    }
}