1use anyhow::{bail, Context, Result};
4use serde::{Deserialize, Serialize};
5use std::fmt;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
10#[serde(rename_all = "snake_case")]
11pub enum BronzeCheckMode {
12 Off,
14 #[default]
16 Warn,
17 Fail,
19}
20
21impl BronzeCheckMode {
22 pub fn as_str(self) -> &'static str {
23 match self {
24 Self::Off => "off",
25 Self::Warn => "warn",
26 Self::Fail => "fail",
27 }
28 }
29}
30
31impl fmt::Display for BronzeCheckMode {
32 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33 f.write_str(self.as_str())
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum SourceFormat {
41 #[serde(alias = "ndjson")]
43 Jsonl,
44 Json,
46 Parquet,
47 Csv,
48 #[serde(alias = "arrow", alias = "arrow_file", alias = "ipc")]
50 ArrowIpc,
51 #[serde(alias = "arrow_stream", alias = "ipc_stream")]
53 ArrowIpcStream,
54 Log,
56 Txt,
58 Toml,
60}
61
62impl SourceFormat {
63 pub fn as_str(self) -> &'static str {
64 match self {
65 Self::Jsonl => "jsonl",
66 Self::Json => "json",
67 Self::Parquet => "parquet",
68 Self::Csv => "csv",
69 Self::ArrowIpc => "arrow_ipc",
70 Self::ArrowIpcStream => "arrow_ipc_stream",
71 Self::Log => "log",
72 Self::Txt => "txt",
73 Self::Toml => "toml",
74 }
75 }
76
77 pub fn prefers_datafusion_listing(self) -> bool {
79 matches!(self, Self::Parquet | Self::Csv | Self::Json | Self::Jsonl)
80 }
81
82 pub fn from_extension(ext: &str) -> Option<Self> {
84 match ext.to_ascii_lowercase().as_str() {
85 "jsonl" | "ndjson" => Some(Self::Jsonl),
86 "json" => Some(Self::Json),
87 "parquet" | "pq" => Some(Self::Parquet),
88 "csv" | "tsv" => Some(Self::Csv),
89 "arrow" | "arrows" | "ipc" | "feather" => Some(Self::ArrowIpc),
90 "arrows_stream" | "ipc_stream" => Some(Self::ArrowIpcStream),
91 "log" => Some(Self::Log),
92 "txt" | "text" | "md" => Some(Self::Txt),
93 "toml" => Some(Self::Toml),
94 _ => None,
95 }
96 }
97
98 pub fn parse(s: &str) -> Result<Self> {
100 let key = s.trim().to_ascii_lowercase().replace('-', "_");
101 match key.as_str() {
102 "jsonl" | "ndjson" | "json_lines" => Ok(Self::Jsonl),
103 "json" => Ok(Self::Json),
104 "parquet" | "pq" => Ok(Self::Parquet),
105 "csv" | "tsv" => Ok(Self::Csv),
106 "arrow_ipc" | "arrow" | "arrow_file" | "ipc" | "feather" => Ok(Self::ArrowIpc),
107 "arrow_ipc_stream" | "arrow_stream" | "ipc_stream" => Ok(Self::ArrowIpcStream),
108 "log" => Ok(Self::Log),
109 "txt" | "text" => Ok(Self::Txt),
110 "toml" => Ok(Self::Toml),
111 other => bail!(
112 "Unknown source_format '{}'. Expected one of: jsonl, json, parquet, csv, arrow_ipc, arrow_ipc_stream, log, txt, toml",
113 other
114 ),
115 }
116 }
117}
118
119impl fmt::Display for SourceFormat {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 f.write_str(self.as_str())
122 }
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
127pub struct ModelTests {
128 #[serde(default)]
130 pub not_null: Option<Vec<String>>,
131 #[serde(default)]
133 pub unique: Option<Vec<String>>,
134 #[serde(default)]
136 pub accepted_values: Option<std::collections::HashMap<String, Vec<String>>>,
137 #[serde(default)]
139 pub fail_on_error: Option<bool>,
140}
141
142impl ModelTests {
143 pub fn is_empty(&self) -> bool {
144 self.not_null.as_ref().map(|v| v.is_empty()).unwrap_or(true)
145 && self.unique.as_ref().map(|v| v.is_empty()).unwrap_or(true)
146 && self
147 .accepted_values
148 .as_ref()
149 .map(|m| m.is_empty())
150 .unwrap_or(true)
151 }
152
153 pub fn should_fail_on_error(&self) -> bool {
154 self.fail_on_error.unwrap_or(true)
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
163pub struct ColumnMeta {
164 #[serde(default)]
165 pub description: Option<String>,
166 #[serde(default)]
167 pub context: Option<String>,
168 #[serde(default)]
170 pub dtype: Option<String>,
171 #[serde(default)]
173 pub unit: Option<String>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
181pub struct StagingFrontmatter {
182 #[serde(default)]
184 pub description: Option<String>,
185 #[serde(default)]
187 pub context: Option<String>,
188 #[serde(default)]
190 pub columns: Option<std::collections::BTreeMap<String, ColumnMeta>>,
191
192 #[serde(default)]
194 pub source_format: Option<SourceFormat>,
195 #[serde(default)]
197 pub scan_path: Option<String>,
198 #[serde(default)]
200 pub partition_by: Option<Vec<String>>,
201 #[serde(default)]
203 pub paths: Option<Vec<String>>,
204 #[serde(default)]
206 pub source_name: Option<String>,
207 #[serde(default)]
209 pub source_table: Option<String>,
210 #[serde(default)]
212 pub toml_rows_key: Option<String>,
213 #[serde(default)]
215 pub force_scan: Option<bool>,
216 #[serde(default)]
219 pub require_partitions: Option<std::collections::HashMap<String, String>>,
220 #[serde(default)]
223 pub inject_source_path: Option<bool>,
224
225 #[serde(default)]
227 pub grain: Option<Vec<String>>,
228 #[serde(default)]
230 pub unique_key: Option<Vec<String>>,
231 #[serde(default)]
233 pub tags: Option<Vec<String>>,
234 #[serde(default)]
236 pub materialization: Option<String>,
237 #[serde(default)]
239 pub tests: Option<ModelTests>,
240 #[serde(default)]
242 pub meta: Option<std::collections::BTreeMap<String, serde_yaml::Value>>,
243}
244
245impl StagingFrontmatter {
246 pub fn resolve_format(&self) -> Result<SourceFormat> {
248 if let Some(fmt) = self.source_format {
249 return Ok(fmt);
250 }
251 let path = self
252 .scan_path
253 .as_deref()
254 .context("frontmatter missing both source_format and scan_path")?;
255 let candidate = path.rsplit('/').next().unwrap_or(path);
257 let candidate = candidate.trim_matches(|c| c == '*' || c == '?');
258 if let Some(ext) = Path::new(candidate).extension().and_then(|e| e.to_str()) {
259 if let Some(fmt) = SourceFormat::from_extension(ext) {
260 return Ok(fmt);
261 }
262 }
263 bail!(
265 "Cannot infer source_format from scan_path '{}'; set source_format explicitly",
266 path
267 );
268 }
269
270 pub fn has_scan_contract(&self) -> bool {
271 self.scan_path.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false)
272 }
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum DiagnosticSeverity {
278 Warning,
279 Error,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct BronzeDiagnostic {
285 pub model: String,
286 pub severity: DiagnosticSeverity,
287 pub code: &'static str,
288 pub message: String,
289}
290
291impl fmt::Display for BronzeDiagnostic {
292 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293 let level = match self.severity {
294 DiagnosticSeverity::Warning => "warning",
295 DiagnosticSeverity::Error => "error",
296 };
297 write!(
298 f,
299 "{}[{}] model={}: {}",
300 level, self.code, self.model, self.message
301 )
302 }
303}
304
305#[derive(Debug, Clone, Default)]
307pub struct BronzeValidationReport {
308 pub diagnostics: Vec<BronzeDiagnostic>,
309}
310
311impl BronzeValidationReport {
312 pub fn warning_count(&self) -> usize {
313 self.diagnostics
314 .iter()
315 .filter(|d| d.severity == DiagnosticSeverity::Warning)
316 .count()
317 }
318
319 pub fn error_count(&self) -> usize {
320 self.diagnostics
321 .iter()
322 .filter(|d| d.severity == DiagnosticSeverity::Error)
323 .count()
324 }
325
326 pub fn has_errors(&self) -> bool {
327 self.error_count() > 0
328 }
329}
330
331pub fn resolve_scan_path(project_dir: &Path, scan_path: &str) -> PathBuf {
333 let trimmed = scan_path.trim();
334 if trimmed.is_empty() {
335 return project_dir.to_path_buf();
336 }
337 if is_remote_uri(trimmed) {
338 return PathBuf::from(trimmed);
339 }
340 let p = Path::new(trimmed);
341 if p.is_absolute() {
342 p.to_path_buf()
343 } else {
344 project_dir.join(p)
345 }
346}
347
348pub fn is_remote_uri(path: &str) -> bool {
349 let lower = path.to_ascii_lowercase();
350 lower.starts_with("s3://")
351 || lower.starts_with("s3a://")
352 || lower.starts_with("gs://")
353 || lower.starts_with("gcs://")
354 || lower.starts_with("az://")
355 || lower.starts_with("abfs://")
356 || lower.starts_with("abfss://")
357 || lower.starts_with("http://")
358 || lower.starts_with("https://")
359 || lower.starts_with("file://")
360}
361
362pub fn scan_path_exists(project_dir: &Path, scan_path: &str) -> bool {
365 if is_remote_uri(scan_path.trim()) {
366 return true;
367 }
368 let resolved = resolve_scan_path(project_dir, scan_path);
369 let check = strip_simple_glob(&resolved);
371 check.exists()
372}
373
374fn strip_simple_glob(path: &Path) -> PathBuf {
375 let s = path.to_string_lossy();
376 if s.contains('*') || s.contains('?') {
377 if let Some(parent) = path.parent() {
378 return parent.to_path_buf();
379 }
380 }
381 path.to_path_buf()
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn format_from_extension_and_parse() {
390 assert_eq!(SourceFormat::from_extension("jsonl"), Some(SourceFormat::Jsonl));
391 assert_eq!(SourceFormat::from_extension("toml"), Some(SourceFormat::Toml));
392 assert_eq!(SourceFormat::from_extension("log"), Some(SourceFormat::Log));
393 assert_eq!(SourceFormat::parse("arrow-ipc").unwrap(), SourceFormat::ArrowIpc);
394 assert_eq!(SourceFormat::parse("ndjson").unwrap(), SourceFormat::Jsonl);
395 }
396
397 #[test]
398 fn resolve_format_from_path() {
399 let fm = StagingFrontmatter {
400 scan_path: Some("lake/bronze/raw.jsonl".into()),
401 ..Default::default()
402 };
403 assert_eq!(fm.resolve_format().unwrap(), SourceFormat::Jsonl);
404 }
405
406 #[test]
407 fn remote_uri_exists_for_compile() {
408 assert!(scan_path_exists(Path::new("/tmp"), "s3://bucket/bronze/x.jsonl"));
409 }
410}