1use crate::analysis::data_quality::{
11 ColumnQualityProfile, DataQualityPlan, DataQualityResults, QualityPrecision, interval_label,
12};
13use crate::analysis::quality_report::{Outcome, Severity, coverage, describe, verdict};
14use crate::analysis::sampling::SampleMethod;
15use serde::{Deserialize, Serialize};
16use std::path::Path;
17
18pub const REPORT_FORMAT: &str = "datui-data-quality-report";
20
21pub const REPORT_VERSION: u32 = 1;
24
25const MAX_FILE_NAMES: usize = 100;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum ReportFormat {
31 #[default]
32 Json,
33 Markdown,
34}
35
36impl ReportFormat {
37 pub const ALL: [Self; 2] = [Self::Json, Self::Markdown];
38
39 pub fn label(self) -> &'static str {
40 match self {
41 Self::Json => "JSON",
42 Self::Markdown => "Markdown",
43 }
44 }
45
46 pub fn extension(self) -> &'static str {
47 match self {
48 Self::Json => "json",
49 Self::Markdown => "md",
50 }
51 }
52
53 pub fn holds(self) -> &'static str {
55 match self {
56 Self::Json => "Every measurement, versioned, for tools",
57 Self::Markdown => "The verdict, coverage, findings and setup, for people",
58 }
59 }
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
66pub struct SourceIdentity {
67 pub location: Option<String>,
69 pub remote: bool,
70 pub format: Option<String>,
71 pub files: Option<usize>,
73 pub file_names: Vec<String>,
75 pub bytes: Option<u64>,
77 pub modified: Option<String>,
79 pub view: Vec<String>,
82}
83
84impl SourceIdentity {
85 pub fn stat(&mut self) {
88 let Some(location) = self.location.as_deref().filter(|_| !self.remote) else {
89 return;
90 };
91 let Ok(meta) = std::fs::metadata(location) else {
92 return;
93 };
94 if !meta.is_file() {
95 return;
96 }
97 self.bytes = Some(meta.len());
98 self.modified = meta.modified().ok().map(|time| {
99 chrono::DateTime::<chrono::Utc>::from(time)
100 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
101 });
102 }
103
104 pub fn with_files(mut self, names: &[String]) -> Self {
106 if names.len() > 1 {
107 self.files = Some(names.len());
108 self.file_names = names.iter().take(MAX_FILE_NAMES).cloned().collect();
109 }
110 self
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct ReportFile {
117 pub format: String,
118 pub version: u32,
119 pub datui_version: String,
121 pub exported_at: String,
123 pub source: Option<SourceIdentity>,
125 pub setup: SetupJson,
126 pub run: RunJson,
127 pub verdict: String,
129 pub coverage: CoverageJson,
130 pub checks: Vec<CheckJson>,
131 pub findings: Vec<FindingJson>,
132 pub columns: Vec<ColumnJson>,
133 pub duplicates: Option<DuplicatesJson>,
134 pub segments: Vec<SegmentJson>,
135 pub intervals: Vec<IntervalJson>,
136 pub intent: Option<IntentJson>,
137 pub gaps: Option<GapsJson>,
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct SetupJson {
144 pub scope: String,
146 pub values: String,
148 pub sample: SampleJson,
149 pub grain: String,
151 pub comparison: String,
153 pub baseline_segment: Option<String>,
154 pub time_formats: Vec<TimeFormatJson>,
155 pub time_roles: Vec<TimeRoleJson>,
156 pub intervals: Vec<String>,
158 pub window_by: String,
160 pub latency_threshold_seconds: Option<i64>,
161 pub intent: DeclaredJson,
162 pub expected: Option<ExpectedJson>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct ExpectedJson {
168 pub weekdays: bool,
170 pub from: Option<String>,
173 pub before: Option<String>,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct GapsJson {
179 pub status: String,
182 pub column: String,
184 pub every: String,
185 pub cadence: String,
187 pub windows_in_range: Option<usize>,
189 pub from: Option<String>,
191 pub before: Option<String>,
192 pub expected: Option<usize>,
194 pub weekend: Option<usize>,
195 pub with_rows: Option<usize>,
196 pub empty: Option<usize>,
197 pub not_sampled: Option<usize>,
198 pub out_of_scope: Option<usize>,
199 pub counted: Option<bool>,
201 pub runs: Vec<GapRunJson>,
203 pub more_runs: usize,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct GapRunJson {
209 pub kind: String,
211 pub first: String,
213 pub last: String,
214 pub span: String,
216 pub windows: usize,
217 pub rows: Option<usize>,
219}
220
221fn utc(time: chrono::NaiveDateTime) -> String {
222 time.format("%Y-%m-%dT%H:%M:%SZ").to_string()
223}
224
225fn gap_kind(kind: crate::analysis::quality_trends::GapKind) -> &'static str {
227 use crate::analysis::quality_trends::GapKind;
228 match kind {
229 GapKind::Empty => "empty",
230 GapKind::Unsampled => "not_sampled",
231 GapKind::OutOfScope => "out_of_scope",
232 }
233}
234
235fn gaps_json(plan: &DataQualityPlan, results: &DataQualityResults) -> Option<GapsJson> {
236 use crate::analysis::quality_trends::Gaps;
237 let gaps = crate::analysis::quality_trends::expected_gaps(plan, results)?;
238 let crate::analysis::data_quality::QualityGrain::TimeWindows { column, every } = &plan.grain
239 else {
240 return None;
241 };
242 let mut json = GapsJson {
243 status: String::new(),
244 column: column.clone(),
245 every: every.clone(),
246 cadence: plan
247 .expected_windows()
248 .map(|expected| expected.cadence_label(every))
249 .unwrap_or_default(),
250 windows_in_range: None,
251 from: None,
252 before: None,
253 expected: None,
254 weekend: None,
255 with_rows: None,
256 empty: None,
257 not_sampled: None,
258 out_of_scope: None,
259 counted: None,
260 runs: Vec::new(),
261 more_runs: 0,
262 };
263 json.status = match gaps {
264 Gaps::NoValues => "no_values",
265 Gaps::NoWindows => "no_windows",
266 Gaps::TooMany { windows } => {
267 json.windows_in_range = Some(windows);
268 "too_many"
269 }
270 Gaps::Checked(check) => {
271 json.from = Some(utc(check.from));
272 json.before = Some(utc(check.before));
273 json.expected = Some(check.expected);
274 json.weekend = Some(check.weekend);
275 json.with_rows = Some(check.with_rows);
276 json.empty = Some(check.empty);
277 json.not_sampled = Some(check.unsampled);
278 json.out_of_scope = Some(check.out_of_scope);
279 json.counted = Some(check.counted);
280 json.more_runs = check.more_runs;
281 json.runs = check
282 .runs
283 .iter()
284 .map(|run| GapRunJson {
285 kind: gap_kind(run.kind).to_string(),
286 first: utc(run.first),
287 last: utc(run.last),
288 span: crate::analysis::quality_trends::calendar_span(
289 run.first, run.last, every,
290 ),
291 windows: run.windows,
292 rows: run.rows,
293 })
294 .collect();
295 "checked"
296 }
297 }
298 .to_string();
299 Some(json)
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303pub struct SampleJson {
304 pub method: String,
306 pub rows: usize,
308 pub seed: u64,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313pub struct TimeFormatJson {
314 pub column: String,
315 pub kind: String,
317 pub format: String,
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322pub struct TimeRoleJson {
323 pub role: String,
324 pub column: String,
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329pub struct DeclaredJson {
330 pub key: Vec<String>,
331 pub columns: Vec<ColumnIntentJson>,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335pub struct ColumnIntentJson {
336 pub column: String,
337 pub required: bool,
338 pub allowed: Vec<String>,
339 pub min: Option<String>,
340 pub max: Option<String>,
341 pub read_as: Option<String>,
343}
344
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
347pub struct RunJson {
348 pub precision: String,
350 pub total_rows: Option<usize>,
352 pub evaluated_rows: usize,
354 pub per_value: Option<usize>,
356 pub source_files: Option<usize>,
357 pub footers_read: Option<usize>,
358 pub reads: Option<ReadsJson>,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363pub struct ReadsJson {
364 pub source_reads: usize,
366 pub counted: usize,
368 pub rows_traversed: usize,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub local_copy: Option<LocalCopyJson>,
373}
374
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
376pub struct LocalCopyJson {
377 pub bytes: u64,
378 pub objects: usize,
379 pub fetched_by_this_run: bool,
381}
382
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384pub struct CoverageJson {
385 pub exact: usize,
386 pub sampled: usize,
387 pub metadata: usize,
388 pub skipped: usize,
389 pub unavailable: Vec<UnavailableJson>,
390 pub rows: Vec<String>,
391 pub limits: Vec<String>,
392}
393
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct UnavailableJson {
396 pub reason: String,
397 pub checks: Vec<String>,
398}
399
400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
401pub struct CheckJson {
402 pub name: String,
403 pub looks_for: String,
404 pub applies_to: String,
405 pub outcome: String,
407 pub detail: Option<String>,
409 pub basis: String,
411}
412
413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
414pub struct FindingJson {
415 pub severity: String,
417 pub title: String,
418 pub columns: Vec<String>,
419 pub affected_rows: usize,
420 pub evaluated_rows: usize,
421 pub summary: String,
422 pub headline: String,
424 pub evidence: Vec<String>,
425}
426
427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428pub struct ColumnJson {
429 pub name: String,
430 pub dtype: String,
431 pub evaluated_rows: usize,
432 pub null_count: usize,
433 pub distinct_count: Option<usize>,
434 pub empty_count: Option<usize>,
435 pub whitespace_count: Option<usize>,
436 pub nan_count: Option<usize>,
437 pub positive_infinity_count: Option<usize>,
438 pub negative_infinity_count: Option<usize>,
439 pub min: Option<String>,
440 pub max: Option<String>,
441 pub dominant_value: Option<String>,
442 pub dominant_count: Option<usize>,
443 pub min_length: Option<usize>,
444 pub max_length: Option<usize>,
445 pub integer_parse_count: Option<usize>,
446 pub decimal_parse_count: Option<usize>,
447 pub date_parse_count: Option<usize>,
448 pub datetime_parse_count: Option<usize>,
449}
450
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
452pub struct DuplicatesJson {
453 pub groups: usize,
454 pub extra_rows: usize,
455 pub rows_involved: usize,
456 pub evaluated_rows: usize,
457}
458
459#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
460pub struct SegmentJson {
461 pub label: String,
462 pub total_rows: Option<usize>,
464 pub evaluated_rows: usize,
465 pub null_cells: usize,
466 pub null_rate: f64,
467 pub compared_with: Option<String>,
468 pub largest_change: Option<String>,
469}
470
471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
472pub struct IntervalJson {
473 pub interval: String,
474 pub segment: String,
475 pub start_column: String,
476 pub end_column: String,
477 pub rows: usize,
478 pub both_ends: usize,
480 pub missing_start: usize,
481 pub missing_end: usize,
482 pub unparsed_start: usize,
483 pub unparsed_end: usize,
484 pub negative: usize,
485 pub zero: usize,
486 pub p50_seconds: Option<i64>,
487 pub p90_seconds: Option<i64>,
488 pub p95_seconds: Option<i64>,
489 pub p99_seconds: Option<i64>,
490 pub max_seconds: Option<i64>,
491 pub threshold_seconds: Option<i64>,
493 pub over_threshold: Option<usize>,
494}
495
496#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
498pub struct IntentJson {
499 pub measured: bool,
501 pub precision: String,
502 pub evaluated_rows: usize,
503 pub key: Option<KeyJson>,
504 pub columns: Vec<ColumnCheckJson>,
505 pub absent: Vec<String>,
507}
508
509#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
510pub struct KeyJson {
511 pub columns: Vec<String>,
512 pub missing: usize,
514 pub groups: usize,
516 pub extra_rows: usize,
517 pub rows_involved: usize,
518}
519
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
521pub struct ColumnCheckJson {
522 pub column: String,
523 pub dtype: String,
524 pub values: usize,
526 pub missing: Option<usize>,
527 pub unparsed: Option<usize>,
528 pub outside: Option<usize>,
529 pub compared: Option<usize>,
531 pub below: Option<usize>,
532 pub above: Option<usize>,
533 pub lowest: Option<String>,
534 pub highest: Option<String>,
535}
536
537fn precision_label(precision: QualityPrecision) -> String {
538 precision.label().to_string()
539}
540
541fn column_json(column: &ColumnQualityProfile) -> ColumnJson {
542 ColumnJson {
543 name: column.name.clone(),
544 dtype: column.dtype.to_string(),
545 evaluated_rows: column.evaluated_rows,
546 null_count: column.null_count,
547 distinct_count: column.distinct_count,
548 empty_count: column.empty_count,
549 whitespace_count: column.whitespace_count,
550 nan_count: column.nan_count,
551 positive_infinity_count: column.positive_infinity_count,
552 negative_infinity_count: column.negative_infinity_count,
553 min: column.min.clone(),
554 max: column.max.clone(),
555 dominant_value: column.dominant_value.clone(),
556 dominant_count: column.dominant_count,
557 min_length: column.min_length,
558 max_length: column.max_length,
559 integer_parse_count: column.integer_parse_count,
560 decimal_parse_count: column.decimal_parse_count,
561 date_parse_count: column.date_parse_count,
562 datetime_parse_count: column.datetime_parse_count,
563 }
564}
565
566fn setup_json(plan: &DataQualityPlan) -> SetupJson {
567 SetupJson {
568 scope: plan.scope.label(),
569 values: plan.compute.label().to_string(),
570 sample: SampleJson {
571 method: plan.method.label(),
572 rows: plan.dataset_rows,
573 seed: plan.sample_seed,
574 },
575 grain: plan.grain.label(),
576 comparison: plan.comparison.label().to_string(),
577 baseline_segment: plan.baseline_segment.clone(),
578 time_formats: plan
579 .time_formats
580 .iter()
581 .map(|format| TimeFormatJson {
582 column: format.column.clone(),
583 kind: format.kind.label().to_string(),
584 format: format.format.clone(),
585 })
586 .collect(),
587 time_roles: plan
588 .temporal_roles
589 .iter()
590 .map(|assignment| TimeRoleJson {
591 role: assignment.role.label().to_string(),
592 column: assignment.column.clone(),
593 })
594 .collect(),
595 intervals: plan
596 .interval_pairs()
597 .into_iter()
598 .map(interval_label)
599 .collect(),
600 window_by: plan.interval_clock.label().to_string(),
601 latency_threshold_seconds: plan.latency_threshold_seconds,
602 expected: plan.expected_windows().map(|expected| ExpectedJson {
603 weekdays: expected.weekdays,
604 from: expected.from.clone(),
605 before: expected.before.clone(),
606 }),
607 intent: DeclaredJson {
608 key: plan.intent.key.clone(),
609 columns: plan
610 .intent
611 .columns
612 .iter()
613 .map(|intent| ColumnIntentJson {
614 column: intent.column.clone(),
615 required: intent.required,
616 allowed: intent.allowed.clone(),
617 min: intent.min.clone(),
618 max: intent.max.clone(),
619 read_as: intent.number.map(|number| number.label().to_string()),
620 })
621 .collect(),
622 },
623 }
624}
625
626fn outcome_json(outcome: &Outcome) -> (String, Option<String>) {
627 match outcome {
628 Outcome::Passed => ("passed".to_string(), None),
629 Outcome::Found { detail, .. } => ("found".to_string(), Some(detail.clone())),
630 Outcome::Skipped(reason) => ("skipped".to_string(), Some(reason.to_string())),
631 Outcome::Unavailable(reason) => ("unavailable".to_string(), Some(reason.to_string())),
632 }
633}
634
635pub fn report_file(
638 results: &DataQualityResults,
639 plan: &DataQualityPlan,
640 exported_at: &str,
641) -> ReportFile {
642 let report = results.report();
643 let all_checks = results.checks();
644 let covered = coverage(results, all_checks, plan);
645 let severity = |severity: Severity| match severity {
646 Severity::Problem => "problem",
647 Severity::Note => "note",
648 Severity::Clean => "clean",
649 };
650 ReportFile {
651 format: REPORT_FORMAT.to_string(),
652 version: REPORT_VERSION,
653 datui_version: env!("CARGO_PKG_VERSION").to_string(),
654 exported_at: exported_at.to_string(),
655 source: results.source.as_deref().cloned(),
656 setup: setup_json(plan),
657 run: RunJson {
658 precision: precision_label(results.precision),
659 total_rows: results.total_rows,
660 evaluated_rows: results.evaluated_rows,
661 per_value: results.per_value,
662 source_files: results.source_files,
663 footers_read: results.footers_read,
664 reads: results.reads.map(|reads| ReadsJson {
665 source_reads: reads.reads,
666 counted: reads.counted,
667 rows_traversed: reads.rows,
668 local_copy: reads.copy.map(|copy| LocalCopyJson {
669 bytes: copy.bytes,
670 objects: copy.objects,
671 fetched_by_this_run: copy.fetched,
672 }),
673 }),
674 },
675 verdict: verdict(report),
676 coverage: CoverageJson {
677 exact: covered.exact,
678 sampled: covered.sampled,
679 metadata: covered.metadata,
680 skipped: covered.skipped,
681 unavailable: covered
682 .unavailable
683 .iter()
684 .map(|(reason, names)| UnavailableJson {
685 reason: reason.to_string(),
686 checks: names.iter().map(|name| name.to_string()).collect(),
687 })
688 .collect(),
689 rows: covered.rows.clone(),
690 limits: covered.limits.clone(),
691 },
692 checks: all_checks
693 .iter()
694 .map(|check| {
695 let (outcome, detail) = outcome_json(&check.outcome);
696 CheckJson {
697 name: check.name.to_string(),
698 looks_for: check.looks_for.to_string(),
699 applies_to: check.applies_to.clone(),
700 outcome,
701 detail,
702 basis: precision_label(check.basis),
703 }
704 })
705 .collect(),
706 findings: report
707 .findings
708 .iter()
709 .map(|finding| {
710 let (headline, evidence) = describe(finding, results);
711 FindingJson {
712 severity: severity(finding.severity).to_string(),
713 title: finding.title.to_string(),
714 columns: finding.columns.clone(),
715 affected_rows: finding.affected_rows,
716 evaluated_rows: finding.evaluated_rows,
717 summary: finding.summary.clone(),
718 headline,
719 evidence,
720 }
721 })
722 .collect(),
723 columns: results.columns.iter().map(column_json).collect(),
724 duplicates: results.identity.as_ref().map(|identity| DuplicatesJson {
725 groups: identity.duplicate_groups,
726 extra_rows: identity.extra_rows,
727 rows_involved: identity.rows_involved,
728 evaluated_rows: identity.evaluated_rows,
729 }),
730 segments: results
731 .segments
732 .iter()
733 .map(|segment| SegmentJson {
734 label: segment.label.clone(),
735 total_rows: segment.total_rows,
736 evaluated_rows: segment.evaluated_rows,
737 null_cells: segment.null_cells,
738 null_rate: segment.null_rate,
739 compared_with: segment.compared_with.clone(),
740 largest_change: segment.largest_change.clone(),
741 })
742 .collect(),
743 intervals: results
744 .temporal
745 .iter()
746 .map(|interval| IntervalJson {
747 interval: interval.label(),
748 segment: interval.segment.clone(),
749 start_column: interval.start_column.clone(),
750 end_column: interval.end_column.clone(),
751 rows: interval.evaluated_rows,
752 both_ends: interval.paired_rows,
753 missing_start: interval.missing_start,
754 missing_end: interval.missing_end,
755 unparsed_start: interval.unparsed_start,
756 unparsed_end: interval.unparsed_end,
757 negative: interval.negative_count,
758 zero: interval.zero_count,
759 p50_seconds: interval.p50_seconds,
760 p90_seconds: interval.p90_seconds,
761 p95_seconds: interval.p95_seconds,
762 p99_seconds: interval.p99_seconds,
763 max_seconds: interval.max_seconds,
764 threshold_seconds: interval.threshold_seconds,
765 over_threshold: interval.above_threshold_count,
766 })
767 .collect(),
768 gaps: gaps_json(plan, results),
769 intent: results.intent.as_ref().map(|intent| IntentJson {
770 measured: intent.measured,
771 precision: precision_label(intent.precision),
772 evaluated_rows: intent.evaluated_rows,
773 key: intent.key.as_ref().map(|key| KeyJson {
774 columns: key.columns.clone(),
775 missing: key.missing,
776 groups: key.groups,
777 extra_rows: key.extra_rows,
778 rows_involved: key.rows_involved,
779 }),
780 columns: intent
781 .columns
782 .iter()
783 .map(|check| ColumnCheckJson {
784 column: check.intent.column.clone(),
785 dtype: check.dtype.to_string(),
786 values: check.values,
787 missing: check.missing,
788 unparsed: check.unparsed,
789 outside: check.outside,
790 compared: check.compared,
791 below: check.below,
792 above: check.above,
793 lowest: check.lowest.clone(),
794 highest: check.highest.clone(),
795 })
796 .collect(),
797 absent: intent.absent.clone(),
798 }),
799 }
800}
801
802pub fn to_json(file: &ReportFile) -> color_eyre::Result<String> {
804 Ok(serde_json::to_string_pretty(file)?)
805}
806
807fn cell(text: &str) -> String {
809 text.replace('|', "\\|").replace('\n', " ")
810}
811
812pub fn to_markdown(
817 file: &ReportFile,
818 results: &DataQualityResults,
819 plan: &DataQualityPlan,
820) -> String {
821 let mut out = String::new();
822 let mut line = |text: String| {
823 out.push_str(&text);
824 out.push('\n');
825 };
826 line("# Data quality report".to_string());
827 line(String::new());
828 if let Some(source) = &file.source {
829 if let Some(location) = &source.location {
830 let mut facts = Vec::new();
831 if let Some(format) = &source.format {
832 facts.push(format.clone());
833 }
834 if let Some(files) = source.files {
835 facts.push(format!("{} files", crate::numfmt::group_chrome(files)));
836 }
837 if let Some(bytes) = source.bytes {
838 facts.push(format!(
839 "{} bytes",
840 crate::numfmt::group_chrome(bytes as usize)
841 ));
842 }
843 if let Some(modified) = &source.modified {
844 facts.push(format!("modified {modified}"));
845 }
846 let facts = if facts.is_empty() {
847 String::new()
848 } else {
849 format!(" ({})", facts.join(", "))
850 };
851 line(format!("- Source: `{location}`{facts}"));
852 }
853 if !source.view.is_empty() {
854 line(format!("- View: {}", source.view.join("; ")));
855 }
856 }
857 let count = crate::numfmt::group_chrome;
858 let evaluated = results.evaluated_rows;
859 let rows = match (results.precision, results.total_rows) {
860 (QualityPrecision::Metadata, _) => "file metadata only, no values read".to_string(),
861 (QualityPrecision::Exact, _) => format!("all {} rows, exact", count(evaluated)),
862 (QualityPrecision::Sampled, Some(total)) => {
863 format!("{} of {} rows, sampled", count(evaluated), count(total))
864 }
865 (QualityPrecision::Sampled, None) => {
866 format!("{} rows, sampled", count(evaluated))
867 }
868 };
869 line(format!("- Measured: {rows}"));
870 line(format!(
871 "- Written by datui {} at {}, from the report on screen; no data read",
872 file.datui_version, file.exported_at
873 ));
874 line(String::new());
875 line(format!("**{}**", file.verdict.replace(" ", ", ")));
877 line(String::new());
878
879 line("## Coverage".to_string());
880 line(String::new());
881 let coverage = &file.coverage;
882 let checks = [
883 (coverage.exact, "exact"),
884 (coverage.sampled, "sampled"),
885 (coverage.metadata, "metadata"),
886 (coverage.skipped, "skipped"),
887 (
888 coverage
889 .unavailable
890 .iter()
891 .map(|unavailable| unavailable.checks.len())
892 .sum(),
893 "unavailable",
894 ),
895 ]
896 .into_iter()
897 .filter(|(count, _)| *count > 0)
898 .map(|(count, label)| format!("{count} {label}"))
899 .collect::<Vec<_>>();
900 line(format!("- Checks: {}", checks.join(", ")));
901 if !coverage.rows.is_empty() {
902 line(format!("- Rows: {}", coverage.rows.join(", ")));
903 }
904 let limits = coverage
905 .unavailable
906 .iter()
907 .map(|unavailable| format!("{}: {}", unavailable.checks.join(", "), unavailable.reason))
908 .chain(coverage.limits.iter().cloned())
909 .collect::<Vec<_>>();
910 if !limits.is_empty() {
911 line(format!("- Limits: {}", limits.join("; ")));
912 }
913 line(String::new());
914
915 for (severity, heading) in [("problem", "Problems"), ("note", "Notes")] {
916 let findings = file
917 .findings
918 .iter()
919 .filter(|finding| finding.severity == severity)
920 .collect::<Vec<_>>();
921 if findings.is_empty() {
922 continue;
923 }
924 line(format!("## {heading}"));
925 line(String::new());
926 for finding in findings {
927 line(format!(
928 "- **{}** ({}): {}",
929 finding.title,
930 finding.columns.join(", "),
931 finding.headline
932 ));
933 for evidence in &finding.evidence {
934 line(format!(" - {}", evidence.trim()));
935 }
936 }
937 line(String::new());
938 }
939 if let Some(clean) = file
940 .findings
941 .iter()
942 .find(|finding| finding.severity == "clean")
943 {
944 line("## Clean".to_string());
945 line(String::new());
946 line(clean.columns.join(", "));
947 line(String::new());
948 }
949
950 line("## Checks".to_string());
951 line(String::new());
952 line("| Check | Covers | Read | Result |".to_string());
953 line("|---|---|---|---|".to_string());
954 for check in &file.checks {
955 let result = match &check.detail {
956 Some(detail) => format!("{}: {detail}", check.outcome),
957 None => check.outcome.clone(),
958 };
959 line(format!(
960 "| {} | {} | {} | {} |",
961 cell(&check.name),
962 cell(&check.applies_to),
963 cell(&check.basis),
964 cell(&result)
965 ));
966 }
967 line(String::new());
968
969 if !file.intervals.is_empty() {
970 line("## Intervals".to_string());
971 line(String::new());
972 line("| Interval | Segment | Both ends | p50 s | p95 s | Negative | Over |".to_string());
973 line("|---|---|---|---|---|---|---|".to_string());
974 let seconds = |value: Option<i64>| value.map_or("-".to_string(), |value| value.to_string());
975 for interval in &file.intervals {
976 line(format!(
977 "| {} | {} | {} | {} | {} | {} | {} |",
978 cell(&interval.interval),
979 cell(&interval.segment),
980 interval.both_ends,
981 seconds(interval.p50_seconds),
982 seconds(interval.p95_seconds),
983 interval.negative,
984 interval
985 .over_threshold
986 .map_or("-".to_string(), |over| over.to_string())
987 ));
988 }
989 line(String::new());
990 }
991
992 if let Some(gaps) = crate::analysis::quality_trends::expected_gaps(plan, results)
993 && let crate::analysis::data_quality::QualityGrain::TimeWindows { column, every } =
994 &plan.grain
995 {
996 use crate::analysis::quality_trends::Gaps;
997 line("## Gaps".to_string());
998 line(String::new());
999 let cadence = plan
1000 .expected_windows()
1001 .map(|expected| expected.cadence_label(every))
1002 .unwrap_or_default();
1003 let expected = format!("Expected {cadence} by {column}");
1004 match gaps {
1005 Gaps::NoValues => line(format!("{expected}: file metadata only counts no windows")),
1006 Gaps::NoWindows => line(format!("{expected}: no window found and no range stated")),
1007 Gaps::TooMany { windows } => line(format!(
1008 "{expected}: {} windows in range, more than are checked",
1009 count(windows)
1010 )),
1011 Gaps::Checked(check) => {
1012 let mut facts = vec![
1013 format!("{} with rows", count(check.with_rows)),
1014 format!("{} empty", count(check.empty)),
1015 format!("{} not sampled", count(check.unsampled)),
1016 format!("{} out of scope", count(check.out_of_scope)),
1017 ];
1018 if check.weekend > 0 {
1019 facts.push(format!(
1020 "{} weekend windows not expected",
1021 count(check.weekend)
1022 ));
1023 }
1024 line(format!(
1025 "{expected}, {} to before {}: {} windows; {}",
1026 utc(check.from),
1027 utc(check.before),
1028 count(check.expected),
1029 facts.join(", ")
1030 ));
1031 if !check.runs.is_empty() {
1032 line(String::new());
1033 line("| Windows | Gap | Count | Rows |".to_string());
1034 line("|---|---|---|---|".to_string());
1035 for run in &check.runs {
1036 line(format!(
1037 "| {} | {} | {} | {} |",
1038 cell(&crate::analysis::quality_trends::calendar_span(
1039 run.first, run.last, every
1040 )),
1041 gap_kind(run.kind).replace('_', " "),
1042 run.windows,
1043 run.rows.map_or("-".to_string(), |rows| rows.to_string())
1044 ));
1045 }
1046 if check.more_runs > 0 {
1047 line(String::new());
1048 line(format!("{} more runs not listed", check.more_runs));
1049 }
1050 }
1051 }
1052 }
1053 line(String::new());
1054 }
1055
1056 line("## Setup".to_string());
1057 line(String::new());
1058 line("| Setting | Value |".to_string());
1059 line("|---|---|".to_string());
1060 let setup = &file.setup;
1061 let none = || "none".to_string();
1062 let joined = |items: Vec<String>| {
1063 if items.is_empty() {
1064 none()
1065 } else {
1066 items.join(", ")
1067 }
1068 };
1069 let mut rows = vec![
1070 ("Scope", setup.scope.clone()),
1071 ("Values", setup.values.clone()),
1072 (
1073 "Sample",
1074 match &plan.method {
1076 SampleMethod::EveryRow => plan.method.label(),
1077 SampleMethod::FirstRows => {
1078 format!("{}, {} rows", plan.method.label(), count(plan.dataset_rows))
1079 }
1080 SampleMethod::PerPartition { .. } => format!(
1081 "{}, {} rows per value, seed {}",
1082 plan.method.label(),
1083 count(plan.dataset_rows),
1084 plan.sample_seed
1085 ),
1086 SampleMethod::Spread => format!(
1087 "{}, {} rows, seed {}",
1088 plan.method.label(),
1089 count(plan.dataset_rows),
1090 plan.sample_seed
1091 ),
1092 },
1093 ),
1094 (
1095 "Text as time",
1096 joined(
1097 setup
1098 .time_formats
1099 .iter()
1100 .map(|format| {
1101 format!("{} as {} `{}`", format.column, format.kind, format.format)
1102 })
1103 .collect(),
1104 ),
1105 ),
1106 (
1107 "Time roles",
1108 joined(
1109 setup
1110 .time_roles
1111 .iter()
1112 .map(|role| format!("{} = {}", role.role, role.column))
1113 .collect(),
1114 ),
1115 ),
1116 ("Intervals", joined(setup.intervals.clone())),
1117 ("Grain", setup.grain.clone()),
1118 (
1119 "Compare",
1120 match &setup.baseline_segment {
1121 Some(segment) => format!("{} {segment}", setup.comparison),
1122 None => setup.comparison.clone(),
1123 },
1124 ),
1125 (
1126 "Latency over",
1127 setup
1128 .latency_threshold_seconds
1129 .map_or_else(none, |seconds| format!("{seconds} s")),
1130 ),
1131 ("Window by", setup.window_by.clone()),
1132 (
1133 "Expected",
1134 setup.expected.as_ref().map_or_else(none, |expected| {
1135 let windows = crate::analysis::data_quality::ExpectedWindows {
1136 weekdays: expected.weekdays,
1137 from: expected.from.clone(),
1138 before: expected.before.clone(),
1139 };
1140 let every = match &plan.grain {
1141 crate::analysis::data_quality::QualityGrain::TimeWindows { every, .. } => {
1142 every.as_str()
1143 }
1144 _ => "",
1145 };
1146 format!(
1147 "{}, {}",
1148 windows.cadence_label(every),
1149 windows.range_label()
1150 )
1151 }),
1152 ),
1153 (
1154 "Key",
1155 if setup.intent.key.is_empty() {
1156 none()
1157 } else {
1158 setup.intent.key.join(", ")
1159 },
1160 ),
1161 ];
1162 for intent in &setup.intent.columns {
1163 let mut rules = Vec::new();
1164 if intent.required {
1165 rules.push("required".to_string());
1166 }
1167 if let Some(read_as) = &intent.read_as {
1168 rules.push(format!("read as {read_as}"));
1169 }
1170 if !intent.allowed.is_empty() {
1171 rules.push(format!(
1172 "one of {}",
1173 crate::analysis::quality_intent::format_allowed(&intent.allowed)
1174 ));
1175 }
1176 match (&intent.min, &intent.max) {
1177 (Some(min), Some(max)) => rules.push(format!("{min} to {max}")),
1178 (Some(min), None) => rules.push(format!("at least {min}")),
1179 (None, Some(max)) => rules.push(format!("at most {max}")),
1180 (None, None) => {}
1181 }
1182 rows.push(("Intent", format!("{}: {}", intent.column, rules.join("; "))));
1183 }
1184 for (setting, value) in rows {
1185 line(format!("| {setting} | {} |", cell(&value)));
1186 }
1187 out
1188}
1189
1190pub fn render(
1192 results: &DataQualityResults,
1193 plan: &DataQualityPlan,
1194 format: ReportFormat,
1195 exported_at: &str,
1196) -> color_eyre::Result<String> {
1197 let file = report_file(results, plan, exported_at);
1198 match format {
1199 ReportFormat::Json => to_json(&file),
1200 ReportFormat::Markdown => Ok(to_markdown(&file, results, plan)),
1201 }
1202}
1203
1204pub fn write(
1208 path: &Path,
1209 results: &DataQualityResults,
1210 plan: &DataQualityPlan,
1211 format: ReportFormat,
1212 overwrite: crate::export::output_file::Overwrite,
1213) -> color_eyre::Result<()> {
1214 use std::io::Write;
1215 let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
1216 let text = render(results, plan, format, &now)?;
1217 let mut out = crate::export::output_file::OutputFile::create(path, overwrite)?;
1218 out.file().write_all(text.as_bytes())?;
1219 out.commit()?;
1220 Ok(())
1221}
1222
1223pub struct ExportForm {
1225 pub path: crate::widgets::text_input::TextInput,
1226 pub format: ReportFormat,
1227 pub on_format: bool,
1229 pub error: Option<String>,
1231}
1232
1233impl ExportForm {
1234 pub fn new(stem: &str, theme: &crate::config::Theme) -> Self {
1236 let mut path = crate::widgets::text_input::TextInput::new().with_theme(theme);
1237 path.suggest(format!("{stem}-quality.{}", ReportFormat::Json.extension()));
1238 path.set_focused(true);
1239 Self {
1240 path,
1241 format: ReportFormat::Json,
1242 on_format: false,
1243 error: None,
1244 }
1245 }
1246
1247 pub fn cycle_format(&mut self) {
1250 let next = match self.format {
1251 ReportFormat::Json => ReportFormat::Markdown,
1252 ReportFormat::Markdown => ReportFormat::Json,
1253 };
1254 let old = format!(".{}", self.format.extension());
1255 let value = self.path.value().to_string();
1256 if let Some(stem) = value.strip_suffix(&old) {
1257 let renamed = format!("{stem}.{}", next.extension());
1258 if self.path.is_suggested() {
1259 self.path.suggest(renamed);
1260 } else {
1261 self.path.set_value(renamed);
1262 }
1263 }
1264 self.format = next;
1265 self.error = None;
1266 }
1267
1268 pub fn toggle_focus(&mut self) {
1269 self.on_format = !self.on_format;
1270 self.path.set_focused(!self.on_format);
1271 }
1272
1273 pub fn target(&self) -> Result<(std::path::PathBuf, ReportFormat), String> {
1277 let typed = self.path.value().trim();
1278 if typed.is_empty() {
1279 return Err("Type a path to write to".to_string());
1280 }
1281 let mut path = crate::home::expand_user_path(typed);
1282 let typed_format = path
1283 .extension()
1284 .and_then(|extension| extension.to_str())
1285 .and_then(|extension| {
1286 ReportFormat::ALL
1287 .into_iter()
1288 .find(|format| extension.eq_ignore_ascii_case(format.extension()))
1289 });
1290 if path.extension().is_none() {
1291 path.set_extension(self.format.extension());
1292 }
1293 Ok((path, typed_format.unwrap_or(self.format)))
1294 }
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299 use super::*;
1300 use crate::analysis::data_quality::fixtures::measure;
1301 use crate::analysis::data_quality::{ObservationKind, QualityCompute};
1302 use crate::analysis::quality_intent::{ColumnIntent, DeclaredIntent};
1303 use polars::prelude::*;
1304
1305 fn measured() -> (DataQualityResults, DataQualityPlan) {
1306 let df = df!(
1307 "id" => &[1i64, 2, 2, 4],
1308 "status" => &[Some("open"), Some("void"), None, Some("open")],
1309 )
1310 .unwrap();
1311 let plan = DataQualityPlan {
1312 compute: QualityCompute::Full,
1313 intent: DeclaredIntent {
1314 key: vec!["id".to_string()],
1315 columns: vec![ColumnIntent {
1316 required: true,
1317 allowed: vec!["open".to_string(), "closed".to_string()],
1318 ..ColumnIntent::new("status")
1319 }],
1320 },
1321 ..DataQualityPlan::default()
1322 };
1323 let mut results = measure(&df.lazy(), Some(4), &plan);
1324 results.source = Some(Box::new(SourceIdentity {
1325 location: Some("orders.parquet".to_string()),
1326 format: Some("Parquet".to_string()),
1327 view: vec!["filter: status = open".to_string()],
1328 ..SourceIdentity::default()
1329 }));
1330 (results, plan)
1331 }
1332
1333 #[test]
1336 fn json_round_trips_through_its_schema() {
1337 let (results, plan) = measured();
1338 let text = render(&results, &plan, ReportFormat::Json, "2026-09-30T00:00:00Z").unwrap();
1339 let file: ReportFile = serde_json::from_str(&text).unwrap();
1340 assert_eq!(file, report_file(&results, &plan, "2026-09-30T00:00:00Z"));
1341 assert_eq!(file.format, REPORT_FORMAT);
1342 assert_eq!(file.version, REPORT_VERSION);
1343 assert_eq!(file.setup.sample.seed, plan.sample_seed);
1344 assert_eq!(file.setup.values, "full");
1345 assert_eq!(file.setup.intent.key, vec!["id"]);
1346 assert_eq!(file.run.precision, "exact");
1347 assert_eq!(file.run.evaluated_rows, 4);
1348 assert_eq!(
1349 file.source.as_ref().unwrap().location.as_deref(),
1350 Some("orders.parquet")
1351 );
1352 let key = file.intent.as_ref().unwrap().key.clone().unwrap();
1353 assert_eq!((key.groups, key.rows_involved), (1, 2));
1354 let report = results.report();
1356 let (at, repeated) = report
1357 .findings
1358 .iter()
1359 .enumerate()
1360 .find(|(_, finding)| finding.kind == Some(ObservationKind::KeyRepeated))
1361 .expect("the report finds the repeated key");
1362 let written = &file.findings[at];
1363 assert_eq!(written.title, repeated.title);
1364 assert_eq!(written.columns, ["id"]);
1365 assert_eq!(written.affected_rows, 2);
1366 assert_eq!(file.checks[0].name, "Column intent");
1367 let value: serde_json::Value = serde_json::from_str(&text).unwrap();
1369 assert_eq!(value["version"], serde_json::json!(REPORT_VERSION));
1370 assert_eq!(value["format"], serde_json::json!(REPORT_FORMAT));
1371 }
1372
1373 #[test]
1376 fn markdown_says_what_was_measured_and_how() {
1377 let (results, plan) = measured();
1378 let text = render(
1379 &results,
1380 &plan,
1381 ReportFormat::Markdown,
1382 "2026-09-30T00:00:00Z",
1383 )
1384 .unwrap();
1385 for expected in [
1386 "# Data quality report",
1387 "- Source: `orders.parquet` (Parquet)",
1388 "- View: filter: status = open",
1389 "- Measured: all 4 rows, exact",
1390 "## Coverage",
1391 "## Problems",
1392 "**Repeated key** (id): 2 of 4 rows (50.0%) share their key with another row",
1393 "**Not allowed** (status): 1 of 3 values (33.3%) are not allowed",
1394 " - Allowed: open, closed",
1395 "| Column intent |",
1396 "| Key | id |",
1397 "| Intent | status: required; one of open, closed |",
1398 &format!("seed {}", plan.sample_seed),
1399 ] {
1400 assert!(text.contains(expected), "missing {expected:?} in\n{text}");
1401 }
1402 let every_row = DataQualityPlan {
1404 method: crate::analysis::sampling::SampleMethod::EveryRow,
1405 ..plan
1406 };
1407 let text = render(
1408 &results,
1409 &every_row,
1410 ReportFormat::Markdown,
1411 "2026-09-30T00:00:00Z",
1412 )
1413 .unwrap();
1414 assert!(text.contains("| Sample | Every row |"), "{text}");
1415 }
1416
1417 #[test]
1420 fn gaps_are_exported_with_their_windows() {
1421 let days = [0, 1, 2, 5, 6, 7, 8, 9];
1423 let base = chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
1424 let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
1425 let day = days
1426 .iter()
1427 .map(|offset| (base - epoch).num_days() as i32 + offset)
1428 .collect::<Vec<_>>();
1429 let lf = df!("day" => day, "value" => [1i64, 2, 3, 4, 5, 6, 7, 8])
1430 .unwrap()
1431 .lazy()
1432 .with_column(col("day").cast(DataType::Date));
1433 let plan = DataQualityPlan {
1434 compute: QualityCompute::Full,
1435 grain: crate::analysis::data_quality::QualityGrain::TimeWindows {
1436 column: "day".to_string(),
1437 every: "1d".to_string(),
1438 },
1439 expected: Some(crate::analysis::data_quality::ExpectedWindows {
1440 weekdays: false,
1441 from: Some("2024-01-01".to_string()),
1442 before: Some("2024-01-12".to_string()),
1443 }),
1444 ..DataQualityPlan::default()
1445 };
1446 let results = measure(&lf, None, &plan);
1447 let file = report_file(&results, &plan, "2026-09-30T00:00:00Z");
1448 let text = to_json(&file).unwrap();
1449 assert_eq!(serde_json::from_str::<ReportFile>(&text).unwrap(), file);
1450 let expected = file.setup.expected.as_ref().unwrap();
1451 assert_eq!(expected.from.as_deref(), Some("2024-01-01"));
1452 let gaps = file.gaps.as_ref().unwrap();
1453 assert_eq!(gaps.status, "checked");
1454 assert_eq!((gaps.column.as_str(), gaps.every.as_str()), ("day", "1d"));
1455 assert_eq!(gaps.from.as_deref(), Some("2024-01-01T00:00:00Z"));
1456 assert_eq!(gaps.before.as_deref(), Some("2024-01-12T00:00:00Z"));
1457 assert_eq!(
1458 (gaps.expected, gaps.with_rows, gaps.empty, gaps.not_sampled),
1459 (Some(11), Some(8), Some(3), Some(0))
1460 );
1461 assert_eq!(gaps.counted, Some(true));
1462 let runs = gaps
1463 .runs
1464 .iter()
1465 .map(|run| (run.kind.as_str(), run.span.as_str(), run.windows))
1466 .collect::<Vec<_>>();
1467 assert_eq!(
1468 runs,
1469 [
1470 ("empty", "2024-01-04 to 2024-01-05", 2),
1471 ("empty", "2024-01-11", 1)
1472 ]
1473 );
1474
1475 let markdown = to_markdown(&file, &results, &plan);
1476 for expected in [
1477 "## Gaps",
1478 "Expected every day by day, 2024-01-01T00:00:00Z to before 2024-01-12T00:00:00Z: 11 windows; 8 with rows, 3 empty, 0 not sampled, 0 out of scope",
1479 "| 2024-01-04 to 2024-01-05 | empty | 2 | - |",
1480 "| Expected | every day, 2024-01-01 to before 2024-01-12 |",
1481 ] {
1482 assert!(
1483 markdown.contains(expected),
1484 "missing {expected:?} in\n{markdown}"
1485 );
1486 }
1487
1488 let unstated = DataQualityPlan {
1490 expected: None,
1491 ..plan
1492 };
1493 let file = report_file(&results, &unstated, "2026-09-30T00:00:00Z");
1494 assert!(file.gaps.is_none() && file.setup.expected.is_none());
1495 assert!(!to_markdown(&file, &results, &unstated).contains("## Gaps"));
1496 }
1497
1498 #[test]
1499 fn the_path_takes_the_forms_extension() {
1500 let theme =
1501 crate::config::Theme::from_config(&crate::config::AppConfig::default().theme).unwrap();
1502 let mut form = ExportForm::new("orders", &theme);
1503 assert_eq!(form.path.value(), "orders-quality.json");
1504 form.cycle_format();
1505 assert_eq!(form.format, ReportFormat::Markdown);
1506 assert_eq!(form.path.value(), "orders-quality.md");
1507 assert!(form.path.is_suggested());
1509 form.path.set_value("report");
1510 assert_eq!(
1511 form.target().unwrap(),
1512 (
1513 std::path::PathBuf::from("report.md"),
1514 ReportFormat::Markdown
1515 )
1516 );
1517 form.path.set_value("report.json");
1519 assert_eq!(form.target().unwrap().1, ReportFormat::Json);
1520 form.path.set_value(" ");
1521 assert!(form.target().is_err());
1522 }
1523}