coil-ops 0.1.1

Operations and release-management capabilities for the Coil framework.
Documentation
use crate::error::OpsModelError;
use crate::identifiers::{SearchFieldId, SearchIndexId};
use crate::validation::require_non_empty;
use coil_auth::Capability;
use coil_core::{
    SearchFieldContribution as ManifestSearchFieldContribution,
    SearchFieldRole as ManifestSearchFieldRole,
    SearchInvalidationRule as ManifestSearchInvalidationRule,
    SearchInvalidationTrigger as ManifestSearchInvalidationTrigger,
};
use std::collections::HashSet;
use std::fmt;
use std::time::Duration;

mod catalog;
mod mapping;

pub use catalog::SearchCatalog;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchDocumentKind {
    Page,
    Product,
    Collection,
    Event,
    EventSlot,
    Booking,
    Media,
    MembershipSubscription,
    Custom,
}

impl fmt::Display for SearchDocumentKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Page => f.write_str("page"),
            Self::Product => f.write_str("product"),
            Self::Collection => f.write_str("collection"),
            Self::Event => f.write_str("event"),
            Self::EventSlot => f.write_str("event_slot"),
            Self::Booking => f.write_str("booking"),
            Self::Media => f.write_str("media"),
            Self::MembershipSubscription => f.write_str("membership_subscription"),
            Self::Custom => f.write_str("custom"),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchFieldRole {
    Title,
    Summary,
    Body,
    Keyword,
    Facet,
    Metadata,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchVisibility {
    Public,
    Authenticated,
    Capability(Capability),
}

impl SearchVisibility {
    pub fn allows(&self, capabilities: &[Capability]) -> bool {
        match self {
            Self::Public => true,
            Self::Authenticated => !capabilities.is_empty(),
            Self::Capability(capability) => capabilities.contains(capability),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchInvalidationTrigger {
    Published,
    Updated,
    Unpublished,
    Deleted,
    ManualRebuild,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchRebuildStrategy {
    OnInvalidate,
    Scheduled { interval: Duration },
    ManualOnly,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchFieldContribution {
    pub id: SearchFieldId,
    pub source_path: String,
    pub role: SearchFieldRole,
    pub stored: bool,
    pub searchable: bool,
}

impl SearchFieldContribution {
    pub fn new(
        id: SearchFieldId,
        source_path: impl Into<String>,
        role: SearchFieldRole,
        stored: bool,
        searchable: bool,
    ) -> Result<Self, OpsModelError> {
        Ok(Self {
            id,
            source_path: require_non_empty("search_field_source_path", source_path.into())?,
            role,
            stored,
            searchable,
        })
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchInvalidationRule {
    pub trigger: SearchInvalidationTrigger,
    pub reason: String,
}

impl SearchInvalidationRule {
    pub fn new(
        trigger: SearchInvalidationTrigger,
        reason: impl Into<String>,
    ) -> Result<Self, OpsModelError> {
        Ok(Self {
            trigger,
            reason: require_non_empty("search_invalidation_reason", reason.into())?,
        })
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchIndexContribution {
    pub id: SearchIndexId,
    pub source_module: String,
    pub document_kind: SearchDocumentKind,
    pub visibility: SearchVisibility,
    pub publication_required: bool,
    pub fields: Vec<SearchFieldContribution>,
    pub invalidation_rules: Vec<SearchInvalidationRule>,
    pub rebuild_strategy: SearchRebuildStrategy,
}

impl SearchIndexContribution {
    pub fn new(
        id: SearchIndexId,
        source_module: impl Into<String>,
        document_kind: SearchDocumentKind,
        visibility: SearchVisibility,
        publication_required: bool,
        fields: Vec<SearchFieldContribution>,
        invalidation_rules: Vec<SearchInvalidationRule>,
        rebuild_strategy: SearchRebuildStrategy,
    ) -> Result<Self, OpsModelError> {
        let source_module = require_non_empty("search_source_module", source_module.into())?;

        if fields.is_empty() {
            return Err(OpsModelError::InvalidSearchVisibility {
                index_id: id.to_string(),
                reason: "at least one indexed field is required".to_string(),
            });
        }

        if matches!(visibility, SearchVisibility::Public) && !publication_required {
            return Err(OpsModelError::InvalidSearchVisibility {
                index_id: id.to_string(),
                reason: "public search indexes must require publication state".to_string(),
            });
        }

        let mut seen_fields = HashSet::new();
        for field in &fields {
            if !seen_fields.insert(field.id.as_str().to_string()) {
                return Err(OpsModelError::DuplicateField {
                    index_id: id.to_string(),
                    field_id: field.id.to_string(),
                });
            }
        }

        Ok(Self {
            id,
            source_module,
            document_kind,
            visibility,
            publication_required,
            fields,
            invalidation_rules,
            rebuild_strategy,
        })
    }

    pub fn visible_to(&self, capabilities: &[Capability]) -> bool {
        self.visibility.allows(capabilities)
    }
}