coil-core 0.1.0

Core runtime contracts and composition primitives for the Coil framework.
Documentation
use super::*;

#[derive(Debug, Error, PartialEq, Eq)]
pub enum CapabilityValidationError {
    #[error(
        "module `{module}` requires capability `{capability}` but the active auth package does not bind it"
    )]
    MissingCapability {
        module: String,
        capability: Capability,
    },
    #[error("module `{module}` does not declare a capability contract for `{capability}`")]
    MissingCapabilityContract {
        module: String,
        capability: Capability,
    },
    #[error(
        "module `{module}` declares capability `{capability}` as {actual} but {expected} was required"
    )]
    CapabilityContractRoleMismatch {
        module: String,
        capability: Capability,
        expected: &'static str,
        actual: &'static str,
    },
    #[error("module `{module}` declares capability `{capability}` without any resource kinds")]
    EmptyCapabilityResourceKinds {
        module: String,
        capability: Capability,
    },
    #[error(
        "module `{module}` declares a capability contract for `{capability}` without listing it as required or optional"
    )]
    UndeclaredCapabilityContract {
        module: String,
        capability: Capability,
    },
    #[error("module `{module}` declares duplicate job `{job}`")]
    DuplicateModuleJob { module: String, job: String },
    #[error("module `{module}` subscribes to `{event}` with unknown job `{job}`")]
    UnknownSubscriptionJob {
        module: String,
        event: String,
        job: String,
    },
    #[error("module `{module}` declares duplicate {kind} `{id}`")]
    DuplicateOperationalContribution {
        module: String,
        kind: &'static str,
        id: String,
    },
    #[error("module `{module}` has invalid {kind} `{id}`: {reason}")]
    InvalidOperationalContribution {
        module: String,
        kind: &'static str,
        id: String,
        reason: String,
    },
    #[error("module `{module}` declares duplicate extension slot `{kind:?}` on `{surface}`")]
    DuplicateExtensionSlot {
        module: String,
        kind: ExtensionSlotKind,
        surface: String,
    },
    #[error("module `{module}` has invalid extension slot `{kind:?}` on `{surface}`: {reason}")]
    InvalidExtensionSlot {
        module: String,
        kind: ExtensionSlotKind,
        surface: String,
        reason: String,
    },
}

#[derive(Debug, Error, PartialEq, Eq)]
pub enum ModuleInstallationError {
    #[error("module `{module}` requires module dependency `{dependency}`")]
    MissingModuleDependency { module: String, dependency: String },
    #[error(
        "module `{module}` requires core dependency `{dependency:?}` but service `{service_id}` is not available"
    )]
    MissingCoreServiceDependency {
        module: String,
        dependency: CoreServiceDependency,
        service_id: String,
    },
}

pub fn validate_module_capabilities<P>(
    package: &P,
    manifest: &ModuleManifest,
) -> Result<(), CapabilityValidationError>
where
    P: AuthModelPackage,
{
    for capability in &manifest.required_capabilities {
        if package.binding_for(*capability).is_none() {
            return Err(CapabilityValidationError::MissingCapability {
                module: manifest.name.clone(),
                capability: *capability,
            });
        }

        validate_capability_contract(manifest, *capability, true)?;
    }

    for capability in &manifest.optional_capabilities {
        validate_capability_contract(manifest, *capability, false)?;
    }

    for contract in &manifest.capability_contracts {
        let declared = manifest
            .required_capabilities
            .contains(&contract.capability)
            || manifest
                .optional_capabilities
                .contains(&contract.capability);
        if !declared {
            return Err(CapabilityValidationError::UndeclaredCapabilityContract {
                module: manifest.name.clone(),
                capability: contract.capability,
            });
        }
    }

    let mut seen_jobs = std::collections::BTreeSet::new();
    for job in &manifest.jobs {
        if !seen_jobs.insert(job.name.clone()) {
            return Err(CapabilityValidationError::DuplicateModuleJob {
                module: manifest.name.clone(),
                job: job.name.clone(),
            });
        }
    }

    for subscription in &manifest.event_subscriptions {
        if let Some(job) = &subscription.job {
            if !manifest.jobs.iter().any(|declared| declared.name == *job) {
                return Err(CapabilityValidationError::UnknownSubscriptionJob {
                    module: manifest.name.clone(),
                    event: subscription.event.clone(),
                    job: job.clone(),
                });
            }
        }
    }

    let mut seen_search = BTreeSet::new();
    for contribution in &manifest.search_contributions {
        if !seen_search.insert(contribution.id.clone()) {
            return Err(
                CapabilityValidationError::DuplicateOperationalContribution {
                    module: manifest.name.clone(),
                    kind: "search contribution",
                    id: contribution.id.clone(),
                },
            );
        }

        if contribution.fields.is_empty() {
            return Err(CapabilityValidationError::InvalidOperationalContribution {
                module: manifest.name.clone(),
                kind: "search contribution",
                id: contribution.id.clone(),
                reason: "at least one indexed field is required".to_string(),
            });
        }

        if matches!(contribution.visibility, SearchVisibility::Public)
            && !contribution.publication_required
        {
            return Err(CapabilityValidationError::InvalidOperationalContribution {
                module: manifest.name.clone(),
                kind: "search contribution",
                id: contribution.id.clone(),
                reason: "public search indexes must require publication state".to_string(),
            });
        }

        if let SearchVisibility::Capability(capability) = contribution.visibility {
            validate_declared_capability(manifest, capability)?;
        }
    }

    let mut seen_reports = BTreeSet::new();
    for definition in &manifest.report_definitions {
        if !seen_reports.insert(definition.id.clone()) {
            return Err(
                CapabilityValidationError::DuplicateOperationalContribution {
                    module: manifest.name.clone(),
                    kind: "report definition",
                    id: definition.id.clone(),
                },
            );
        }

        validate_declared_capability(manifest, definition.required_capability)?;
    }

    let mut seen_bulk = BTreeSet::new();
    for definition in &manifest.bulk_operations {
        if !seen_bulk.insert(definition.id.clone()) {
            return Err(
                CapabilityValidationError::DuplicateOperationalContribution {
                    module: manifest.name.clone(),
                    kind: "bulk operation",
                    id: definition.id.clone(),
                },
            );
        }

        validate_declared_capability(manifest, definition.required_capability)?;
    }

    let mut seen_extension_slots = BTreeSet::new();
    for slot in &manifest.extension_slots {
        if slot.surface.trim().is_empty() {
            return Err(CapabilityValidationError::InvalidExtensionSlot {
                module: manifest.name.clone(),
                kind: slot.kind,
                surface: slot.surface.clone(),
                reason: "surface must not be empty".to_string(),
            });
        }

        if slot.description.trim().is_empty() {
            return Err(CapabilityValidationError::InvalidExtensionSlot {
                module: manifest.name.clone(),
                kind: slot.kind,
                surface: slot.surface.clone(),
                reason: "description must not be empty".to_string(),
            });
        }

        if !seen_extension_slots.insert((slot.kind, slot.surface.clone())) {
            return Err(CapabilityValidationError::DuplicateExtensionSlot {
                module: manifest.name.clone(),
                kind: slot.kind,
                surface: slot.surface.clone(),
            });
        }
    }

    Ok(())
}

pub fn validate_module_installation(
    manifest: &ModuleManifest,
    installed_modules: &[String],
    core_service_ids: &[&str],
) -> Result<(), ModuleInstallationError> {
    for dependency in &manifest.module_dependencies {
        if dependency.kind == ModuleDependencyKind::Required
            && !installed_modules.contains(&dependency.module)
        {
            return Err(ModuleInstallationError::MissingModuleDependency {
                module: manifest.name.clone(),
                dependency: dependency.module.clone(),
            });
        }
    }

    for dependency in &manifest.core_service_dependencies {
        for service_id in dependency.required_service_ids() {
            if !core_service_ids.contains(service_id) {
                return Err(ModuleInstallationError::MissingCoreServiceDependency {
                    module: manifest.name.clone(),
                    dependency: *dependency,
                    service_id: (*service_id).to_string(),
                });
            }
        }
    }

    Ok(())
}

fn validate_capability_contract(
    manifest: &ModuleManifest,
    capability: Capability,
    required: bool,
) -> Result<(), CapabilityValidationError> {
    let Some(contract) = manifest
        .capability_contracts
        .iter()
        .find(|contract| contract.capability == capability)
    else {
        return Err(CapabilityValidationError::MissingCapabilityContract {
            module: manifest.name.clone(),
            capability,
        });
    };

    if contract.required != required {
        return Err(CapabilityValidationError::CapabilityContractRoleMismatch {
            module: manifest.name.clone(),
            capability,
            expected: if required { "required" } else { "optional" },
            actual: if contract.required {
                "required"
            } else {
                "optional"
            },
        });
    }

    if contract.resource_kinds.is_empty() {
        return Err(CapabilityValidationError::EmptyCapabilityResourceKinds {
            module: manifest.name.clone(),
            capability,
        });
    }

    Ok(())
}

fn validate_declared_capability(
    manifest: &ModuleManifest,
    capability: Capability,
) -> Result<(), CapabilityValidationError> {
    if package_capability_is_required(manifest, capability) {
        validate_capability_contract(manifest, capability, true)
    } else if manifest.optional_capabilities.contains(&capability) {
        validate_capability_contract(manifest, capability, false)
    } else {
        Err(CapabilityValidationError::UndeclaredCapabilityContract {
            module: manifest.name.clone(),
            capability,
        })
    }
}

fn package_capability_is_required(manifest: &ModuleManifest, capability: Capability) -> bool {
    manifest.required_capabilities.contains(&capability)
}