1use rich::{Console, ConsoleOptions, Renderable, Segment, Table, Text};
44use rich_ext::sanitize::{sanitize_single_line, sanitize_terminal_and_bidi_controls};
45use serde::{Deserialize, Serialize};
46
47use crate::profile::{block, join, style};
48use crate::{DataError, Rows, Value};
49
50pub const STYLES: &[(&str, &str)] = &[
53 ("quality.pass", "green"),
54 ("quality.warn", "yellow"),
55 ("quality.fail", "bold red"),
56 ("quality.error", "bold magenta"),
57 ("quality.check", "bold"),
58 ("quality.dim", "dim"),
59];
60
61pub const FAILING_ROWS: usize = 5;
63
64#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum Status {
68 Pass,
70 Warn,
72 Fail,
74 Error,
76}
77
78impl Status {
79 pub fn label(self) -> &'static str {
81 match self {
82 Status::Pass => "PASS",
83 Status::Warn => "WARN",
84 Status::Fail => "FAIL",
85 Status::Error => "ERROR",
86 }
87 }
88
89 pub fn key(self) -> &'static str {
91 match self {
92 Status::Pass => "quality.pass",
93 Status::Warn => "quality.warn",
94 Status::Fail => "quality.fail",
95 Status::Error => "quality.error",
96 }
97 }
98}
99
100#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
102pub struct FailingRows {
103 pub columns: Vec<String>,
105 pub rows: Vec<Vec<String>>,
107 pub total: Option<u64>,
109}
110
111#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
113pub struct CheckResult {
114 pub check: String,
116 pub column: Option<String>,
118 pub status: Status,
120 pub observed: Option<String>,
122 pub expected: Option<String>,
124 pub message: Option<String>,
126 pub failing_rows: Option<FailingRows>,
128}
129
130impl CheckResult {
131 pub fn new(check: impl Into<String>, status: Status) -> Self {
133 CheckResult {
134 check: check.into(),
135 column: None,
136 status,
137 observed: None,
138 expected: None,
139 message: None,
140 failing_rows: None,
141 }
142 }
143
144 pub fn column(mut self, column: impl Into<String>) -> Self {
146 self.column = Some(column.into());
147 self
148 }
149
150 pub fn observed(mut self, observed: impl Into<String>) -> Self {
152 self.observed = Some(observed.into());
153 self
154 }
155
156 pub fn expected(mut self, expected: impl Into<String>) -> Self {
158 self.expected = Some(expected.into());
159 self
160 }
161
162 pub fn message(mut self, message: impl Into<String>) -> Self {
164 self.message = Some(message.into());
165 self
166 }
167
168 pub fn failing_rows(mut self, rows: FailingRows) -> Self {
170 self.failing_rows = Some(rows);
171 self
172 }
173
174 pub fn title(&self) -> String {
176 match &self.column {
177 Some(column) => format!("{} › {}", self.check, column),
178 None => self.check.clone(),
179 }
180 }
181}
182
183#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
185pub struct Totals {
186 pub passed: usize,
188 pub warned: usize,
190 pub failed: usize,
192 pub errored: usize,
194}
195
196#[derive(Clone, Debug, PartialEq)]
199pub struct QualityReport {
200 results: Vec<CheckResult>,
201 show_rows: usize,
202}
203
204impl QualityReport {
205 pub fn new(results: impl IntoIterator<Item = CheckResult>) -> Self {
207 QualityReport {
208 results: results.into_iter().collect(),
209 show_rows: FAILING_ROWS,
210 }
211 }
212
213 pub fn push(&mut self, result: CheckResult) -> &mut Self {
215 self.results.push(result);
216 self
217 }
218
219 pub fn show_rows(mut self, rows: usize) -> Self {
221 self.show_rows = rows;
222 self
223 }
224
225 pub fn results(&self) -> &[CheckResult] {
227 &self.results
228 }
229
230 pub fn totals(&self) -> Totals {
232 let mut t = Totals::default();
233 for r in &self.results {
234 match r.status {
235 Status::Pass => t.passed += 1,
236 Status::Warn => t.warned += 1,
237 Status::Fail => t.failed += 1,
238 Status::Error => t.errored += 1,
239 }
240 }
241 t
242 }
243
244 pub fn is_success(&self) -> bool {
246 let t = self.totals();
247 t.failed == 0 && t.errored == 0
248 }
249
250 pub fn summary(&self) -> String {
252 let t = self.totals();
253 let mut parts = vec![
254 format!("{} passed", t.passed),
255 format!("{} warned", t.warned),
256 format!("{} failed", t.failed),
257 ];
258 if t.errored > 0 {
259 parts.push(format!("{} errored", t.errored));
260 }
261 parts.join(", ")
262 }
263
264 pub fn to_json(&self) -> serde_json::Value {
266 serde_json::json!({
267 "results": self.results,
268 "totals": self.totals(),
269 "ok": self.is_success(),
270 })
271 }
272
273 pub fn to_table(&self, console: &Console) -> Table {
275 let mut table = Table::new();
276 for heading in ["status", "check", "column", "observed", "expected"] {
277 table.add_column(heading);
278 }
279 for r in &self.results {
280 let line =
281 |v: &Option<String>| Text::new(v.as_deref().map(one_line).unwrap_or_default());
282 table.add_row_text(vec![
283 Text::styled(r.status.label(), style(console, r.status.key(), STYLES)),
284 Text::new(one_line(&r.check)),
285 line(&r.column),
286 line(&r.observed),
287 line(&r.expected),
288 ]);
289 }
290 table
291 }
292
293 fn details(
294 &self,
295 console: &Console,
296 options: &ConsoleOptions,
297 r: &CheckResult,
298 ) -> Vec<Vec<Segment>> {
299 let mut lines = Vec::new();
300 let mut title = Text::styled(
301 format!("{} ", r.status.label()),
302 style(console, r.status.key(), STYLES),
303 );
304 title.append(
305 &one_line(&r.title()),
306 Some(style(console, "quality.check", STYLES).into()),
307 );
308 block(console, options, &title, 0, &mut lines);
309 let mut found = Vec::new();
310 if let Some(observed) = &r.observed {
311 found.push(format!("observed {}", one_line(observed)));
312 }
313 if let Some(expected) = &r.expected {
314 found.push(format!("expected {}", one_line(expected)));
315 }
316 if !found.is_empty() {
317 block(
318 console,
319 options,
320 &Text::new(found.join(" · ")),
321 2,
322 &mut lines,
323 );
324 }
325 if let Some(message) = &r.message {
326 let message = sanitize_terminal_and_bidi_controls(message.trim_end());
327 block(console, options, &Text::new(message), 2, &mut lines);
328 }
329 if let Some(failing) = r.failing_rows.as_ref().filter(|f| !f.rows.is_empty()) {
330 let mut table = Table::new();
331 for heading in &failing.columns {
332 table.add_column(one_line(heading));
333 }
334 let width = failing.columns.len().max(1);
335 if failing.columns.is_empty() {
336 table.add_column("");
337 }
338 for row in failing.rows.iter().take(self.show_rows) {
339 let mut cells: Vec<Text> = row
340 .iter()
341 .take(width)
342 .map(|c| Text::new(one_line(c)))
343 .collect();
344 cells.resize_with(width, || Text::new(""));
345 table.add_row_text(cells);
346 }
347 block(console, options, &table, 2, &mut lines);
348 let shown = failing.rows.len().min(self.show_rows) as u64;
349 let total = failing
350 .total
351 .unwrap_or(failing.rows.len() as u64)
352 .max(shown);
353 if total > shown {
354 let more = Text::styled(
355 format!("{shown} of {total} failing rows shown"),
356 style(console, "quality.dim", STYLES),
357 );
358 block(console, options, &more, 2, &mut lines);
359 }
360 }
361 lines
362 }
363}
364
365impl Renderable for QualityReport {
366 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
367 if options.max_width == 0 {
368 return Vec::new();
369 }
370 let options = options.reset_height();
371 let mut lines: Vec<Vec<Segment>> = Vec::new();
372 for wanted in [[Status::Fail, Status::Error], [Status::Warn, Status::Warn]] {
374 for r in self.results.iter().filter(|r| wanted.contains(&r.status)) {
375 lines.extend(self.details(console, &options, r));
376 lines.push(Vec::new());
377 }
378 }
379 if !self.results.is_empty() {
380 block(console, &options, &self.to_table(console), 0, &mut lines);
381 }
382 let t = self.totals();
383 let mut total = Text::new("");
384 let mut part = |n: usize, label: &str, status: Status, always: bool| {
385 if n == 0 && !always {
386 return;
387 }
388 if !total.is_empty() {
389 total.append(", ", None);
390 }
391 let st = if n > 0 {
392 Some(style(console, status.key(), STYLES).into())
393 } else {
394 None
395 };
396 total.append(&format!("{n} {label}"), st);
397 };
398 part(t.passed, "passed", Status::Pass, true);
399 part(t.warned, "warned", Status::Warn, true);
400 part(t.failed, "failed", Status::Fail, true);
401 part(t.errored, "errored", Status::Error, false);
402 block(console, &options, &total, 0, &mut lines);
403 join(lines)
404 }
405
406 fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
407 let widest = Segment::split_lines(&self.rich_render(console, options))
408 .iter()
409 .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
410 .max()
411 .unwrap_or(0);
412 rich::measure::Measurement::new(widest, widest)
413 }
414}
415
416fn one_line(text: &str) -> String {
418 sanitize_single_line(text)
419}
420
421pub fn not_null(rows: &Rows, column: &str) -> Result<CheckResult, DataError> {
425 let index = column_index(rows, column)?;
426 let failing: Vec<usize> = rows
427 .rows()
428 .iter()
429 .enumerate()
430 .filter(|(_, row)| row.get(index).is_none_or(Value::is_empty))
431 .map(|(i, _)| i)
432 .collect();
433 let count = failing.len() as u64;
434 let result = CheckResult::new("not_null", status(count))
435 .column(column)
436 .observed(format!(
437 "{count} {}",
438 if count == 1 { "null" } else { "nulls" }
439 ))
440 .expected("0");
441 Ok(with_rows(result, rows, &failing))
442}
443
444pub fn unique(rows: &Rows, column: &str) -> Result<CheckResult, DataError> {
448 let index = column_index(rows, column)?;
449 let mut seen = std::collections::HashSet::new();
450 let mut failing = Vec::new();
451 for (i, row) in rows.rows().iter().enumerate() {
452 let Some(value) = row.get(index).filter(|v| !v.is_empty()) else {
453 continue;
454 };
455 if !seen.insert(value.plain()) {
456 failing.push(i);
457 }
458 }
459 let count = failing.len() as u64;
460 let result = CheckResult::new("unique", status(count))
461 .column(column)
462 .observed(format!(
463 "{count} {}",
464 if count == 1 {
465 "duplicate"
466 } else {
467 "duplicates"
468 }
469 ))
470 .expected("0");
471 Ok(with_rows(result, rows, &failing))
472}
473
474fn column_index(rows: &Rows, column: &str) -> Result<usize, DataError> {
475 rows.column_index(column).ok_or_else(|| {
476 DataError::new(format!(
477 "no column {:?}; the columns are {}",
478 one_line(column),
479 rows.columns()
480 .iter()
481 .map(|c| one_line(c))
482 .collect::<Vec<_>>()
483 .join(", ")
484 ))
485 })
486}
487
488fn status(failures: u64) -> Status {
489 if failures == 0 {
490 Status::Pass
491 } else {
492 Status::Fail
493 }
494}
495
496fn with_rows(result: CheckResult, rows: &Rows, failing: &[usize]) -> CheckResult {
499 if failing.is_empty() {
500 return result;
501 }
502 let mut columns = vec!["row".to_string()];
503 columns.extend(rows.columns().iter().cloned());
504 let sample = failing
505 .iter()
506 .take(FAILING_ROWS)
507 .map(|&i| {
508 let mut cells = vec![(i + 1).to_string()];
509 cells.extend(rows.rows()[i].iter().map(Value::plain));
510 cells
511 })
512 .collect();
513 result.failing_rows(FailingRows {
514 columns,
515 rows: sample,
516 total: Some(failing.len() as u64),
517 })
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 fn people() -> Rows {
525 let mut rows = Rows::new(["id", "name"]);
526 for (id, name) in [(1, "ada"), (2, ""), (2, "alan"), (3, "grace"), (3, "")] {
527 rows.push([Value::Int(id), name.into()]);
528 }
529 rows
530 }
531
532 #[test]
533 fn built_in_checks_find_their_rows() {
534 let rows = people();
535 let nulls = not_null(&rows, "name").unwrap();
536 assert_eq!(nulls.status, Status::Fail);
537 assert_eq!(nulls.observed.as_deref(), Some("2 nulls"));
538 let failing = nulls.failing_rows.unwrap();
539 assert_eq!(failing.columns, ["row", "id", "name"]);
540 assert_eq!(failing.rows, [["2", "2", ""], ["5", "3", ""]]);
541 let dupes = unique(&rows, "id").unwrap();
542 assert_eq!(dupes.observed.as_deref(), Some("2 duplicates"));
543 assert_eq!(dupes.failing_rows.unwrap().rows[0][0], "3");
544 let ok = unique(&rows, "name").unwrap();
545 assert_eq!(ok.status, Status::Pass);
546 assert!(ok.failing_rows.is_none());
547 let error = not_null(&rows, "nope").unwrap_err();
548 assert_eq!(
549 error.to_string(),
550 "no column \"nope\"; the columns are id, name"
551 );
552 }
553
554 #[test]
555 fn the_report_shows_failures_first_then_a_table_and_a_summary() {
556 let rows = people();
557 let report = QualityReport::new([
558 CheckResult::new("row_count", Status::Pass)
559 .observed("5")
560 .expected(">= 1"),
561 not_null(&rows, "name").unwrap(),
562 CheckResult::new("freshness", Status::Error).message("no \x1b[31mclock\x1b[0m"),
563 CheckResult::new("range", Status::Warn)
564 .column("id")
565 .observed("max 3"),
566 ])
567 .show_rows(1);
568 let console = Console::builder().width(60).color_system(None).build();
569 let out = console.render_export(&report);
570 let expected = concat!(
571 "FAIL not_null › name\n",
572 " observed 2 nulls · expected 0\n",
573 " ┏━━━━━┳━━━━┳━━━━━━┓\n",
574 " ┃ row ┃ id ┃ name ┃\n",
575 " ┡━━━━━╇━━━━╇━━━━━━┩\n",
576 " │ 2 │ 2 │ │\n",
577 " └─────┴────┴──────┘\n",
578 " 1 of 2 failing rows shown\n",
579 "\n",
580 "ERROR freshness\n",
581 " no ␛[31mclock␛[0m\n",
582 "\n",
583 "WARN range › id\n",
584 " observed max 3\n",
585 "\n",
586 "┏━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┓\n",
587 "┃ status ┃ check ┃ column ┃ observed ┃ expected ┃\n",
588 "┡━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━┩\n",
589 "│ PASS │ row_count │ │ 5 │ >= 1 │\n",
590 "│ FAIL │ not_null │ name │ 2 nulls │ 0 │\n",
591 "│ ERROR │ freshness │ │ │ │\n",
592 "│ WARN │ range │ id │ max 3 │ │\n",
593 "└────────┴───────────┴────────┴──────────┴──────────┘\n",
594 "1 passed, 1 warned, 1 failed, 1 errored\n",
595 );
596 assert_eq!(out, expected);
597 assert_eq!(report.to_json()["totals"]["errored"], 1);
598 assert_eq!(report.to_json()["results"][1]["status"], "fail");
599 }
600
601 #[test]
602 fn status_colours_come_from_the_theme_keys() {
603 let report = QualityReport::new([CheckResult::new("x", Status::Fail)]);
604 let console = Console::builder()
605 .width(40)
606 .force_terminal(true)
607 .color_system(Some(rich::ColorSystem::Standard))
608 .build();
609 let out = console.render_export(&report);
610 assert!(out.contains("\x1b[1;31mFAIL"), "{out:?}");
611 }
612}