1use crate::capability::CapabilityEnvelope;
6use crate::types::SchemaSpec;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum ImplKind {
14 Builtin,
17 Expression,
19 Wasm,
21 Container,
23 Onnx,
28 Llm,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
36#[serde(rename_all = "kebab-case")]
37pub enum ContainerProtocol {
38 #[default]
39 ArrowIpc,
40 Json,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
45#[serde(rename_all = "snake_case")]
46pub enum LabelsRule {
47 #[default]
49 Propagate,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, Default)]
53pub struct Labels {
54 #[serde(default)]
55 pub rule: LabelsRule,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct TransformManifest {
62 pub id: String,
64 pub version: String,
66 #[serde(rename = "impl")]
67 pub impl_kind: ImplKind,
68 #[serde(default, rename = "ref")]
70 pub reference: Option<String>,
71 #[serde(default)]
73 pub entry: Option<String>,
74 #[serde(default)]
76 pub inputs: Vec<SchemaSpec>,
77 pub output: SchemaSpec,
79 #[serde(default)]
80 pub capabilities: CapabilityEnvelope,
81 #[serde(default, rename = "columnLineage")]
84 pub column_lineage: BTreeMap<String, Vec<String>>,
85 #[serde(default)]
86 pub labels: Labels,
87 #[serde(default)]
89 pub protocol: ContainerProtocol,
90}
91
92#[derive(Debug, thiserror::Error)]
93pub enum ManifestError {
94 #[error("TOML parse error: {0}")]
97 Toml(Box<toml::de::Error>),
98 #[error("invalid manifest `{id}`: {msg}")]
99 Invalid { id: String, msg: String },
100}
101
102impl TransformManifest {
103 pub fn from_toml_str(s: &str) -> Result<Self, ManifestError> {
105 toml::from_str(s).map_err(|e| ManifestError::Toml(Box::new(e)))
106 }
107
108 fn invalid(&self, msg: impl Into<String>) -> ManifestError {
109 ManifestError::Invalid {
110 id: self.id.clone(),
111 msg: msg.into(),
112 }
113 }
114
115 pub fn validate(&self) -> Result<(), ManifestError> {
117 if self.id.is_empty() || !is_ident(&self.id) {
119 return Err(self.invalid("id must be a non-empty identifier (alnum, starting with a letter)"));
120 }
121 if semver::Version::parse(&self.version).is_err() {
123 return Err(self.invalid(format!("version `{}` is not valid semver", self.version)));
124 }
125 match self.impl_kind {
127 ImplKind::Builtin => {
128 if self.entry.is_some() {
129 return Err(self.invalid("builtin must not set `entry` (it points at native code via `ref`)"));
130 }
131 }
133 ImplKind::Expression | ImplKind::Wasm | ImplKind::Container | ImplKind::Onnx => {
134 if self.entry.as_deref().unwrap_or("").is_empty() {
135 return Err(self.invalid("this impl requires a non-empty `entry` (the artifact/payload path)"));
136 }
137 if self.reference.is_some() {
138 return Err(self.invalid("`ref` is only for builtin impls"));
139 }
140 }
141 ImplKind::Llm => { }
142 }
143 if self.output.columns.is_empty() {
145 return Err(self.invalid("output must declare at least one column"));
146 }
147 if self.inputs.len() > 1 {
149 let mut seen = std::collections::HashSet::new();
150 for inp in &self.inputs {
151 match &inp.name {
152 None => return Err(self.invalid("every input must be named when there is >1 input")),
153 Some(n) if !seen.insert(n.clone()) => {
154 return Err(self.invalid(format!("duplicate input name `{n}`")));
155 }
156 _ => {}
157 }
158 }
159 }
160 self.validate_lineage()?;
162 Ok(())
163 }
164
165 fn validate_lineage(&self) -> Result<(), ManifestError> {
166 let multi = self.inputs.len() > 1;
167 for (out_col, sources) in &self.column_lineage {
168 if !self.output.has_column(out_col) {
169 return Err(self.invalid(format!("columnLineage references unknown output column `{out_col}`")));
170 }
171 if sources.is_empty() {
172 return Err(self.invalid(format!("columnLineage for `{out_col}` has no sources")));
173 }
174 for src in sources {
175 if multi {
176 let (inp, col) = src.split_once('.').ok_or_else(|| {
178 self.invalid(format!(
179 "multi-input lineage source `{src}` must be qualified as `input.column`"
180 ))
181 })?;
182 let found = self
183 .inputs
184 .iter()
185 .find(|i| i.name.as_deref() == Some(inp))
186 .ok_or_else(|| self.invalid(format!("lineage source references unknown input `{inp}`")))?;
187 if !found.has_column(col) {
188 return Err(
189 self.invalid(format!("input `{inp}` has no column `{col}` (lineage for `{out_col}`)"))
190 );
191 }
192 } else {
193 if let Some(inp) = self.inputs.first() {
195 if !inp.has_column(src) {
196 return Err(self.invalid(format!(
197 "lineage source `{src}` is not a column of the input (for `{out_col}`)"
198 )));
199 }
200 }
201 }
202 }
203 }
204 Ok(())
205 }
206
207 pub fn builtin_ref(&self) -> &str {
209 self.reference.as_deref().unwrap_or(&self.id)
210 }
211}
212
213pub fn parse_and_validate(s: &str) -> Result<TransformManifest, ManifestError> {
215 let m = TransformManifest::from_toml_str(s)?;
216 m.validate()?;
217 Ok(m)
218}
219
220fn is_ident(s: &str) -> bool {
222 let mut chars = s.chars();
223 match chars.next() {
224 Some(c) if c.is_ascii_alphabetic() => {}
225 _ => return false,
226 }
227 s.chars().all(|c| c.is_ascii_alphanumeric())
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn is_ident_rules() {
236 assert!(is_ident("haversineKm"));
237 assert!(is_ident("rename"));
238 assert!(!is_ident("2cool"));
239 assert!(!is_ident("has space"));
240 assert!(!is_ident("snake_case"));
241 assert!(!is_ident(""));
242 }
243
244 #[test]
245 fn builtin_ref_defaults_to_id() {
246 let m = parse_and_validate(
247 r#"
248 id = "haversineKm"
249 version = "0.1.0"
250 impl = "builtin"
251 [output]
252 columns = [{ name = "km", type = "float64" }]
253 "#,
254 )
255 .unwrap();
256 assert_eq!(m.builtin_ref(), "haversineKm");
257 }
258}