use crate::capability::CapabilityEnvelope;
use crate::types::SchemaSpec;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImplKind {
Builtin,
Expression,
Wasm,
Container,
Onnx,
Llm,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ContainerProtocol {
#[default]
ArrowIpc,
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum LabelsRule {
#[default]
Propagate,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Labels {
#[serde(default)]
pub rule: LabelsRule,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransformManifest {
pub id: String,
pub version: String,
#[serde(rename = "impl")]
pub impl_kind: ImplKind,
#[serde(default, rename = "ref")]
pub reference: Option<String>,
#[serde(default)]
pub entry: Option<String>,
#[serde(default)]
pub inputs: Vec<SchemaSpec>,
pub output: SchemaSpec,
#[serde(default)]
pub capabilities: CapabilityEnvelope,
#[serde(default, rename = "columnLineage")]
pub column_lineage: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub labels: Labels,
#[serde(default)]
pub protocol: ContainerProtocol,
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
#[error("TOML parse error: {0}")]
Toml(Box<toml::de::Error>),
#[error("invalid manifest `{id}`: {msg}")]
Invalid { id: String, msg: String },
}
impl TransformManifest {
pub fn from_toml_str(s: &str) -> Result<Self, ManifestError> {
toml::from_str(s).map_err(|e| ManifestError::Toml(Box::new(e)))
}
fn invalid(&self, msg: impl Into<String>) -> ManifestError {
ManifestError::Invalid {
id: self.id.clone(),
msg: msg.into(),
}
}
pub fn validate(&self) -> Result<(), ManifestError> {
if self.id.is_empty() || !is_ident(&self.id) {
return Err(self.invalid("id must be a non-empty identifier (alnum, starting with a letter)"));
}
if semver::Version::parse(&self.version).is_err() {
return Err(self.invalid(format!("version `{}` is not valid semver", self.version)));
}
match self.impl_kind {
ImplKind::Builtin => {
if self.entry.is_some() {
return Err(self.invalid("builtin must not set `entry` (it points at native code via `ref`)"));
}
}
ImplKind::Expression | ImplKind::Wasm | ImplKind::Container | ImplKind::Onnx => {
if self.entry.as_deref().unwrap_or("").is_empty() {
return Err(self.invalid("this impl requires a non-empty `entry` (the artifact/payload path)"));
}
if self.reference.is_some() {
return Err(self.invalid("`ref` is only for builtin impls"));
}
}
ImplKind::Llm => { }
}
if self.output.columns.is_empty() {
return Err(self.invalid("output must declare at least one column"));
}
if self.inputs.len() > 1 {
let mut seen = std::collections::HashSet::new();
for inp in &self.inputs {
match &inp.name {
None => return Err(self.invalid("every input must be named when there is >1 input")),
Some(n) if !seen.insert(n.clone()) => {
return Err(self.invalid(format!("duplicate input name `{n}`")));
}
_ => {}
}
}
}
self.validate_lineage()?;
Ok(())
}
fn validate_lineage(&self) -> Result<(), ManifestError> {
let multi = self.inputs.len() > 1;
for (out_col, sources) in &self.column_lineage {
if !self.output.has_column(out_col) {
return Err(self.invalid(format!("columnLineage references unknown output column `{out_col}`")));
}
if sources.is_empty() {
return Err(self.invalid(format!("columnLineage for `{out_col}` has no sources")));
}
for src in sources {
if multi {
let (inp, col) = src.split_once('.').ok_or_else(|| {
self.invalid(format!(
"multi-input lineage source `{src}` must be qualified as `input.column`"
))
})?;
let found = self
.inputs
.iter()
.find(|i| i.name.as_deref() == Some(inp))
.ok_or_else(|| self.invalid(format!("lineage source references unknown input `{inp}`")))?;
if !found.has_column(col) {
return Err(
self.invalid(format!("input `{inp}` has no column `{col}` (lineage for `{out_col}`)"))
);
}
} else {
if let Some(inp) = self.inputs.first() {
if !inp.has_column(src) {
return Err(self.invalid(format!(
"lineage source `{src}` is not a column of the input (for `{out_col}`)"
)));
}
}
}
}
}
Ok(())
}
pub fn builtin_ref(&self) -> &str {
self.reference.as_deref().unwrap_or(&self.id)
}
}
pub fn parse_and_validate(s: &str) -> Result<TransformManifest, ManifestError> {
let m = TransformManifest::from_toml_str(s)?;
m.validate()?;
Ok(m)
}
fn is_ident(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() => {}
_ => return false,
}
s.chars().all(|c| c.is_ascii_alphanumeric())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_ident_rules() {
assert!(is_ident("haversineKm"));
assert!(is_ident("rename"));
assert!(!is_ident("2cool"));
assert!(!is_ident("has space"));
assert!(!is_ident("snake_case"));
assert!(!is_ident(""));
}
#[test]
fn builtin_ref_defaults_to_id() {
let m = parse_and_validate(
r#"
id = "haversineKm"
version = "0.1.0"
impl = "builtin"
[output]
columns = [{ name = "km", type = "float64" }]
"#,
)
.unwrap();
assert_eq!(m.builtin_ref(), "haversineKm");
}
}