use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum BronzeCheckMode {
Off,
#[default]
Warn,
Fail,
}
impl BronzeCheckMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Warn => "warn",
Self::Fail => "fail",
}
}
}
impl fmt::Display for BronzeCheckMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceFormat {
#[serde(alias = "ndjson")]
Jsonl,
Json,
Parquet,
Csv,
#[serde(alias = "arrow", alias = "arrow_file", alias = "ipc")]
ArrowIpc,
#[serde(alias = "arrow_stream", alias = "ipc_stream")]
ArrowIpcStream,
Log,
Txt,
Toml,
#[serde(alias = "pb", alias = "proto")]
Protobuf,
}
impl SourceFormat {
pub fn as_str(self) -> &'static str {
match self {
Self::Jsonl => "jsonl",
Self::Json => "json",
Self::Parquet => "parquet",
Self::Csv => "csv",
Self::ArrowIpc => "arrow_ipc",
Self::ArrowIpcStream => "arrow_ipc_stream",
Self::Log => "log",
Self::Txt => "txt",
Self::Toml => "toml",
Self::Protobuf => "protobuf",
}
}
pub fn prefers_datafusion_listing(self) -> bool {
matches!(self, Self::Parquet | Self::Csv | Self::Json | Self::Jsonl)
}
pub fn from_extension(ext: &str) -> Option<Self> {
match ext.to_ascii_lowercase().as_str() {
"jsonl" | "ndjson" => Some(Self::Jsonl),
"json" => Some(Self::Json),
"parquet" | "pq" => Some(Self::Parquet),
"csv" | "tsv" => Some(Self::Csv),
"arrow" | "arrows" | "ipc" | "feather" => Some(Self::ArrowIpc),
"arrows_stream" | "ipc_stream" => Some(Self::ArrowIpcStream),
"log" => Some(Self::Log),
"txt" | "text" | "md" => Some(Self::Txt),
"toml" => Some(Self::Toml),
"pb" | "protobuf" | "protobin" => Some(Self::Protobuf),
_ => None,
}
}
pub fn parse(s: &str) -> Result<Self> {
let key = s.trim().to_ascii_lowercase().replace('-', "_");
match key.as_str() {
"jsonl" | "ndjson" | "json_lines" => Ok(Self::Jsonl),
"json" => Ok(Self::Json),
"parquet" | "pq" => Ok(Self::Parquet),
"csv" | "tsv" => Ok(Self::Csv),
"arrow_ipc" | "arrow" | "arrow_file" | "ipc" | "feather" => Ok(Self::ArrowIpc),
"arrow_ipc_stream" | "arrow_stream" | "ipc_stream" => Ok(Self::ArrowIpcStream),
"log" => Ok(Self::Log),
"txt" | "text" => Ok(Self::Txt),
"toml" => Ok(Self::Toml),
"protobuf" | "pb" | "proto" | "protobin" => Ok(Self::Protobuf),
other => bail!(
"Unknown source_format '{}'. Expected one of: jsonl, json, parquet, csv, arrow_ipc, arrow_ipc_stream, log, txt, toml, protobuf",
other
),
}
}
}
impl fmt::Display for SourceFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ModelTests {
#[serde(default)]
pub not_null: Option<Vec<String>>,
#[serde(default)]
pub unique: Option<Vec<String>>,
#[serde(default)]
pub accepted_values: Option<std::collections::HashMap<String, Vec<String>>>,
#[serde(default)]
pub fail_on_error: Option<bool>,
}
impl ModelTests {
pub fn is_empty(&self) -> bool {
self.not_null.as_ref().map(|v| v.is_empty()).unwrap_or(true)
&& self.unique.as_ref().map(|v| v.is_empty()).unwrap_or(true)
&& self
.accepted_values
.as_ref()
.map(|m| m.is_empty())
.unwrap_or(true)
}
pub fn should_fail_on_error(&self) -> bool {
self.fail_on_error.unwrap_or(true)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ColumnMeta {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub context: Option<String>,
#[serde(default)]
pub dtype: Option<String>,
#[serde(default)]
pub unit: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct StagingFrontmatter {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub context: Option<String>,
#[serde(default)]
pub columns: Option<std::collections::BTreeMap<String, ColumnMeta>>,
#[serde(default)]
pub source_format: Option<SourceFormat>,
#[serde(default)]
pub scan_path: Option<String>,
#[serde(default, deserialize_with = "deserialize_string_or_vec")]
pub path_glob: Option<Vec<String>>,
#[serde(default)]
pub partition_by: Option<Vec<String>>,
#[serde(default)]
pub paths: Option<Vec<String>>,
#[serde(default)]
pub source_name: Option<String>,
#[serde(default)]
pub source_table: Option<String>,
#[serde(default)]
pub toml_rows_key: Option<String>,
#[serde(default)]
pub force_scan: Option<bool>,
#[serde(default)]
pub require_partitions: Option<std::collections::HashMap<String, String>>,
#[serde(default)]
pub inject_source_path: Option<bool>,
#[serde(default)]
pub grain: Option<Vec<String>>,
#[serde(default)]
pub unique_key: Option<Vec<String>>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub materialization: Option<String>,
#[serde(default)]
pub tests: Option<ModelTests>,
#[serde(default)]
pub meta: Option<std::collections::BTreeMap<String, serde_yaml::Value>>,
}
impl StagingFrontmatter {
pub fn resolve_format(&self) -> Result<SourceFormat> {
if let Some(fmt) = self.source_format {
return Ok(fmt);
}
let path = self
.scan_path
.as_deref()
.context("frontmatter missing both source_format and scan_path")?;
let candidate = path.rsplit('/').next().unwrap_or(path);
let candidate = candidate.trim_matches(|c| c == '*' || c == '?');
if let Some(ext) = Path::new(candidate).extension().and_then(|e| e.to_str()) {
if let Some(fmt) = SourceFormat::from_extension(ext) {
return Ok(fmt);
}
}
bail!(
"Cannot infer source_format from scan_path '{}'; set source_format explicitly",
path
);
}
pub fn has_scan_contract(&self) -> bool {
self.scan_path
.as_ref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticSeverity {
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BronzeDiagnostic {
pub model: String,
pub severity: DiagnosticSeverity,
pub code: &'static str,
pub message: String,
}
impl fmt::Display for BronzeDiagnostic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let level = match self.severity {
DiagnosticSeverity::Warning => "warning",
DiagnosticSeverity::Error => "error",
};
write!(
f,
"{}[{}] model={}: {}",
level, self.code, self.model, self.message
)
}
}
#[derive(Debug, Clone, Default)]
pub struct BronzeValidationReport {
pub diagnostics: Vec<BronzeDiagnostic>,
}
impl BronzeValidationReport {
pub fn warning_count(&self) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == DiagnosticSeverity::Warning)
.count()
}
pub fn error_count(&self) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == DiagnosticSeverity::Error)
.count()
}
pub fn has_errors(&self) -> bool {
self.error_count() > 0
}
}
pub fn resolve_scan_path(project_dir: &Path, scan_path: &str) -> PathBuf {
crate::core::paths::resolve_project_path(project_dir, scan_path, &Default::default())
.unwrap_or_else(|_| project_dir.to_path_buf())
}
pub use crate::core::paths::is_remote_uri;
fn deserialize_string_or_vec<'de, D>(
deserializer: D,
) -> std::result::Result<Option<Vec<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{self, SeqAccess, Visitor};
use std::fmt;
struct StringOrVec;
impl<'de> Visitor<'de> for StringOrVec {
type Value = Option<Vec<String>>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a string or list of strings")
}
fn visit_none<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
Ok(None)
}
fn visit_unit<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
Ok(None)
}
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
Ok(Some(vec![v.to_string()]))
}
fn visit_string<E: de::Error>(self, v: String) -> std::result::Result<Self::Value, E> {
Ok(Some(vec![v]))
}
fn visit_seq<A: SeqAccess<'de>>(
self,
mut seq: A,
) -> std::result::Result<Self::Value, A::Error> {
let mut out = Vec::new();
while let Some(s) = seq.next_element::<String>()? {
out.push(s);
}
Ok(Some(out))
}
}
deserializer.deserialize_any(StringOrVec)
}
pub fn scan_path_exists(project_dir: &Path, scan_path: &str) -> bool {
scan_path_exists_with_roots(project_dir, scan_path, &std::collections::HashMap::new())
}
pub fn scan_path_exists_with_roots(
project_dir: &Path,
scan_path: &str,
roots: &std::collections::HashMap<String, String>,
) -> bool {
if is_remote_uri(scan_path.trim()) {
return true;
}
let Ok(resolved) = crate::core::paths::resolve_project_path(project_dir, scan_path, roots)
else {
return false;
};
let check = strip_simple_glob(&resolved);
check.exists()
}
fn strip_simple_glob(path: &Path) -> PathBuf {
let s = path.to_string_lossy();
if s.contains('*') || s.contains('?') {
if let Some(parent) = path.parent() {
return parent.to_path_buf();
}
}
path.to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_from_extension_and_parse() {
assert_eq!(
SourceFormat::from_extension("jsonl"),
Some(SourceFormat::Jsonl)
);
assert_eq!(
SourceFormat::from_extension("toml"),
Some(SourceFormat::Toml)
);
assert_eq!(SourceFormat::from_extension("log"), Some(SourceFormat::Log));
assert_eq!(
SourceFormat::parse("arrow-ipc").unwrap(),
SourceFormat::ArrowIpc
);
assert_eq!(SourceFormat::parse("ndjson").unwrap(), SourceFormat::Jsonl);
}
#[test]
fn resolve_format_from_path() {
let fm = StagingFrontmatter {
scan_path: Some("lake/bronze/raw.jsonl".into()),
..Default::default()
};
assert_eq!(fm.resolve_format().unwrap(), SourceFormat::Jsonl);
}
#[test]
fn remote_uri_exists_for_compile() {
assert!(scan_path_exists(
Path::new("/tmp"),
"s3://bucket/bronze/x.jsonl"
));
}
}