component-manifest 0.2.0

Manifest validation helpers for Greentic components
Documentation
use std::collections::HashSet;

use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use thiserror::Error;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(transparent)]
pub struct CapabilityRef(pub String);

impl CapabilityRef {
    pub fn validate(&self, pattern: &Regex) -> Result<(), ManifestError> {
        if self.0.trim().is_empty() {
            return Err(ManifestError::EmptyField("capabilities"));
        }
        if !pattern.is_match(&self.0) {
            return Err(ManifestError::InvalidCapability(self.0.clone()));
        }
        Ok(())
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentExport {
    pub operation: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub input_schema: Option<Value>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub output_schema: Option<Value>,
}

impl ComponentExport {
    pub fn validate(&self, pattern: &Regex) -> Result<(), ManifestError> {
        if self.operation.trim().is_empty() {
            return Err(ManifestError::EmptyField("exports.operation"));
        }
        if !pattern.is_match(&self.operation) {
            return Err(ManifestError::InvalidOperation(self.operation.clone()));
        }
        Ok(())
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WitCompat {
    pub package: String,
    pub min: String,
    pub max: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentManifest {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(default)]
    pub capabilities: Vec<CapabilityRef>,
    #[serde(default)]
    pub exports: Vec<ComponentExport>,
    pub config_schema: Value,
    #[serde(default)]
    pub secrets: Vec<String>,
    pub wit_compat: WitCompat,
    #[serde(default, skip_serializing_if = "Map::is_empty")]
    pub metadata: Map<String, Value>,
}

impl ComponentManifest {
    pub fn from_value(value: Value) -> Result<Self, ManifestError> {
        Ok(serde_json::from_value(value)?)
    }
}

#[derive(Debug, Clone)]
pub struct CompiledExportSchema {
    pub operation: String,
    pub description: Option<String>,
    pub input_schema: Option<Value>,
    pub output_schema: Option<Value>,
}

#[derive(Debug, Clone)]
pub struct ComponentInfo {
    pub name: Option<String>,
    pub description: Option<String>,
    pub capabilities: Vec<CapabilityRef>,
    pub exports: Vec<CompiledExportSchema>,
    pub config_schema: Value,
    pub secrets: Vec<String>,
    pub wit_compat: WitCompat,
    pub metadata: Map<String, Value>,
    pub raw: Value,
}

#[derive(Debug, Error)]
pub enum ManifestError {
    #[error("manifest json parse failed: {0}")]
    Json(#[from] serde_json::Error),
    #[error("config schema must be a JSON object")]
    ConfigSchemaNotObject,
    #[error("invalid config schema: {0}")]
    InvalidConfigSchema(String),
    #[error("invalid export schema for `{operation}`: {reason}")]
    InvalidExportSchema { operation: String, reason: String },
    #[error("capabilities must not be empty")]
    MissingCapabilities,
    #[error("exports must not be empty")]
    MissingExports,
    #[error("duplicate capability `{0}` detected")]
    DuplicateCapability(String),
    #[error("duplicate secret `{0}` detected")]
    DuplicateSecret(String),
    #[error("duplicate operation `{0}` detected")]
    DuplicateOperation(String),
    #[error("secret name `{0}` is invalid")]
    InvalidSecret(String),
    #[error("capability `{0}` is invalid")]
    InvalidCapability(String),
    #[error("operation `{0}` is invalid")]
    InvalidOperation(String),
    #[error("wit package must be `greentic:component`, found `{found}`")]
    InvalidWitPackage { found: String },
    #[error("invalid version requirement for `{field}`: {source}")]
    InvalidVersionReq {
        field: &'static str,
        #[source]
        source: semver::Error,
    },
    #[error("field `{0}` is required and cannot be empty")]
    EmptyField(&'static str),
}

pub(crate) fn ensure_unique<T, F>(
    values: impl IntoIterator<Item = T>,
    mut duplicate_err: F,
) -> Result<(), ManifestError>
where
    T: Eq + std::hash::Hash + Clone,
    F: FnMut(T) -> ManifestError,
{
    let mut seen = HashSet::new();
    for value in values {
        if !seen.insert(value.clone()) {
            return Err(duplicate_err(value));
        }
    }
    Ok(())
}