Skip to main content

datui_lib/analysis/
quality_export.rs

1//! A Data Quality report written to a file: the measurements on screen, the setup
2//! they were measured with, and the source they were measured on. Built from the
3//! results in memory; writing one reads nothing from the data.
4//!
5//! Two forms: versioned JSON for tools, with every measurement, and a short
6//! Markdown report for people. The JSON schema is documented in
7//! `docs/reference/data-quality.md`; a change that breaks a reader bumps
8//! [`REPORT_VERSION`].
9
10use 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
18/// What the JSON says it is, so a reader can tell it from any other JSON.
19pub const REPORT_FORMAT: &str = "datui-data-quality-report";
20
21/// The JSON schema's version. Fields may be added within a version; one removed,
22/// renamed or changed in meaning is a new version.
23pub const REPORT_VERSION: u32 = 1;
24
25/// The most file names the source identity lists.
26const MAX_FILE_NAMES: usize = 100;
27
28/// Which form of the report to write.
29#[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    /// What the format holds, for the dialog's line.
54    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/// What a report was measured on, as far as datui can say without reading it: where
63/// the data came from, what the view did to it, and for a local file its size and
64/// modification time when the run began. No content hash: that would be a read.
65#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
66pub struct SourceIdentity {
67    /// The path or URL as opened.
68    pub location: Option<String>,
69    pub remote: bool,
70    pub format: Option<String>,
71    /// Files the dataset reads, when it reads several.
72    pub files: Option<usize>,
73    /// Their names, the first hundred.
74    pub file_names: Vec<String>,
75    /// A local file's size in bytes when the run began.
76    pub bytes: Option<u64>,
77    /// A local file's modification time when the run began, RFC 3339 in UTC.
78    pub modified: Option<String>,
79    /// What the view did to the rows: query, filters, sort, reshape. Empty for the
80    /// data as loaded.
81    pub view: Vec<String>,
82}
83
84impl SourceIdentity {
85    /// Record `location`'s size and modification time, when it is one local file.
86    /// A stat, not a read; run in the worker as the run begins.
87    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    /// File names, cut to the first hundred.
105    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/// The report as JSON: [`REPORT_FORMAT`], version [`REPORT_VERSION`].
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct ReportFile {
117    pub format: String,
118    pub version: u32,
119    /// The datui that wrote it.
120    pub datui_version: String,
121    /// When the file was written, RFC 3339 in UTC. Not when the data was read.
122    pub exported_at: String,
123    /// Where the rows came from. `None` for results no run labeled.
124    pub source: Option<SourceIdentity>,
125    pub setup: SetupJson,
126    pub run: RunJson,
127    /// The headline: `2 problems  1 note  9 of 12 columns clean`.
128    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    /// The expected windows checked for gaps; `None` when none are stated.
138    pub gaps: Option<GapsJson>,
139}
140
141/// The setup the report was measured with: every setting a run takes.
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct SetupJson {
144    /// `current view`, `whole source`, a partition, files or a row range.
145    pub scope: String,
146    /// `sample`, `full` or `metadata`.
147    pub values: String,
148    pub sample: SampleJson,
149    /// `whole dataset`, `by file`, `by year`, `by day of date`, `in chunks of …`.
150    pub grain: String,
151    /// `none`, `previous` or `baseline`.
152    pub comparison: String,
153    pub baseline_segment: Option<String>,
154    pub time_formats: Vec<TimeFormatJson>,
155    pub time_roles: Vec<TimeRoleJson>,
156    /// Measured intervals, `start to end` by role.
157    pub intervals: Vec<String>,
158    /// What puts an interval in a time window.
159    pub window_by: String,
160    pub latency_threshold_seconds: Option<i64>,
161    pub intent: DeclaredJson,
162    /// The windows rows are expected in, on a time-window grain; `None` unstated.
163    pub expected: Option<ExpectedJson>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct ExpectedJson {
168    /// Only Monday to Friday's hours or days are expected.
169    pub weekdays: bool,
170    /// The first expected time and the time expected windows end before, as typed;
171    /// `None` takes the first or last window the run found.
172    pub from: Option<String>,
173    pub before: Option<String>,
174}
175
176/// The expected windows checked against the segments the run counted.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct GapsJson {
179    /// `checked`; `no_values` (file metadata only), `no_windows` (no range stated
180    /// and no window found) or `too_many` (more windows than are checked).
181    pub status: String,
182    /// The grain's column and window width: `1h`, `1d`, `1w`, `1mo`.
183    pub column: String,
184    pub every: String,
185    /// `every hour`, `weekdays`, ...
186    pub cadence: String,
187    /// Windows in the stated range, for `too_many`.
188    pub windows_in_range: Option<usize>,
189    /// The first expected window's start and where the last ends, UTC.
190    pub from: Option<String>,
191    pub before: Option<String>,
192    /// Windows expected in the range, and weekend windows left out of it.
193    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    /// Whether a window with no rows found is known to be empty.
200    pub counted: Option<bool>,
201    /// Consecutive gap windows of one kind, in order.
202    pub runs: Vec<GapRunJson>,
203    /// Runs past the listed ones, counted and not listed.
204    pub more_runs: usize,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct GapRunJson {
209    /// `empty`, `not_sampled` or `out_of_scope`.
210    pub kind: String,
211    /// Where its first and last windows start, UTC.
212    pub first: String,
213    pub last: String,
214    /// The windows it covers, in calendar terms: `2024-01-06 to 2024-01-07`.
215    pub span: String,
216    pub windows: usize,
217    /// Rows the scope holds in it, for windows the sample missed, when counted.
218    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
225/// A gap run's kind as the JSON names it.
226fn 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    /// `Random`, `Equal per <column>`, `First rows` or `Every row`.
305    pub method: String,
306    /// Rows asked for: in all, or per value for an equal-per-value sample.
307    pub rows: usize,
308    /// The seed: the same seed, scope and source draw the same rows.
309    pub seed: u64,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313pub struct TimeFormatJson {
314    pub column: String,
315    /// `date` or `datetime`.
316    pub kind: String,
317    /// A strftime format.
318    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/// The declared intent, as Setup holds it.
328#[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    /// `whole number` or `decimal` for text read as a number.
342    pub read_as: Option<String>,
343}
344
345/// What the run read and how far its numbers reach.
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
347pub struct RunJson {
348    /// `exact`, `sampled` or `metadata`.
349    pub precision: String,
350    /// Rows in scope, when known.
351    pub total_rows: Option<usize>,
352    /// Rows measured: the sample's, or every row in scope.
353    pub evaluated_rows: usize,
354    /// Rows an equal-per-value sample kept of each value.
355    pub per_value: Option<usize>,
356    pub source_files: Option<usize>,
357    pub footers_read: Option<usize>,
358    /// What the run's reads were seen to do; `None` when nothing watched them.
359    pub reads: Option<ReadsJson>,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363pub struct ReadsJson {
364    /// Stages that read the source.
365    pub source_reads: usize,
366    /// Of those, the ones whose rows were counted.
367    pub counted: usize,
368    /// Rows the counted reads passed through, over every pass.
369    pub rows_traversed: usize,
370    /// The local copy of a remote source a full scan's passes read, when they did.
371    #[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    /// This run fetched it; false when an earlier run did.
380    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    /// `passed`, `found`, `skipped` or `unavailable`.
406    pub outcome: String,
407    /// What was found, or why it was skipped or unavailable.
408    pub detail: Option<String>,
409    /// `exact`, `sampled` or `metadata`.
410    pub basis: String,
411}
412
413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
414pub struct FindingJson {
415    /// `problem`, `note` or `clean`.
416    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    /// The finding's numbers in a sentence, as its detail shows them.
423    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    /// Rows in the segment, when counted.
463    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    /// Rows with both ends present and read: what negative, zero and over are of.
479    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    /// `duration > threshold`, strictly.
492    pub threshold_seconds: Option<i64>,
493    pub over_threshold: Option<usize>,
494}
495
496/// What the declared intent found.
497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
498pub struct IntentJson {
499    /// False when the run read no values, so nothing declared was checked.
500    pub measured: bool,
501    pub precision: String,
502    pub evaluated_rows: usize,
503    pub key: Option<KeyJson>,
504    pub columns: Vec<ColumnCheckJson>,
505    /// Declared columns the scope did not have.
506    pub absent: Vec<String>,
507}
508
509#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
510pub struct KeyJson {
511    pub columns: Vec<String>,
512    /// Rows with no value in some part of the key.
513    pub missing: usize,
514    /// Key values held by more than one row.
515    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    /// Rows with a value, as stored.
525    pub values: usize,
526    pub missing: Option<usize>,
527    pub unparsed: Option<usize>,
528    pub outside: Option<usize>,
529    /// Values the range compared.
530    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
635/// The report as [`ReportFile`]: built from `results`, measured with `plan` on
636/// `results.source`, at `exported_at`. Nothing here reads the data.
637pub 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
802/// The JSON report, pretty-printed.
803pub fn to_json(file: &ReportFile) -> color_eyre::Result<String> {
804    Ok(serde_json::to_string_pretty(file)?)
805}
806
807/// A cell of a Markdown table: pipes escaped, line breaks flattened.
808fn cell(text: &str) -> String {
809    text.replace('|', "\\|").replace('\n', " ")
810}
811
812/// The report for people: what was measured on, the verdict and how far it reaches,
813/// the findings with their numbers and evidence, and the setup to run it again.
814/// `file` is the report `results` and `plan` make; what it holds as text is read
815/// from them, typed.
816pub 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    // The verdict's parts sit two spaces apart on screen; a comma here.
876    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            // Every row takes no size and no seed, and the first rows no seed.
1075            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
1190/// The report in `format`, ready to write.
1191pub 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
1204/// Write the report to `path`, replacing a file there only under
1205/// [`Overwrite::Replace`](crate::export::output_file::Overwrite). The results are in
1206/// memory: this writes, and reads nothing.
1207pub 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
1223/// The export dialog: where to write, and which form.
1224pub struct ExportForm {
1225    pub path: crate::widgets::text_input::TextInput,
1226    pub format: ReportFormat,
1227    /// The cursor is on the format rather than the path.
1228    pub on_format: bool,
1229    /// Why Enter did not write, until the next edit.
1230    pub error: Option<String>,
1231}
1232
1233impl ExportForm {
1234    /// The dialog on `stem`'s report as JSON in the current directory.
1235    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    /// The next or previous form; a path ending in the old form's extension takes
1248    /// the new one's.
1249    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    /// Where to write and in which form: `~` and `$VAR` expanded, a typed `.json`
1274    /// or `.md` choosing the form, and the form's extension added when the path has
1275    /// none. A blank path is refused.
1276    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    /// The JSON reads back into the schema it was written from, with its format and
1334    /// version, the setup, the intent and its findings.
1335    #[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        // The repeated key is written as the report lists it: by kind, not by its title.
1355        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        // A generic reader finds the version where the schema says.
1368        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    /// The Markdown names the source, the verdict, coverage, every finding with its
1374    /// numbers, and the setup to run it again.
1375    #[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        // Every row takes no size or seed, so the setup names none.
1403        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    /// Expected windows go into the setup, and the gaps they find into the report:
1418    /// counts by kind and each run, in JSON and in the Markdown.
1419    #[test]
1420    fn gaps_are_exported_with_their_windows() {
1421        // 2024-01-01 to 2024-01-10, with no rows on the 4th and 5th.
1422        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        // No windows stated: no gaps, and the setup says none.
1489        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        // Still the form's own suggestion, so typing replaces it.
1508        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        // A typed extension chooses the form.
1518        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}