rebecca-core 0.3.0

Core planning, safety, scanning, and history models for Rebecca.
Documentation
use std::path::{Path, PathBuf};

use thiserror::Error;

pub type Result<T> = std::result::Result<T, RebeccaError>;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScanFailureKind {
    NotFound,
    PermissionDenied,
    InvalidInput,
    DirectoryLoop,
    MetadataUnavailable,
    DirectoryTraversal,
}

impl ScanFailureKind {
    fn label(self) -> &'static str {
        match self {
            Self::NotFound => "not-found",
            Self::PermissionDenied => "permission-denied",
            Self::InvalidInput => "invalid-input",
            Self::DirectoryLoop => "directory-loop",
            Self::MetadataUnavailable => "metadata-unavailable",
            Self::DirectoryTraversal => "directory-traversal",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScanFailurePhase {
    RootMetadata,
    DirectoryWalk,
    EntryMetadata,
}

impl ScanFailurePhase {
    fn label(self) -> &'static str {
        match self {
            Self::RootMetadata => "root-metadata",
            Self::DirectoryWalk => "directory-walk",
            Self::EntryMetadata => "entry-metadata",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScanFailure {
    pub kind: ScanFailureKind,
    pub phase: ScanFailurePhase,
    pub path: PathBuf,
    pub message: String,
}

impl ScanFailure {
    pub fn from_io(path: &Path, phase: ScanFailurePhase, err: &std::io::Error) -> Self {
        Self {
            kind: classify_io_error(err),
            phase,
            path: path.to_path_buf(),
            message: err.to_string(),
        }
    }

    pub fn directory_walk(path: &Path, err: &ignore::Error) -> Self {
        Self::from_ignore(path, ScanFailurePhase::DirectoryWalk, err)
    }

    pub fn from_ignore(path: &Path, phase: ScanFailurePhase, err: &ignore::Error) -> Self {
        let kind = classify_ignore_error(err).unwrap_or(match phase {
            ScanFailurePhase::DirectoryWalk => ScanFailureKind::DirectoryTraversal,
            ScanFailurePhase::EntryMetadata | ScanFailurePhase::RootMetadata => {
                ScanFailureKind::MetadataUnavailable
            }
        });

        Self {
            kind,
            phase,
            path: ignore_error_path(err).unwrap_or(path).to_path_buf(),
            message: err.to_string(),
        }
    }
}

impl std::fmt::Display for ScanFailure {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            formatter,
            "{} during {} at {}: {}",
            self.kind.label(),
            self.phase.label(),
            self.path.display(),
            self.message
        )
    }
}

fn classify_io_error(err: &std::io::Error) -> ScanFailureKind {
    match err.kind() {
        std::io::ErrorKind::NotFound => ScanFailureKind::NotFound,
        std::io::ErrorKind::PermissionDenied => ScanFailureKind::PermissionDenied,
        std::io::ErrorKind::InvalidInput => ScanFailureKind::InvalidInput,
        _ => ScanFailureKind::MetadataUnavailable,
    }
}

fn classify_ignore_error(err: &ignore::Error) -> Option<ScanFailureKind> {
    match err {
        ignore::Error::Partial(errors) if errors.len() == 1 => {
            errors.first().and_then(classify_ignore_error)
        }
        ignore::Error::WithLineNumber { err, .. }
        | ignore::Error::WithPath { err, .. }
        | ignore::Error::WithDepth { err, .. } => classify_ignore_error(err),
        ignore::Error::Loop { .. } => Some(ScanFailureKind::DirectoryLoop),
        ignore::Error::Io(err) => Some(classify_io_error(err)),
        _ => None,
    }
}

fn ignore_error_path(err: &ignore::Error) -> Option<&Path> {
    match err {
        ignore::Error::Partial(errors) if errors.len() == 1 => {
            errors.first().and_then(ignore_error_path)
        }
        ignore::Error::WithLineNumber { err, .. } | ignore::Error::WithDepth { err, .. } => {
            ignore_error_path(err)
        }
        ignore::Error::WithPath { path, .. } => Some(path),
        ignore::Error::Loop { child, .. } => Some(child),
        _ => None,
    }
}

#[derive(Debug, Error)]
pub enum RebeccaError {
    #[error("i/o error: {0}")]
    Io(#[from] std::io::Error),

    #[error("json error: {0}")]
    Json(#[from] serde_json::Error),

    #[error("config read failed at {path}: {message}")]
    ConfigRead { path: PathBuf, message: String },

    #[error("config parse failed at {path}: {message}")]
    ConfigParse { path: PathBuf, message: String },

    #[error("could not locate the current user's standard directories")]
    UserDirsUnavailable,

    #[error("invalid rule id: {0}")]
    InvalidRuleId(String),

    #[error("invalid category: {0}")]
    InvalidCategory(String),

    #[error("invalid project artifact selector: {0}")]
    InvalidProjectArtifactSelector(String),

    #[error("invalid catalog selector: {0}")]
    InvalidCatalogSelector(String),

    #[error("invalid rule catalog: {0}")]
    RuleCatalogInvalid(String),

    #[error("invalid safety catalog: {0}")]
    SafetyCatalogInvalid(String),

    #[error("path template expansion failed: {0}")]
    PathExpansionFailed(String),

    #[error("application discovery failed: {0}")]
    ApplicationDiscoveryFailed(String),

    #[error("cleanup target was blocked by safety policy: {0}")]
    SafetyBlocked(String),

    #[error("scan failed: {0}")]
    ScanFailed(ScanFailure),

    #[error("scan cache is unavailable: {0}")]
    ScanCacheUnavailable(String),

    #[error("operation cancelled: {0}")]
    OperationCancelled(String),

    #[error("platform feature is not available: {0}")]
    PlatformUnavailable(String),

    #[error("history is unavailable: {0}")]
    HistoryUnavailable(String),

    #[error("history record was corrupted: {0}")]
    HistoryCorrupted(String),

    #[error("cleanup execution failed: {0}")]
    ExecutionFailed(String),
}

#[cfg(test)]
mod tests {
    use super::{ScanFailure, ScanFailureKind, ScanFailurePhase};

    #[test]
    fn ignore_error_failure_keeps_nested_path_and_io_kind() {
        let root = std::path::Path::new("root");
        let nested = std::path::PathBuf::from("root/cache");
        let err = ignore::Error::WithPath {
            path: nested.clone(),
            err: Box::new(ignore::Error::Io(std::io::Error::new(
                std::io::ErrorKind::PermissionDenied,
                "denied",
            ))),
        };

        let failure = ScanFailure::from_ignore(root, ScanFailurePhase::DirectoryWalk, &err);

        assert_eq!(failure.kind, ScanFailureKind::PermissionDenied);
        assert_eq!(failure.phase, ScanFailurePhase::DirectoryWalk);
        assert_eq!(failure.path, nested);
    }
}