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