use crate::error::PipelineError;
use std::fmt::Write as _;
#[derive(Debug, Clone, Default)]
pub struct Provenance {
pub source: Option<String>,
pub timestamp: Option<String>,
pub dpid: Option<String>,
}
#[derive(Debug, Default)]
pub struct DaivBuilder {
sources: Vec<(String, String)>,
types: Vec<String>,
lines: Vec<String>,
}
impl DaivBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn declare_types(&mut self, lib: &str) -> Result<(), PipelineError> {
let ok = !lib.is_empty()
&& lib
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '/'));
if !ok {
return Err(PipelineError::Other(format!(
"'{lib}' is not a valid type-library path"
)));
}
if !self.types.iter().any(|t| t == lib) {
self.types.push(lib.to_string());
}
Ok(())
}
pub fn declare_source(&mut self, id: &str, uri: &str) -> Result<(), PipelineError> {
check_ident(id, "provenance source id")?;
if uri.is_empty() || uri.chars().any(|c| c == '\n' || c == '\r' || c == '\0') {
return Err(PipelineError::Other(format!(
"provenance source uri for '{id}' must be a non-empty single line"
)));
}
self.sources.push((id.to_string(), uri.to_string()));
Ok(())
}
pub fn leaf(
&mut self,
namepath: &str,
type_name: &str,
value: &str,
prov: Option<&Provenance>,
) -> Result<(), PipelineError> {
self.leaf_with_unit(namepath, type_name, None, value, prov)
}
pub fn leaf_with_unit(
&mut self,
namepath: &str,
type_name: &str,
unit: Option<&str>,
value: &str,
prov: Option<&Provenance>,
) -> Result<(), PipelineError> {
check_namepath(namepath)?;
check_type_name(type_name)?;
if let Some(u) = unit {
if crate::unit::canonicalize(u).is_none() {
return Err(PipelineError::Other(format!(
"'{u}' is not a well-formed unit expression"
)));
}
}
check_value(namepath, value)?;
let mut line = String::new();
write!(line, "!{type_name}").expect("string write");
if let Some(u) = unit {
write!(line, ":{u}").expect("string write");
}
if let Some(p) = prov {
line.push_str(&self.render_prov(p)?);
}
write!(line, "'{namepath}={value}").expect("string write");
self.lines.push(line);
Ok(())
}
pub fn finish(&self) -> String {
let mut out = String::from(".!kaiv\n");
for lib in &self.types {
let _ = writeln!(out, ".!types {lib}");
}
for (id, uri) in &self.sources {
let _ = writeln!(out, ".?{id} {uri}");
}
for line in &self.lines {
out.push_str(line);
out.push('\n');
}
out
}
fn render_prov(&self, p: &Provenance) -> Result<String, PipelineError> {
let mut out = String::new();
if let Some(src) = &p.source {
check_ident(src, "provenance source id")?;
if !self.sources.iter().any(|(id, _)| id == src) {
return Err(PipelineError::Other(format!(
"provenance source '{src}' is not declared (declare_source first)"
)));
}
write!(out, "?{src}").expect("string write");
}
if let Some(ts) = &p.timestamp {
check_timestamp(ts)?;
if out.is_empty() {
out.push('?');
}
write!(out, "@{ts}").expect("string write");
}
if let Some(dpid) = &p.dpid {
check_ident(dpid, "data point identifier")?;
if out.is_empty() {
out.push('?');
}
write!(out, "#{dpid}").expect("string write");
}
Ok(out)
}
}
#[derive(Debug, Default)]
pub struct KaivBuilder {
inner: DaivBuilder,
}
impl KaivBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn declare_types(&mut self, lib: &str) -> Result<(), PipelineError> {
self.inner.declare_types(lib)
}
pub fn declare_source(&mut self, id: &str, uri: &str) -> Result<(), PipelineError> {
self.inner.declare_source(id, uri)
}
pub fn leaf(
&mut self,
namepath: &str,
type_name: &str,
value: &str,
prov: Option<&Provenance>,
) -> Result<(), PipelineError> {
self.inner.leaf(namepath, type_name, value, prov)
}
pub fn leaf_with_unit(
&mut self,
namepath: &str,
type_name: &str,
unit: Option<&str>,
value: &str,
prov: Option<&Provenance>,
) -> Result<(), PipelineError> {
self.inner
.leaf_with_unit(namepath, type_name, unit, value, prov)
}
pub fn finish(&self) -> Result<String, PipelineError> {
let daiv = self.inner.finish().replacen(".!kaiv\n", ".!daiv\n", 1);
crate::fmt::lift(&daiv)
}
}
fn check_ident(s: &str, what: &str) -> Result<(), PipelineError> {
let b = s.as_bytes();
let ok = !b.is_empty()
&& (b[0].is_ascii_alphanumeric() || b[0] == b'_')
&& b[1..]
.iter()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-'));
if ok {
Ok(())
} else {
Err(PipelineError::Other(format!(
"{what} '{s}' is not a valid identifier ([A-Za-z0-9_][A-Za-z0-9_-]*)"
)))
}
}
fn check_timestamp(ts: &str) -> Result<(), PipelineError> {
let b = ts.as_bytes();
let ok = b.len() == 16
&& b[..8].iter().all(u8::is_ascii_digit)
&& b[8] == b'T'
&& b[9..15].iter().all(u8::is_ascii_digit)
&& b[15] == b'Z';
if ok {
Ok(())
} else {
Err(PipelineError::Other(format!(
"provenance timestamp '{ts}' is not YYYYMMDDTHHmmSSZ"
)))
}
}
fn check_namepath(namepath: &str) -> Result<(), PipelineError> {
let bad = |why: &str| Err(PipelineError::Other(format!("namepath '{namepath}' {why}")));
if crate::lexer::check_key(&format!("!x'{namepath}"), 0, crate::lexer::FileKind::Data).is_err() {
return bad("is not a valid canonical namepath");
}
if namepath.ends_with([':', '+', ';']) {
return bad("ends with an assignment-operator sigil");
}
let projs = unquoted_projections(namepath);
if projs.len() != 1 {
return bad("needs exactly one terminal ::field");
}
let split = projs[0];
let (ns, field) = (&namepath[..split], &namepath[split + 2..]);
if field.is_empty() {
return bad("has an empty terminal field");
}
if !ns.is_empty() && !ns.starts_with('/') {
return bad("has a namespace part that does not start with '/'");
}
Ok(())
}
fn unquoted_projections(s: &str) -> Vec<usize> {
let b = s.as_bytes();
let mut in_quote = false;
let mut positions = Vec::new();
let mut i = 0;
while i < b.len() {
match b[i] {
b'"' => {
if in_quote && b.get(i + 1) == Some(&b'"') {
i += 1;
} else {
in_quote = !in_quote;
}
}
b':' if !in_quote && b.get(i + 1) == Some(&b':') => {
positions.push(i);
i += 1;
}
_ => {}
}
i += 1;
}
positions
}
fn check_type_name(t: &str) -> Result<(), PipelineError> {
let ok = !t.is_empty()
&& t.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '/' | '|'));
if ok {
Ok(())
} else {
Err(PipelineError::Other(format!(
"type annotation '{t}' is not a valid type name"
)))
}
}
fn check_value(namepath: &str, value: &str) -> Result<(), PipelineError> {
if value.starts_with('$') {
return Err(PipelineError::Other(format!(
"value for '{namepath}' starts with '$' (reserved); embed it via std/enc"
)));
}
if value.chars().any(|c| c == '\n' || c == '\r' || c == '\0') {
return Err(PipelineError::Other(format!(
"value for '{namepath}' contains a line break or NUL; embed it via std/enc"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lexer::{lex, FileKind};
fn prov(src: &str, dpid: &str) -> Provenance {
Provenance {
source: Some(src.into()),
timestamp: None,
dpid: Some(dpid.into()),
}
}
#[test]
fn builds_the_conformance_shape() {
let mut b = DaivBuilder::new();
b.declare_source("sensor1", "https://sensors.example.com/1")
.unwrap();
b.leaf(
"::temp",
"str",
"100",
Some(&Provenance {
source: Some("sensor1".into()),
timestamp: Some("20250115T093000Z".into()),
dpid: Some("req-42".into()),
}),
)
.unwrap();
assert_eq!(
b.finish(),
".!kaiv\n.?sensor1 https://sensors.example.com/1\n\
!str?sensor1@20250115T093000Z#req-42'::temp=100\n"
);
}
#[test]
fn output_always_lexes() {
let mut b = DaivBuilder::new();
b.declare_source("t", "file:cart.json").unwrap();
b.leaf("/@rows/0::name", "str", "Ada", Some(&prov("t", "row-1")))
.unwrap();
b.leaf("/@rows/0::total", "int", "12", Some(&prov("t", "row-1")))
.unwrap();
b.leaf("/@rows/1::total", "null", "", None).unwrap();
b.leaf("/server::host", "null|str", "", None).unwrap();
let daiv = b.finish();
lex(daiv.as_bytes(), FileKind::Data).expect("built .daiv must lex");
}
#[test]
fn rejects_invalid_components() {
let mut b = DaivBuilder::new();
assert!(b.leaf("no-projection", "str", "x", None).is_err());
assert!(b.leaf("::a::b", "str", "x", None).is_err());
assert!(b.leaf("server::host", "str", "x", None).is_err());
assert!(b.leaf("/a//b::c", "str", "x", None).is_err());
assert!(b.leaf("::fie'ld", "str", "x", None).is_err());
assert!(b.leaf("::my-field", "str", "x", None).is_err()); assert!(b.leaf("::a b", "str", "x", None).is_err()); assert!(b.leaf("::café", "str", "x", None).is_err()); assert!(b.leaf("::a:", "str", "x", None).is_err()); assert!(b.declare_source("row.17", "u").is_err());
assert!(b.declare_source("-lead", "u").is_err());
assert!(b.leaf("::a", "str", "two\nlines", None).is_err());
assert!(b.leaf("::a", "str", "$ref", None).is_err());
assert!(b.leaf("::a", "st r", "x", None).is_err());
assert!(b
.leaf("::a", "str", "x", Some(&prov("undeclared", "d")))
.is_err());
assert!(b.declare_source("bad id", "u").is_err());
let ts = Provenance {
timestamp: Some("2025-01-15T09:30Z".into()),
..Default::default()
};
assert!(b.leaf("::a", "str", "x", Some(&ts)).is_err());
assert_eq!(b.finish(), ".!kaiv\n");
}
#[test]
fn kaiv_builder_lifts_to_idiomatic_form() {
let mut b = KaivBuilder::new();
b.declare_source("s1", "https://sensors.example.com/1")
.unwrap();
b.leaf("/server::host", "str", "api.example.com", None)
.unwrap();
b.leaf("/server::port", "int", "8443", None).unwrap();
b.leaf("/@tags::0", "str", "prod", None).unwrap();
b.leaf("/@tags::1", "str", "eu", None).unwrap();
b.leaf(
"::temp",
"float",
"21.5",
Some(&Provenance {
source: Some("s1".into()),
timestamp: None,
dpid: None,
}),
)
.unwrap();
b.leaf("::price", "str", "US$5", None).unwrap();
let kaiv = b.finish().unwrap();
assert_eq!(
kaiv,
".!kaiv\n.?s1 https://sensors.example.com/1\n\n(/server)\nhost=api.example.com\n!int\nport=8443\n()\n\n/@tags;=prod;eu\n!float?s1\ntemp=21.5\nprice=US$$5\n"
);
let raiv = crate::compile(kaiv.as_bytes()).expect("authored form compiles");
let daiv = crate::denormalize(&raiv).expect("and denormalizes");
assert!(daiv.contains("!float?s1'::temp=21.5"), "{daiv}");
assert!(daiv.contains("!str'::price=US$5"), "{daiv}");
assert!(daiv.contains("!int'/server::port=8443"), "{daiv}");
}
#[test]
fn accepts_quoted_namepaths_that_lex() {
let mut b = DaivBuilder::new();
b.leaf(r#"::"a=b""#, "str", "x", None).unwrap();
b.leaf(r#"::"a::b""#, "str", "y", None).unwrap();
let daiv = b.finish();
lex(daiv.as_bytes(), FileKind::Data).expect("built .daiv must lex");
}
}