rebecca-core 0.3.0

Core planning, safety, scanning, and history models for Rebecca.
Documentation
use std::collections::BTreeSet;

use serde::{Deserialize, Serialize};

use crate::project_artifacts::ProjectArtifactPolicy;
use crate::safety_catalog::{ProtectedCategoryKnowledge, WarningKind};
use crate::{Platform, RuleDefinition, SafetyLevel};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CatalogItemKind {
    CleanupRule,
    ProjectArtifact,
    Warning,
    SafetyCategory,
    ActionKind,
}

impl CatalogItemKind {
    pub fn label(&self) -> &'static str {
        match self {
            Self::CleanupRule => "cleanup-rule",
            Self::ProjectArtifact => "project-artifact",
            Self::Warning => "warning",
            Self::SafetyCategory => "safety-category",
            Self::ActionKind => "action-kind",
        }
    }
}

impl std::str::FromStr for CatalogItemKind {
    type Err = String;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value {
            "cleanup-rule" | "rule" | "rules" => Ok(Self::CleanupRule),
            "project-artifact" | "artifact" | "artifacts" => Ok(Self::ProjectArtifact),
            "warning" | "warnings" => Ok(Self::Warning),
            "safety-category" | "safety" | "category" => Ok(Self::SafetyCategory),
            "action-kind" | "action" | "actions" => Ok(Self::ActionKind),
            _ => Err(format!("unknown catalog kind {value}")),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum CatalogItem {
    CleanupRule(CleanupRuleCatalogItem),
    ProjectArtifact(ProjectArtifactCatalogItem),
    Warning(WarningCatalogItem),
    SafetyCategory(SafetyCategoryCatalogItem),
    ActionKind(ActionKindCatalogItem),
}

impl CatalogItem {
    pub fn kind(&self) -> CatalogItemKind {
        match self {
            Self::CleanupRule(_) => CatalogItemKind::CleanupRule,
            Self::ProjectArtifact(_) => CatalogItemKind::ProjectArtifact,
            Self::Warning(_) => CatalogItemKind::Warning,
            Self::SafetyCategory(_) => CatalogItemKind::SafetyCategory,
            Self::ActionKind(_) => CatalogItemKind::ActionKind,
        }
    }

    pub fn id(&self) -> &str {
        match self {
            Self::CleanupRule(item) => &item.id,
            Self::ProjectArtifact(item) => &item.rule_id,
            Self::Warning(item) => &item.id,
            Self::SafetyCategory(item) => &item.id,
            Self::ActionKind(item) => &item.id,
        }
    }

    pub fn matches_query(&self, query: &CatalogQuery) -> bool {
        if let Some(kind) = &query.kind
            && self.kind() != *kind
        {
            return false;
        }

        match self {
            Self::CleanupRule(item) => query.matches_cleanup_rule(item),
            Self::ProjectArtifact(item) => query.matches_project_artifact(item),
            Self::Warning(item) => query.matches_warning(item),
            Self::SafetyCategory(item) => query.matches_safety_category(item),
            Self::ActionKind(item) => query.matches_action_kind(item),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CleanupRuleCatalogItem {
    pub id: String,
    pub platform: Platform,
    pub category: String,
    pub name: String,
    pub safety_level: SafetyLevel,
    pub restore_hint: Option<String>,
    pub warnings: Vec<String>,
    pub search_kinds: Vec<String>,
    pub targets: usize,
}

impl From<&RuleDefinition> for CleanupRuleCatalogItem {
    fn from(rule: &RuleDefinition) -> Self {
        Self {
            id: rule.id.clone(),
            platform: rule.platform,
            category: rule.category.clone(),
            name: rule.name.clone(),
            safety_level: rule.safety_level,
            restore_hint: rule.restore_hint.clone(),
            warnings: rule.warnings.clone(),
            search_kinds: rule
                .path_templates
                .iter()
                .map(|target| target.search_kind().label().to_string())
                .collect::<BTreeSet<_>>()
                .into_iter()
                .collect(),
            targets: rule.path_templates.len(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ProjectArtifactCatalogItem {
    pub artifact: String,
    pub aliases: Vec<String>,
    pub rule_id: String,
    pub rule_suffix: String,
    pub restore_hint: String,
    pub default_min_age_days: u64,
    pub trim_eligible: bool,
    pub deletion_style: String,
    pub ranking: String,
}

impl From<&ProjectArtifactPolicy> for ProjectArtifactCatalogItem {
    fn from(policy: &ProjectArtifactPolicy) -> Self {
        let definition = policy.definition;
        let rule_suffix = project_artifact_rule_suffix(definition.rule_id);
        Self {
            artifact: policy.artifact.to_string(),
            aliases: policy
                .aliases
                .iter()
                .map(|alias| alias.to_string())
                .collect(),
            rule_id: definition.rule_id.to_string(),
            rule_suffix: rule_suffix.to_string(),
            restore_hint: definition.restore_hint.to_string(),
            default_min_age_days: policy.default_min_age_days,
            trim_eligible: policy.trim_eligible,
            deletion_style: policy.deletion_style_label().to_string(),
            ranking: policy.ranking.label().to_string(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WarningCatalogItem {
    pub id: String,
    pub description: String,
}

impl From<&WarningKind> for WarningCatalogItem {
    fn from(warning: &WarningKind) -> Self {
        Self {
            id: warning.id().to_string(),
            description: warning.description().to_string(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SafetyCategoryCatalogItem {
    pub id: String,
    pub description: String,
}

impl From<&ProtectedCategoryKnowledge> for SafetyCategoryCatalogItem {
    fn from(category: &ProtectedCategoryKnowledge) -> Self {
        Self {
            id: category.id().label().to_string(),
            description: category.description().to_string(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ActionKindCatalogItem {
    pub id: String,
    pub description: String,
}

impl ActionKindCatalogItem {
    pub fn delete() -> Self {
        Self {
            id: "delete".to_string(),
            description: "Delete a resolved cleanup target through the active backend.".to_string(),
        }
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CatalogQuery {
    pub kind: Option<CatalogItemKind>,
    pub platform: Option<Platform>,
    pub categories: Vec<String>,
    pub rule_ids: Vec<String>,
    pub artifacts: Vec<String>,
    pub warnings: Vec<String>,
    pub safety_level: Option<SafetyLevel>,
}

impl CatalogQuery {
    pub fn matches_cleanup_rule(&self, item: &CleanupRuleCatalogItem) -> bool {
        self.platform
            .is_none_or(|platform| item.platform == platform)
            && matches_any(&self.categories, &item.category)
            && matches_any(&self.rule_ids, &item.id)
            && (self.warnings.is_empty()
                || self.warnings.iter().any(|warning| {
                    item.warnings
                        .iter()
                        .any(|item| item.eq_ignore_ascii_case(warning))
                }))
            && self
                .safety_level
                .is_none_or(|safety_level| item.safety_level == safety_level)
            && self.artifacts.is_empty()
    }

    pub fn matches_project_artifact(&self, item: &ProjectArtifactCatalogItem) -> bool {
        (self.artifacts.is_empty()
            || self.artifacts.iter().any(|artifact| {
                item.artifact.eq_ignore_ascii_case(artifact)
                    || item.rule_id.eq_ignore_ascii_case(artifact)
                    || item.rule_suffix.eq_ignore_ascii_case(artifact)
                    || item
                        .aliases
                        .iter()
                        .any(|alias| alias.eq_ignore_ascii_case(artifact))
            }))
            && self.categories.is_empty()
            && self.platform.is_none()
            && matches_any(&self.rule_ids, &item.rule_id)
            && self.warnings.is_empty()
            && self.safety_level.is_none()
    }

    pub fn matches_warning(&self, item: &WarningCatalogItem) -> bool {
        (if self.warnings.is_empty() && self.rule_ids.is_empty() {
            true
        } else {
            self.warnings
                .iter()
                .chain(&self.rule_ids)
                .any(|warning| item.id.eq_ignore_ascii_case(warning))
        }) && self.categories.is_empty()
            && self.platform.is_none()
            && self.artifacts.is_empty()
            && self.safety_level.is_none()
    }

    pub fn matches_safety_category(&self, item: &SafetyCategoryCatalogItem) -> bool {
        (if self.categories.is_empty() && self.rule_ids.is_empty() {
            true
        } else {
            self.categories
                .iter()
                .chain(&self.rule_ids)
                .any(|category| item.id.eq_ignore_ascii_case(category))
        }) && self.artifacts.is_empty()
            && self.platform.is_none()
            && self.warnings.is_empty()
            && self.safety_level.is_none()
    }

    pub fn matches_action_kind(&self, item: &ActionKindCatalogItem) -> bool {
        matches_any(&self.rule_ids, &item.id)
            && self.categories.is_empty()
            && self.platform.is_none()
            && self.artifacts.is_empty()
            && self.warnings.is_empty()
            && self.safety_level.is_none()
    }
}

pub fn filter_catalog_items(items: Vec<CatalogItem>, query: &CatalogQuery) -> Vec<CatalogItem> {
    let mut filtered = items
        .into_iter()
        .filter(|item| item.matches_query(query))
        .collect::<Vec<_>>();
    filtered.sort_by(|left, right| {
        left.kind()
            .label()
            .cmp(right.kind().label())
            .then_with(|| left.id().cmp(right.id()))
    });
    filtered
}

fn project_artifact_rule_suffix(rule_id: &str) -> &str {
    rule_id
        .strip_prefix("portable.project-artifact-")
        .unwrap_or(rule_id)
}

fn matches_any(selected: &[String], value: &str) -> bool {
    selected.is_empty() || selected.iter().any(|item| item.eq_ignore_ascii_case(value))
}