ingredients 0.2.1

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
        #[expect(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, PartialEq)]
#[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 `"path_in_vcs"` property is missing from `.cargo_vcs_info.json` and the crate cannot be
    /// found inside the repository.
    NotFoundInRepo {
        /// Repository URL
        repo: String,
        /// Crate name
        name: String,
    },
    /// 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 {
        #[expect(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::Warning,
            Self::NotFoundInRepo { .. } => 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::NotFoundInRepo { .. } => "NotFoundInRepository",
            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();

        #[expect(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::NotFoundInRepo { repo, name } => {
                data.insert(String::from("url"), Value::from(repo.clone()));
                data.insert(String::from("name"), Value::from(name.clone()));
            },
            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)
    }

    /// Human-readable message associated with this report item.
    #[must_use]
    pub fn message(&self) -> Cow<'static, str> {
        match self {
            Self::MissingRepositoryUrl => Into::into("missing repository URL in crate metadata"),
            Self::InvalidRepoUrl { repo } => format!("invalid repository URL: '{repo}'").into(),
            Self::InvalidGitRef { repo, rev } => format!("invalid git ref '{rev}' for repository at '{repo}'").into(),
            Self::MissingVcsInfo => Into::into("missing '.cargo_vcs_info.json' in published crate"),
            Self::NoPathInVcsInfo => Into::into("no path specified in '.cargo_vcs_info.json'"),
            Self::NotFoundInRepo { repo, name } => {
                format!("crate '{name}' cannot be found in repository at '{repo}'").into()
            },
            Self::DirtyRepository => Into::into("crate was published from a \"dirty\" repository"),
            Self::MetadataMismatch { field, krate, urepo } => {
                let kmd = krate.as_ref().map_or("(none)", String::as_str);
                let umd = urepo.as_ref().map_or("(none)", String::as_str);
                format!("metadata mismatch: '{field}' differs between crate ({kmd}) and repository ({umd})").into()
            },
            Self::BrokenSymlinkInCrate { path } => format!("broken symbolic link in crate at path '{path}'").into(),
            Self::BrokenSymlinkInRepo { path } => format!("broken symbolic link in repository at path '{path}'").into(),
            Self::InvalidSymlinkInCrate { path } => format!("invalid symbolic link in crate at path '{path}'").into(),
            Self::InvalidSymlinkInRepo { path } => {
                format!("invalid symbolic link in repository at path '{path}'").into()
            },
            Self::MissingFile { path } => {
                format!("file present in crate missing from repository at path '{path}'").into()
            },
            Self::ContentMismatch { path, .. } => {
                format!("contents of file at path '{path}' differ between crate and repository").into()
            },
            Self::LineEndings { path } => {
                format!("contents of file at path '{path}' use different line endings (CRLF / LF)").into()
            },
            Self::Permissions { path, krate, urepo } => {
                format!("file at path '{path}' has different modes in crate ({krate}) and repository ({urepo})").into()
            },
        }
    }

    /// Additional message content from this report item.
    #[must_use]
    pub fn extra(&self) -> Option<String> {
        if let Self::ContentMismatch { diff, .. } = self
            && let Some(diff) = diff
        {
            Some(diff.clone())
        } else {
            None
        }
    }
}

impl Display for ReportItem {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        if f.alternate() {
            writeln!(f, "{}: {}", self.severity(), self.message())?;
            if let Some(extra) = self.extra() {
                for line in extra.lines() {
                    writeln!(f, "  {line}")?;
                }
                writeln!(f)?;
            }
            Ok(())
        } else {
            writeln!(f, "{}: {}", self.severity(), self.message())
        }
    }
}