1use crate::data_quality::{
2 DataQualityPlan, DataQualityResults, IntervalClock, IntervalFact, QUALITY_WINDOW_WIDTHS,
3 QualityComparison, QualityCompute, QualityGrain, QualityMetric, QualityPage, TIME_FORMATS,
4 TemporalRole, TemporalRoleAssignment, TimeInterpretation, TimeKind,
5};
6use crate::quality_report::{EvidenceRows, Finding, FindingsView, QualityReport};
7use crate::statistics::{AnalysisResults, DistributionType};
8use ratatui::widgets::TableState;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SetupRow {
14 Sample,
15 TextAsTime,
16 TimeRoles,
17 Intervals,
18 Intent,
20 Grain,
21 Expected,
22 Compare,
23 Values,
24 Latency,
25 WindowBy,
26}
27
28impl SetupRow {
29 pub const ALL: [Self; 11] = [
30 Self::Sample,
31 Self::TextAsTime,
32 Self::TimeRoles,
33 Self::Intervals,
34 Self::Intent,
35 Self::Grain,
36 Self::Expected,
37 Self::Compare,
38 Self::Values,
39 Self::Latency,
40 Self::WindowBy,
41 ];
42
43 pub fn label(self) -> &'static str {
44 match self {
45 Self::Sample => "Sample",
46 Self::TextAsTime => "Text as time",
47 Self::TimeRoles => "Time roles",
48 Self::Intervals => "Intervals",
49 Self::Intent => "Column intent",
50 Self::Grain => "Grain",
51 Self::Expected => "Expected",
52 Self::Compare => "Compare",
53 Self::Values => "Values",
54 Self::Latency => "Latency over",
55 Self::WindowBy => "Window by",
56 }
57 }
58
59 pub fn at(index: usize) -> Self {
61 Self::ALL[index.min(Self::ALL.len() - 1)]
62 }
63
64 pub fn index(self) -> usize {
65 Self::ALL.iter().position(|row| *row == self).unwrap_or(0)
66 }
67}
68
69#[derive(Debug, Clone, PartialEq)]
71pub enum PlanChoice {
72 Grain(QualityGrain),
73 Values(QualityCompute),
74 Compare(QualityComparison),
75 Latency(Option<i64>),
76 Clock(IntervalClock),
78 TextColumn(String),
80 Format(String, Option<(TimeKind, &'static str)>),
82 FindingColumn(Option<String>),
84 FindingCheck(Option<&'static str>),
86}
87
88impl PlanChoice {
89 fn is_current(&self, plan: &DataQualityPlan) -> bool {
90 match self {
91 Self::Grain(grain) => &plan.grain == grain,
92 Self::Values(QualityCompute::Metadata) => plan.compute == QualityCompute::Metadata,
93 Self::Values(_) => plan.compute != QualityCompute::Metadata,
94 Self::Compare(comparison) => &plan.comparison == comparison,
95 Self::Latency(seconds) => &plan.latency_threshold_seconds == seconds,
96 Self::Clock(clock) => plan.interval_clock == *clock,
97 Self::TextColumn(column) => plan.time_format(column).is_some(),
98 Self::Format(column, format) => {
99 plan.time_format(column)
100 .map(|current| (current.kind, current.format.as_str()))
101 == format.map(|(kind, format)| (kind, format))
102 }
103 Self::FindingColumn(_) | Self::FindingCheck(_) => false,
105 }
106 }
107}
108
109#[derive(Debug, Clone)]
111pub struct PlanPicker {
112 pub row: Option<SetupRow>,
114 pub title: String,
115 pub choices: Vec<PlanChoice>,
116 pub state: crate::widgets::ui::PickerState,
117}
118
119#[derive(Debug, Clone, Default)]
124pub struct PlanContext {
125 pub partitions: Vec<String>,
126 pub time_columns: Vec<(String, bool)>,
127 pub files: bool,
128 pub text_columns: Vec<(String, Vec<String>)>,
129}
130
131#[derive(Debug, Clone)]
134pub struct EvidenceRead {
135 pub rows: EvidenceRows,
136 pub label: String,
138 pub sample: Option<crate::sampling::Sample>,
140 pub scope: crate::data_quality::QualityScope,
142 pub summary: Vec<(&'static str, String)>,
144}
145
146pub fn threshold_label(seconds: Option<i64>) -> &'static str {
148 match seconds {
149 None => "none",
150 Some(3_600) => "1 hour",
151 Some(86_400) => "1 day",
152 Some(604_800) => "1 week",
153 Some(_) => "custom",
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum ExpectedCadence {
160 None,
162 Every,
164 Weekdays,
166}
167
168pub const EXPECTED_ROWS: [&str; 3] = ["Windows", "From", "Before"];
170
171#[derive(Debug, Clone)]
174pub struct ExpectedForm {
175 pub field: usize,
177 pub cadence: ExpectedCadence,
178 pub from: crate::widgets::text_input::TextInput,
179 pub before: crate::widgets::text_input::TextInput,
180 pub error: Option<String>,
182}
183
184impl ExpectedForm {
185 pub fn new(plan: &DataQualityPlan, theme: &crate::config::Theme) -> Self {
186 let input = || crate::widgets::text_input::TextInput::new().with_theme(theme);
187 let (mut from, mut before) = (input(), input());
188 let cadence = match &plan.expected {
189 None => ExpectedCadence::None,
190 Some(expected) => {
191 from.set_value(expected.from.as_deref().unwrap_or_default());
192 before.set_value(expected.before.as_deref().unwrap_or_default());
193 let every = match &plan.grain {
196 crate::data_quality::QualityGrain::TimeWindows { every, .. } => every.as_str(),
197 _ => "",
198 };
199 if expected.weekdays && crate::data_quality::ExpectedWindows::weekdays_apply(every)
200 {
201 ExpectedCadence::Weekdays
202 } else {
203 ExpectedCadence::Every
204 }
205 }
206 };
207 let mut form = Self {
208 field: 0,
209 cadence,
210 from,
211 before,
212 error: None,
213 };
214 form.sync_focus();
215 form
216 }
217
218 pub fn cadences(every: &str) -> Vec<ExpectedCadence> {
220 let mut cadences = vec![ExpectedCadence::None, ExpectedCadence::Every];
221 if crate::data_quality::ExpectedWindows::weekdays_apply(every) {
222 cadences.push(ExpectedCadence::Weekdays);
223 }
224 cadences
225 }
226
227 pub fn cadence_label(&self, every: &str) -> String {
229 match self.cadence {
230 ExpectedCadence::None => "none: no window is a gap".to_string(),
231 ExpectedCadence::Every => {
232 crate::data_quality::ExpectedWindows::default().cadence_label(every)
233 }
234 ExpectedCadence::Weekdays => "weekdays, Monday to Friday".to_string(),
235 }
236 }
237
238 pub fn cycle(&mut self, every: &str, forward: bool) {
239 let cadences = Self::cadences(every);
240 let at = cadences
241 .iter()
242 .position(|cadence| *cadence == self.cadence)
243 .unwrap_or(0);
244 let next = if forward {
245 (at + 1) % cadences.len()
246 } else {
247 (at + cadences.len() - 1) % cadences.len()
248 };
249 self.cadence = cadences[next];
250 self.error = None;
251 }
252
253 pub fn typing(&self) -> bool {
255 self.field > 0
256 }
257
258 pub fn input_mut(&mut self) -> Option<&mut crate::widgets::text_input::TextInput> {
259 match self.field {
260 1 => Some(&mut self.from),
261 2 => Some(&mut self.before),
262 _ => None,
263 }
264 }
265
266 pub fn sync_focus(&mut self) {
267 self.from.set_focused(self.field == 1);
268 self.before.set_focused(self.field == 2);
269 }
270
271 pub fn expected(&self) -> Result<Option<crate::data_quality::ExpectedWindows>, String> {
273 if self.cadence == ExpectedCadence::None {
274 return Ok(None);
275 }
276 let typed = |input: &crate::widgets::text_input::TextInput| {
277 let text = input.value().trim();
278 (!text.is_empty()).then(|| text.to_string())
279 };
280 let expected = crate::data_quality::ExpectedWindows {
281 weekdays: self.cadence == ExpectedCadence::Weekdays,
282 from: typed(&self.from),
283 before: typed(&self.before),
284 };
285 match expected.problem() {
286 Some(problem) => Err(problem),
287 None => Ok(Some(expected)),
288 }
289 }
290}
291
292impl crate::form::Form for ExpectedForm {
293 type Field = usize;
295
296 fn fields(&self) -> Vec<(usize, crate::form::FieldKind)> {
297 use crate::form::FieldKind;
298 (0..EXPECTED_ROWS.len())
299 .map(|row| {
300 let kind = if row == 0 {
301 FieldKind::Choice
302 } else {
303 FieldKind::Text
304 };
305 (row, kind)
306 })
307 .collect()
308 }
309
310 fn focused(&self) -> usize {
311 self.field
312 }
313
314 fn set_focused(&mut self, field: usize) {
315 self.field = field;
316 self.sync_focus();
317 }
318}
319
320pub fn set_time_format(plan: &mut DataQualityPlan, column: &str, format: Option<(TimeKind, &str)>) {
324 plan.time_formats
325 .retain(|interpretation| interpretation.column != column);
326 match format {
327 Some((kind, format)) => plan.time_formats.push(TimeInterpretation {
328 column: column.to_string(),
329 kind,
330 format: format.to_string(),
331 }),
332 None => {
333 if matches!(&plan.grain, QualityGrain::TimeWindows { column: on, .. } if on == column) {
334 plan.grain = QualityGrain::Dataset;
335 plan.baseline_segment = None;
336 }
337 }
338 }
339}
340
341#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
342pub enum AnalysisView {
343 #[default]
344 Main, DistributionDetail, CorrelationDetail, }
348
349#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
350pub enum AnalysisTool {
351 #[default]
352 Describe, DistributionAnalysis, CorrelationMatrix, DataQuality, }
357
358impl AnalysisTool {
359 pub const ALL: [Self; 4] = [
361 Self::Describe,
362 Self::DistributionAnalysis,
363 Self::CorrelationMatrix,
364 Self::DataQuality,
365 ];
366
367 pub fn index(self) -> usize {
369 Self::ALL.iter().position(|tool| *tool == self).unwrap_or(0)
370 }
371}
372
373#[derive(Debug, Clone)]
375pub struct AnalysisProgress {
376 pub phase: String,
377 pub started: std::time::Instant,
380 pub reads_source: Option<bool>,
383 pub interruptible: Option<bool>,
386 pub read: Option<crate::sampling::ReadWatch>,
388 pub reuse: Option<String>,
390}
391
392impl AnalysisProgress {
393 pub fn new(phase: &str) -> Self {
394 Self {
395 phase: phase.to_string(),
396 started: std::time::Instant::now(),
397 reads_source: None,
398 interruptible: None,
399 read: None,
400 reuse: None,
401 }
402 }
403
404 pub fn read_runs_out(&self) -> bool {
407 self.reads_source == Some(true) && self.interruptible == Some(false)
408 }
409}
410
411#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
412pub enum AnalysisFocus {
413 #[default]
414 Main, Sidebar, DistributionSelector, }
418
419#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
421pub struct DetailScroll {
422 pub offset: u16,
423 pub max: u16,
424}
425
426#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
430pub struct ColumnScroll {
431 pub offset: usize,
432 pub max: usize,
433}
434
435impl ColumnScroll {
436 pub fn left(&mut self) {
437 self.offset = self.offset.min(self.max).saturating_sub(1);
438 }
439
440 pub fn right(&mut self) {
441 if self.offset < self.max {
442 self.offset += 1;
443 }
444 }
445}
446
447#[derive(Default)]
448pub struct AnalysisModal {
449 pub active: bool,
450 pub scroll_position: usize,
451 pub selected_column: Option<usize>,
452 pub describe_columns: ColumnScroll,
453 pub distribution_columns: ColumnScroll,
454 pub correlation_columns: ColumnScroll,
456 pub sample: crate::sampling::Sample,
459 pub sample_dataset: Option<u64>,
462 pub sample_run_for: Option<u64>,
465 pub sample_form: Option<crate::sample_modal::SampleForm>,
467 pub own_sample: Option<crate::sampling::Sample>,
470 pub table_state: TableState, pub distribution_table_state: TableState, pub correlation_table_state: TableState, pub sidebar_state: TableState, pub describe_results: Option<AnalysisResults>,
476 pub distribution_results: Option<AnalysisResults>,
477 pub correlation_results: Option<AnalysisResults>,
478 pub data_quality_results: Option<DataQualityResults>,
479 pub computing: Option<AnalysisProgress>,
481 pub view: AnalysisView,
482 pub focus: AnalysisFocus,
483 pub selected_tool: Option<AnalysisTool>,
485 pub selected_distribution: Option<usize>, pub selected_correlation: Option<(usize, usize)>, pub correlation_method: crate::statistics::CorrelationMethod,
489 pub selected_theoretical_distribution: DistributionType, pub distribution_selector_state: TableState, pub histogram_scale: HistogramScale, pub data_quality_page: QualityPage,
493 pub data_quality_plan: DataQualityPlan,
496 pub data_quality_setup_before: Option<DataQualityPlan>,
499 pub data_quality_setup_return: QualityPage,
501 pub data_quality_setup_note: Option<String>,
503 pub data_quality_table_state: TableState,
504 pub data_quality_picker: Option<PlanPicker>,
506 pub data_quality_plan_field: usize,
508 pub data_quality_show_access: bool,
509 pub data_quality_observation_detail: bool,
510 pub data_quality_detail_scroll: DetailScroll,
512 pub data_quality_segment_index: usize,
514 pub data_quality_segments_by_change: bool,
516 pub data_quality_checks_expanded: bool,
518 pub data_quality_confirm_run: bool,
520 pub data_quality_plan_before_edit: Option<DataQualityPlan>,
521 pub data_quality_last_plan: Option<DataQualityPlan>,
522 pub data_quality_from_cache: bool,
523 pub data_quality_metric: QualityMetric,
524 pub data_quality_column_index: usize,
525 pub data_quality_interval_index: usize,
527 pub data_quality_findings: FindingsView,
529 pub data_quality_evidence_read: Option<EvidenceRead>,
531 pub data_quality_trend_line: usize,
533 pub data_quality_expected_form: Option<ExpectedForm>,
535 pub data_quality_intent_form: Option<crate::intent_modal::IntentForm>,
537 pub data_quality_export: Option<crate::quality_export::ExportForm>,
539 pub results_view: Option<u64>,
542 kept: Option<Kept>,
545}
546
547struct Kept {
549 view: Option<u64>,
550 tool: Option<AnalysisTool>,
551 describe: Option<AnalysisResults>,
552 distribution: Option<AnalysisResults>,
553 correlation: Option<AnalysisResults>,
554 row: Option<usize>,
555 distribution_row: Option<usize>,
556 cell: Option<(usize, usize)>,
557}
558
559#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
560pub enum HistogramScale {
561 #[default]
562 Linear,
563 Log,
564}
565
566impl AnalysisModal {
567 pub fn new() -> Self {
568 Self::default()
569 }
570
571 pub fn with_sample_rows(rows: usize) -> Self {
574 let mut modal = Self::default();
575 modal.sample.seed = crate::sample_modal::new_seed();
576 if rows == 0 {
577 modal.sample.method = crate::sampling::SampleMethod::EveryRow;
578 } else {
579 modal.sample.rows = rows;
580 }
581 modal
582 }
583
584 pub fn open(&mut self, view: Option<u64>) {
588 let kept = self
589 .kept
590 .take()
591 .filter(|kept| view.is_some() && kept.view == view);
592 self.results_view = view;
593 self.active = true;
594 self.scroll_position = 0;
595 self.selected_column = None;
596 self.describe_columns = ColumnScroll::default();
597 self.distribution_columns = ColumnScroll::default();
598 self.correlation_columns = ColumnScroll::default();
599 self.table_state.select(Some(0));
600 self.distribution_table_state.select(Some(0));
601 self.correlation_table_state.select(Some(0));
602 self.sidebar_state.select(Some(0)); self.view = AnalysisView::Main;
604 self.focus = AnalysisFocus::Sidebar; self.selected_tool = None; self.selected_distribution = Some(0);
607 self.selected_correlation = None;
608 self.computing = None;
609 self.describe_results = None;
610 self.distribution_results = None;
611 self.correlation_results = None;
612 self.data_quality_results = None;
613 self.data_quality_page = QualityPage::Setup;
614 self.data_quality_plan = DataQualityPlan::default();
615 self.data_quality_setup_before = None;
616 self.data_quality_setup_return = QualityPage::Overview;
617 self.data_quality_setup_note = None;
618 self.data_quality_table_state.select(Some(0));
619 self.data_quality_picker = None;
620 self.data_quality_plan_field = 0;
621 self.data_quality_show_access = false;
622 self.data_quality_observation_detail = false;
623 self.data_quality_confirm_run = false;
624 self.data_quality_plan_before_edit = None;
625 self.data_quality_last_plan = None;
626 self.data_quality_from_cache = false;
627 self.data_quality_metric = QualityMetric::NullRate;
628 self.data_quality_column_index = 0;
629 self.data_quality_interval_index = 0;
630 self.data_quality_trend_line = 0;
631 self.data_quality_expected_form = None;
632 self.data_quality_intent_form = None;
633 self.data_quality_export = None;
634 self.sample_form = None;
635 if let Some(kept) = kept {
636 self.describe_results = kept.describe;
637 self.distribution_results = kept.distribution;
638 self.correlation_results = kept.correlation;
639 self.selected_tool = kept.tool;
640 if self.current_results().is_none() {
641 self.selected_tool = None;
642 }
643 if let Some(tool) = self.selected_tool {
644 self.sidebar_state.select(Some(tool.index()));
645 }
646 self.table_state.select(kept.row.or(Some(0)));
647 self.distribution_table_state
648 .select(kept.distribution_row.or(Some(0)));
649 self.selected_distribution = kept.distribution_row.or(Some(0));
650 if let Some(cell) = kept.cell {
651 self.selected_correlation = Some(cell);
652 self.correlation_table_state.select(Some(cell.0));
653 }
654 }
655 if self.correlation_results.is_none() {
656 self.selected_correlation = None;
657 }
658 }
659
660 pub fn close(&mut self) {
663 self.kept = Some(Kept {
664 view: self.results_view.take(),
665 tool: self
666 .selected_tool
667 .filter(|tool| *tool != AnalysisTool::DataQuality),
668 describe: self.describe_results.take(),
669 distribution: self.distribution_results.take(),
670 correlation: self.correlation_results.take(),
671 row: self.table_state.selected(),
672 distribution_row: self.distribution_table_state.selected(),
673 cell: self.selected_correlation,
674 });
675 self.active = false;
676 self.scroll_position = 0;
677 self.selected_column = None;
678 self.describe_columns = ColumnScroll::default();
679 self.distribution_columns = ColumnScroll::default();
680 self.correlation_columns = ColumnScroll::default();
681 self.view = AnalysisView::Main;
682 self.focus = AnalysisFocus::Main;
683 self.selected_tool = None;
684 self.selected_distribution = None;
685 self.selected_correlation = None;
686 self.computing = None;
687 self.describe_results = None;
688 self.distribution_results = None;
689 self.correlation_results = None;
690 self.data_quality_results = None;
691 self.data_quality_page = QualityPage::Setup;
692 self.data_quality_setup_before = None;
693 self.data_quality_setup_note = None;
694 self.data_quality_picker = None;
695 self.data_quality_show_access = false;
696 self.data_quality_observation_detail = false;
697 self.data_quality_confirm_run = false;
698 self.data_quality_plan_before_edit = None;
699 self.data_quality_last_plan = None;
700 self.data_quality_from_cache = false;
701 self.data_quality_metric = QualityMetric::NullRate;
702 self.data_quality_column_index = 0;
703 self.data_quality_interval_index = 0;
704 self.data_quality_trend_line = 0;
705 self.data_quality_expected_form = None;
706 self.data_quality_intent_form = None;
707 self.data_quality_export = None;
708 }
709
710 pub fn current_results(&self) -> Option<&AnalysisResults> {
712 match self.selected_tool {
713 Some(AnalysisTool::Describe) => self.describe_results.as_ref(),
714 Some(AnalysisTool::DistributionAnalysis) => self.distribution_results.as_ref(),
715 Some(AnalysisTool::CorrelationMatrix) => self.correlation_results.as_ref(),
716 Some(AnalysisTool::DataQuality) => None,
717 None => None,
718 }
719 }
720
721 pub fn switch_focus(&mut self) {
725 self.focus = match self.focus {
726 AnalysisFocus::Sidebar if self.selected_tool.is_some() => AnalysisFocus::Main,
727 _ => AnalysisFocus::Sidebar,
728 };
729 }
730
731 pub fn highlighted_tool(&self) -> Option<AnalysisTool> {
733 AnalysisTool::ALL
734 .get(self.sidebar_state.selected()?)
735 .copied()
736 }
737
738 pub fn select_tool(&mut self) {
741 if self.sidebar_state.selected().is_some() {
742 self.selected_tool = Some(self.highlighted_tool().unwrap_or_default());
743 }
744 }
745
746 pub fn next_tool(&mut self) {
747 if let Some(current) = self.sidebar_state.selected() {
748 let next = (current + 1).min(AnalysisTool::ALL.len() - 1);
749 self.sidebar_state.select(Some(next));
750 }
751 }
752
753 pub fn previous_tool(&mut self) {
754 if let Some(current) = self.sidebar_state.selected()
755 && current > 0
756 {
757 self.sidebar_state.select(Some(current - 1));
758 }
759 }
760
761 pub fn open_distribution_detail(&mut self) {
762 if self.focus == AnalysisFocus::Main
763 && self.selected_tool == Some(AnalysisTool::DistributionAnalysis)
764 && let Some(idx) = self.distribution_table_state.selected()
765 {
766 if let Some(results) = &self.distribution_results
767 && let Some(dist_analysis) = results.distribution_analyses.get(idx)
768 {
769 self.selected_theoretical_distribution = dist_analysis.distribution_type;
770 }
771 self.view = AnalysisView::DistributionDetail;
772 self.focus = AnalysisFocus::DistributionSelector;
773 if self.selected_theoretical_distribution == DistributionType::Unknown {
774 self.selected_theoretical_distribution = DistributionType::Normal;
775 }
776 self.distribution_selector_state.select(None);
777 }
778 }
779
780 pub fn open_correlation_detail(&mut self) {
781 if self.focus == AnalysisFocus::Main
782 && self.selected_tool == Some(AnalysisTool::CorrelationMatrix)
783 && let Some((row, col)) = self.selected_correlation
784 && row != col
785 {
786 self.view = AnalysisView::CorrelationDetail;
787 }
788 }
789
790 pub fn close_detail(&mut self) {
791 self.view = AnalysisView::Main;
792 self.focus = AnalysisFocus::Main;
793 }
794
795 pub fn scroll_left(&mut self) {
796 if let Some(columns) = self.column_scroll_mut() {
797 columns.left();
798 }
799 }
800
801 pub fn scroll_right(&mut self) {
802 if let Some(columns) = self.column_scroll_mut() {
803 columns.right();
804 }
805 }
806
807 pub fn column_scroll(&self) -> Option<&ColumnScroll> {
809 match self.selected_tool {
810 Some(AnalysisTool::Describe) => Some(&self.describe_columns),
811 Some(AnalysisTool::DistributionAnalysis) => Some(&self.distribution_columns),
812 _ => None,
813 }
814 }
815
816 fn column_scroll_mut(&mut self) -> Option<&mut ColumnScroll> {
817 match self.selected_tool {
818 Some(AnalysisTool::Describe) => Some(&mut self.describe_columns),
819 Some(AnalysisTool::DistributionAnalysis) => Some(&mut self.distribution_columns),
820 _ => None,
821 }
822 }
823
824 pub fn follow_view_sample(&mut self, sampled: bool) {
827 match (sampled, self.own_sample.is_some()) {
828 (true, false) => {
829 let every = crate::sampling::Sample {
830 scope: crate::data_quality::QualityScope::CurrentView,
831 method: crate::sampling::SampleMethod::EveryRow,
832 ..self.sample.clone()
833 };
834 self.own_sample = Some(std::mem::replace(&mut self.sample, every));
835 }
836 (false, true) => {
837 if let Some(own) = self.own_sample.take() {
838 self.sample = own;
839 }
840 }
841 _ => {}
842 }
843 }
844
845 pub fn recalculate(&mut self) {
847 self.sample.seed = crate::sample_modal::new_seed();
848 }
849
850 pub fn quality_row_count(&self) -> usize {
851 let Some(results) = self.data_quality_results.as_ref() else {
852 return 0;
853 };
854 match self.data_quality_page {
855 QualityPage::Setup => SetupRow::ALL.len(),
856 QualityPage::TimeRoles => TemporalRole::ALL.len(),
857 QualityPage::IntervalPairs => self.data_quality_plan.candidate_pairs().len(),
858 QualityPage::Intent => 0,
861 QualityPage::Intervals => results.temporal.len(),
862 QualityPage::IntervalDetail => self.interval_facts().len(),
863 QualityPage::Overview => self
864 .data_quality_findings
865 .shown(&crate::quality_report::build_report(results))
866 .len(),
867 QualityPage::Columns | QualityPage::Detail => results.columns.len(),
868 QualityPage::Segments => results.segments.len(),
869 QualityPage::SegmentDetail => {
870 crate::data_quality::segment_changes(results, self.data_quality_segment_index).len()
871 }
872 QualityPage::Trends => {
874 crate::quality_trends::trend_view(results, self.data_quality_metric, 1)
875 .lines
876 .len()
877 }
878 QualityPage::TrendDetail => crate::quality_trends::trend_slots(results).len(),
881 QualityPage::Gaps => {
882 match crate::quality_trends::expected_gaps(self.quality_result_plan(), results) {
883 Some(crate::quality_trends::Gaps::Checked(check)) => check.runs.len(),
884 _ => 0,
885 }
886 }
887 QualityPage::ExpectedWindows => EXPECTED_ROWS.len(),
888 }
889 }
890
891 pub fn quality_selected_is_clean(&self) -> bool {
894 self.data_quality_page == QualityPage::Overview
895 && self
896 .selected_finding()
897 .is_some_and(|(_, finding)| finding.kind.is_none())
898 }
899
900 pub fn selected_finding(&self) -> Option<(QualityReport, Finding)> {
903 let results = self.data_quality_results.as_ref()?;
904 let report = crate::quality_report::build_report(results);
905 let finding = self
906 .data_quality_findings
907 .selected(&report, self.data_quality_table_state.selected()?)?
908 .clone();
909 Some((report, finding))
910 }
911
912 pub fn open_findings_picker(&mut self, by_column: bool) {
915 let Some(results) = self.data_quality_results.as_ref() else {
916 return;
917 };
918 let report = crate::quality_report::build_report(results);
919 let view = &self.data_quality_findings;
920 let findings = |count: usize| match count {
921 0 => "none".to_string(),
922 1 => "1 finding".to_string(),
923 count => format!("{} findings", crate::numfmt::group_chrome(count)),
924 };
925 let (title, choices, current) = if by_column {
926 let columns = crate::quality_report::column_choices(&report, results);
927 let width = columns
928 .iter()
929 .map(|(name, _)| crate::glyphs::display_width(name))
930 .max()
931 .unwrap_or(0);
932 let current = view
933 .column
934 .as_ref()
935 .and_then(|column| columns.iter().position(|(name, _)| name == column))
936 .map_or(0, |position| position + 1);
937 let mut choices = vec![("All columns".to_string(), PlanChoice::FindingColumn(None))];
938 choices.extend(columns.into_iter().map(|(name, count)| {
939 let pad = width.saturating_sub(crate::glyphs::display_width(&name));
940 (
941 format!("{name}{} {}", " ".repeat(pad), findings(count)),
942 PlanChoice::FindingColumn(Some(name)),
943 )
944 }));
945 ("Findings by Column", choices, current)
946 } else {
947 let checks = crate::quality_report::check_choices(&report);
948 let width = checks
949 .iter()
950 .map(|(name, _)| crate::glyphs::display_width(name))
951 .max()
952 .unwrap_or(0);
953 let current = view
954 .check
955 .and_then(|check| checks.iter().position(|(name, _)| *name == check))
956 .map_or(0, |position| position + 1);
957 let mut choices = vec![("All types".to_string(), PlanChoice::FindingCheck(None))];
958 choices.extend(checks.into_iter().map(|(name, count)| {
959 let pad = width.saturating_sub(crate::glyphs::display_width(name));
960 (
961 format!("{name}{} {}", " ".repeat(pad), findings(count)),
962 PlanChoice::FindingCheck(Some(name)),
963 )
964 }));
965 ("Findings by Type", choices, current)
966 };
967 let (labels, choices): (Vec<_>, Vec<_>) = choices.into_iter().unzip();
968 let mut state = crate::widgets::ui::PickerState::new(labels);
969 state.select_original(current);
970 self.data_quality_picker = Some(PlanPicker {
971 row: None,
972 title: title.to_string(),
973 choices,
974 state,
975 });
976 }
977
978 pub fn cycle_findings_order(&mut self) {
980 let selected = self.selected_finding();
981 self.data_quality_findings.order = self.data_quality_findings.order.next();
982 self.reselect_finding(selected.map(|(_, finding)| finding));
983 }
984
985 pub fn clear_findings_narrowing(&mut self) {
987 let selected = self.selected_finding();
988 self.data_quality_findings.column = None;
989 self.data_quality_findings.check = None;
990 self.reselect_finding(selected.map(|(_, finding)| finding));
991 }
992
993 fn reselect_finding(&mut self, finding: Option<Finding>) {
995 let position = self.data_quality_results.as_ref().and_then(|results| {
996 let report = crate::quality_report::build_report(results);
997 let finding = finding?;
998 let shown = self.data_quality_findings.shown(&report);
999 shown.iter().position(|index| {
1000 let listed = &report.findings[*index];
1001 listed.title == finding.title && listed.columns == finding.columns
1002 })
1003 });
1004 self.data_quality_table_state
1005 .select(Some(position.unwrap_or(0)));
1006 *self.data_quality_table_state.offset_mut() = 0;
1007 }
1008
1009 pub fn sample_key_opens_form(&self) -> bool {
1012 self.active
1013 && self.view == AnalysisView::Main
1014 && self.selected_tool.is_some()
1015 && self.computing.is_none()
1016 && self.data_quality_picker.is_none()
1017 && !matches!(
1018 self.data_quality_page,
1019 QualityPage::TimeRoles
1020 | QualityPage::IntervalPairs
1021 | QualityPage::ExpectedWindows
1022 | QualityPage::Intent
1023 )
1024 && !self.data_quality_show_access
1025 && !self.data_quality_observation_detail
1026 && self.data_quality_evidence_read.is_none()
1027 && self.data_quality_export.is_none()
1028 && self.data_quality_intent_form.is_none()
1029 && self.sample_form.is_none()
1030 }
1031
1032 pub fn export_typing(&self) -> bool {
1034 self.active
1035 && self
1036 .data_quality_export
1037 .as_ref()
1038 .is_some_and(|form| !form.on_format)
1039 }
1040
1041 pub fn intent_typing(&self) -> bool {
1044 self.active
1045 && self
1046 .data_quality_intent_form
1047 .as_ref()
1048 .is_some_and(crate::intent_modal::IntentForm::typing)
1049 }
1050
1051 pub fn sample_scope_typing(&self) -> bool {
1054 self.active
1055 && self.sample_form.as_ref().is_some_and(|form| {
1056 form.field.is_text() && (!form.inline || self.focus == AnalysisFocus::Main)
1057 })
1058 }
1059
1060 pub fn quality_expected_typing(&self) -> bool {
1063 self.active
1064 && self.data_quality_page == QualityPage::ExpectedWindows
1065 && self
1066 .data_quality_expected_form
1067 .as_ref()
1068 .is_some_and(ExpectedForm::typing)
1069 }
1070
1071 pub fn open_trend_detail(&mut self) {
1073 let line = self.data_quality_table_state.selected().unwrap_or(0);
1074 self.data_quality_trend_line = line;
1075 self.set_quality_page(QualityPage::TrendDetail);
1076 }
1077
1078 pub fn cycle_trend_detail_metric(&mut self) {
1081 let Some(results) = self.data_quality_results.as_ref() else {
1082 return;
1083 };
1084 let view = crate::quality_trends::trend_view(results, self.data_quality_metric, 1);
1085 let line = view
1086 .lines
1087 .get(self.data_quality_trend_line)
1088 .map(|line| (line.measure, line.names.clone()));
1089 self.cycle_quality_metric();
1090 let Some(results) = self.data_quality_results.as_ref() else {
1091 return;
1092 };
1093 let view = crate::quality_trends::trend_view(results, self.data_quality_metric, 1);
1094 let found = line.and_then(|(measure, names)| {
1095 view.lines.iter().position(|candidate| {
1096 candidate.measure == measure
1097 || candidate
1098 .names
1099 .iter()
1100 .any(|name| !candidate.rows() && names.contains(name))
1101 })
1102 });
1103 match found {
1104 Some(index) => self.data_quality_trend_line = index,
1105 None => {
1106 self.data_quality_trend_line = 0;
1107 self.close_to_trends();
1108 }
1109 }
1110 }
1111
1112 pub fn close_to_trends(&mut self) {
1114 let line = (self.data_quality_page == QualityPage::TrendDetail)
1115 .then_some(self.data_quality_trend_line);
1116 self.set_quality_page(QualityPage::Trends);
1117 self.data_quality_table_state
1118 .select(Some(line.unwrap_or(0)));
1119 }
1120
1121 pub fn set_quality_page(&mut self, page: QualityPage) {
1122 self.data_quality_page = page;
1123 self.data_quality_observation_detail = false;
1124 self.data_quality_evidence_read = None;
1125 self.data_quality_table_state.select(Some(0));
1126 }
1127
1128 pub fn set_quality_column_page(&mut self, page: QualityPage) {
1131 if matches!(
1134 self.data_quality_page,
1135 QualityPage::Columns | QualityPage::Detail
1136 ) {
1137 self.data_quality_column_index = self.data_quality_table_state.selected().unwrap_or(0);
1138 }
1139 self.set_quality_page(page);
1140 self.data_quality_table_state
1141 .select(Some(self.data_quality_column_index));
1142 }
1143
1144 pub fn open_segment_detail(&mut self) {
1147 if let Some(segment) = self.selected_segment() {
1148 self.data_quality_segment_index = segment;
1149 self.set_quality_page(QualityPage::SegmentDetail);
1150 }
1151 }
1152
1153 pub fn close_segment_detail(&mut self) {
1154 self.set_quality_page(QualityPage::Segments);
1155 let position = self
1156 .segment_order()
1157 .iter()
1158 .position(|segment| *segment == self.data_quality_segment_index);
1159 self.data_quality_table_state
1160 .select(Some(position.unwrap_or(0)));
1161 }
1162
1163 pub fn segment_order(&self) -> Vec<usize> {
1165 self.data_quality_results
1166 .as_ref()
1167 .map(|results| {
1168 crate::data_quality::segment_order(results, self.data_quality_segments_by_change)
1169 })
1170 .unwrap_or_default()
1171 }
1172
1173 pub fn selected_segment(&self) -> Option<usize> {
1175 let position = self.data_quality_table_state.selected()?;
1176 self.segment_order().get(position).copied()
1177 }
1178
1179 pub fn toggle_segment_order(&mut self) {
1182 let segment = self.selected_segment();
1183 self.data_quality_segments_by_change = !self.data_quality_segments_by_change;
1184 let position = segment
1185 .and_then(|segment| self.segment_order().iter().position(|s| *s == segment))
1186 .unwrap_or(0);
1187 self.data_quality_table_state.select(Some(position));
1188 }
1189
1190 pub fn open_interval_detail(&mut self) {
1192 let Some(index) = self.data_quality_table_state.selected() else {
1193 return;
1194 };
1195 if self
1196 .data_quality_results
1197 .as_ref()
1198 .is_some_and(|results| index < results.temporal.len())
1199 {
1200 self.data_quality_interval_index = index;
1201 self.set_quality_page(QualityPage::IntervalDetail);
1202 }
1203 }
1204
1205 pub fn close_interval_detail(&mut self) {
1207 self.set_quality_page(QualityPage::Intervals);
1208 self.data_quality_table_state
1209 .select(Some(self.data_quality_interval_index));
1210 }
1211
1212 pub fn interval_facts(&self) -> Vec<IntervalFact> {
1215 let plan = self.quality_result_plan();
1216 self.data_quality_results
1217 .as_ref()
1218 .and_then(|results| results.temporal.get(self.data_quality_interval_index))
1219 .map(|profile| {
1220 IntervalFact::ALL
1221 .into_iter()
1222 .filter(|fact| profile.count(*fact, plan).is_some())
1223 .collect()
1224 })
1225 .unwrap_or_default()
1226 }
1227
1228 pub fn selected_interval_fact(&self) -> Option<IntervalFact> {
1230 let facts = self.interval_facts();
1231 facts
1232 .get(self.data_quality_table_state.selected().unwrap_or(0))
1233 .copied()
1234 }
1235
1236 pub fn interval_evidence(
1242 &self,
1243 schema: Option<&polars::prelude::Schema>,
1244 ) -> Option<(polars::prelude::Expr, String, usize)> {
1245 let results = self.data_quality_results.as_ref()?;
1246 if !matches!(
1247 results.precision,
1248 crate::data_quality::QualityPrecision::Exact
1249 | crate::data_quality::QualityPrecision::Sampled
1250 ) {
1251 return None;
1252 }
1253 let plan = self.quality_result_plan();
1254 let profile = results.temporal.get(self.data_quality_interval_index)?;
1255 let fact = self.selected_interval_fact()?;
1256 let (count, _) = profile.count(fact, plan)?;
1257 if count == 0 {
1258 return None;
1259 }
1260 let predicate = profile.evidence_predicate(fact, plan, schema)?;
1261 Some((
1262 predicate,
1263 format!(
1264 "Data Quality / {} / {} / {}",
1265 profile.label(),
1266 profile.segment,
1267 fact.short()
1268 ),
1269 count,
1270 ))
1271 }
1272
1273 pub fn scroll_quality_detail(&mut self, rows: i32) {
1275 let scroll = &mut self.data_quality_detail_scroll;
1276 scroll.offset = (scroll.offset as i32 + rows).clamp(0, scroll.max as i32) as u16;
1277 }
1278
1279 pub fn show_quality_tab(&mut self, page: QualityPage) {
1281 if matches!(
1282 self.data_quality_page,
1283 QualityPage::Columns | QualityPage::Detail
1284 ) {
1285 self.data_quality_column_index = self.data_quality_table_state.selected().unwrap_or(0);
1286 }
1287 self.set_quality_page(page);
1288 if page == QualityPage::Columns {
1289 self.data_quality_table_state
1290 .select(Some(self.data_quality_column_index));
1291 }
1292 }
1293
1294 pub fn step_quality_tab(&mut self, forward: bool) {
1296 let tabs = QualityPage::TABS;
1297 let Some(at) = tabs
1298 .iter()
1299 .position(|page| *page == self.data_quality_page.tab())
1300 else {
1301 return;
1302 };
1303 let next = if forward {
1304 (at + 1).min(tabs.len() - 1)
1305 } else {
1306 at.saturating_sub(1)
1307 };
1308 if next != at {
1309 self.show_quality_tab(tabs[next]);
1310 }
1311 }
1312
1313 pub fn cycle_quality_metric(&mut self) {
1314 let current = QualityMetric::ALL
1315 .iter()
1316 .position(|metric| *metric == self.data_quality_metric)
1317 .unwrap_or(0);
1318 self.data_quality_metric = QualityMetric::ALL[(current + 1) % QualityMetric::ALL.len()];
1319 }
1320
1321 pub fn quality_result_plan(&self) -> &DataQualityPlan {
1325 self.data_quality_last_plan
1326 .as_ref()
1327 .unwrap_or(&self.data_quality_plan)
1328 }
1329
1330 pub fn quality_plan_pending(&self) -> bool {
1332 self.data_quality_results.is_some()
1333 && self.data_quality_last_plan.as_ref() != Some(&self.data_quality_plan)
1334 }
1335
1336 pub fn setup_row(&self) -> SetupRow {
1338 SetupRow::at(self.data_quality_plan_field)
1339 }
1340
1341 pub fn setup_edited(&self) -> bool {
1343 self.data_quality_setup_before
1344 .as_ref()
1345 .is_some_and(|before| *before != self.data_quality_plan)
1346 }
1347
1348 pub fn plan_choices(&self, row: SetupRow, context: &PlanContext) -> Vec<(String, PlanChoice)> {
1350 match row {
1351 SetupRow::Grain => {
1352 let mut grains = vec![QualityGrain::Dataset];
1353 if context.files {
1354 grains.push(QualityGrain::File);
1355 }
1356 grains.extend(
1357 context
1358 .partitions
1359 .iter()
1360 .cloned()
1361 .map(QualityGrain::Partition),
1362 );
1363 for (column, has_time) in &context.time_columns {
1366 for every in QUALITY_WINDOW_WIDTHS {
1367 if every == "1h" && !has_time {
1368 continue;
1369 }
1370 grains.push(QualityGrain::TimeWindows {
1371 column: column.clone(),
1372 every: every.to_string(),
1373 });
1374 }
1375 }
1376 grains.extend([100_000, 1_000_000].map(QualityGrain::RowChunks));
1377 if !grains.contains(&self.data_quality_plan.grain) {
1378 grains.insert(0, self.data_quality_plan.grain.clone());
1379 }
1380 grains
1381 .into_iter()
1382 .map(|grain| (grain.label(), PlanChoice::Grain(grain)))
1383 .collect()
1384 }
1385 SetupRow::Values => {
1386 let read =
1388 if self.data_quality_plan.method == crate::sampling::SampleMethod::EveryRow {
1389 QualityCompute::Full
1390 } else {
1391 QualityCompute::Sample
1392 };
1393 vec![
1394 ("read".to_string(), PlanChoice::Values(read)),
1395 (
1396 "file metadata only".to_string(),
1397 PlanChoice::Values(QualityCompute::Metadata),
1398 ),
1399 ]
1400 }
1401 SetupRow::Compare => [
1402 QualityComparison::None,
1403 QualityComparison::Previous,
1404 QualityComparison::Baseline,
1405 ]
1406 .into_iter()
1407 .map(|comparison| {
1408 (
1409 comparison.choice_label().to_string(),
1410 PlanChoice::Compare(comparison),
1411 )
1412 })
1413 .collect(),
1414 SetupRow::WindowBy if !self.data_quality_plan.windows_intervals() => Vec::new(),
1415 SetupRow::WindowBy => IntervalClock::ALL
1416 .into_iter()
1417 .map(|clock| (clock.label().to_string(), PlanChoice::Clock(clock)))
1418 .collect(),
1419 SetupRow::Latency if self.data_quality_plan.interval_pairs().is_empty() => Vec::new(),
1420 SetupRow::Latency => [None, Some(3_600), Some(86_400), Some(604_800)]
1421 .into_iter()
1422 .map(|seconds| {
1423 (
1424 threshold_label(seconds).to_string(),
1425 PlanChoice::Latency(seconds),
1426 )
1427 })
1428 .collect(),
1429 SetupRow::TextAsTime => context
1432 .text_columns
1433 .iter()
1434 .map(|(column, examples)| {
1435 let label = match (self.data_quality_plan.time_format(column), examples.first())
1436 {
1437 (Some(format), _) => format!("{column} as {}", format.label()),
1438 (None, Some(example)) => format!("{column} {example}"),
1439 (None, None) => column.clone(),
1440 };
1441 (label, PlanChoice::TextColumn(column.clone()))
1442 })
1443 .collect(),
1444 SetupRow::Sample
1445 | SetupRow::TimeRoles
1446 | SetupRow::Intervals
1447 | SetupRow::Expected
1448 | SetupRow::Intent => Vec::new(),
1449 }
1450 }
1451
1452 pub fn open_plan_picker(&mut self, row: SetupRow, context: &PlanContext) {
1454 let choices = self.plan_choices(row, context);
1455 self.data_quality_plan_field = row.index();
1456 self.show_picker(row, row.label().to_string(), choices);
1457 }
1458
1459 fn show_picker(&mut self, row: SetupRow, title: String, choices: Vec<(String, PlanChoice)>) {
1460 if choices.is_empty() {
1461 return;
1462 }
1463 let current = choices
1464 .iter()
1465 .position(|(_, choice)| choice.is_current(&self.data_quality_plan))
1466 .unwrap_or(0);
1467 let (labels, choices): (Vec<_>, Vec<_>) = choices.into_iter().unzip();
1468 let mut state = crate::widgets::ui::PickerState::new(labels);
1469 state.select_original(current);
1470 self.data_quality_picker = Some(PlanPicker {
1471 row: Some(row),
1472 title,
1473 choices,
1474 state,
1475 });
1476 }
1477
1478 pub fn open_format_picker(&mut self, column: &str, examples: &[String]) {
1482 let mut formats = TIME_FORMATS
1483 .iter()
1484 .enumerate()
1485 .map(|(order, (kind, format))| {
1486 let interpretation = TimeInterpretation {
1487 column: column.to_string(),
1488 kind: *kind,
1489 format: format.to_string(),
1490 };
1491 let read = examples
1492 .iter()
1493 .filter(|value| interpretation.reads(value))
1494 .count();
1495 (read, order, *kind, *format)
1496 })
1497 .collect::<Vec<_>>();
1498 formats.sort_by_key(|(read, order, _, _)| (std::cmp::Reverse(*read), *order));
1500 let mut choices = formats
1501 .into_iter()
1502 .map(|(read, _, kind, format)| {
1503 let label = if examples.is_empty() {
1506 format!("{} {format}", kind.label())
1507 } else {
1508 format!(
1509 "reads {read} of {} {} {format}",
1510 examples.len(),
1511 kind.label()
1512 )
1513 };
1514 (
1515 label,
1516 PlanChoice::Format(column.to_string(), Some((kind, format))),
1517 )
1518 })
1519 .collect::<Vec<_>>();
1520 if self.data_quality_plan.time_format(column).is_some() {
1521 choices.push((
1522 "text, not a time".to_string(),
1523 PlanChoice::Format(column.to_string(), None),
1524 ));
1525 }
1526 self.show_picker(SetupRow::TextAsTime, format!("Read {column} As"), choices);
1527 }
1528
1529 pub fn choose_plan_picker(&mut self) -> Option<String> {
1532 let picker = self.data_quality_picker.take()?;
1533 let choice = picker
1534 .state
1535 .selected_original()
1536 .and_then(|index| picker.choices.get(index))?;
1537 let plan = &mut self.data_quality_plan;
1538 match choice.clone() {
1539 PlanChoice::Grain(grain) => {
1540 if plan.grain != grain {
1541 plan.baseline_segment = None;
1542 }
1543 plan.grain = grain;
1544 }
1545 PlanChoice::Values(compute) => plan.compute = compute,
1546 PlanChoice::Compare(comparison) => {
1547 plan.comparison = comparison;
1548 if comparison != QualityComparison::Baseline {
1549 plan.baseline_segment = None;
1550 }
1551 }
1552 PlanChoice::Latency(seconds) => plan.latency_threshold_seconds = seconds,
1553 PlanChoice::Clock(clock) => plan.interval_clock = clock,
1554 PlanChoice::TextColumn(column) => return Some(column),
1555 PlanChoice::Format(column, format) => set_time_format(plan, &column, format),
1556 PlanChoice::FindingColumn(column) => {
1557 let selected = self.selected_finding();
1558 self.data_quality_findings.column = column;
1559 self.reselect_finding(selected.map(|(_, finding)| finding));
1560 }
1561 PlanChoice::FindingCheck(check) => {
1562 let selected = self.selected_finding();
1563 self.data_quality_findings.check = check;
1564 self.reselect_finding(selected.map(|(_, finding)| finding));
1565 }
1566 }
1567 None
1568 }
1569
1570 pub fn cycle_setup_choice(&mut self, row: SetupRow, context: &PlanContext, forward: bool) {
1573 let choices = self.plan_choices(row, context);
1574 if choices.is_empty() || matches!(row, SetupRow::TextAsTime) {
1575 return;
1576 }
1577 let current = choices
1578 .iter()
1579 .position(|(_, choice)| choice.is_current(&self.data_quality_plan))
1580 .unwrap_or(0);
1581 let next = if forward {
1582 (current + 1).min(choices.len() - 1)
1583 } else {
1584 current.saturating_sub(1)
1585 };
1586 let (labels, choices): (Vec<_>, Vec<_>) = choices.into_iter().unzip();
1587 let mut state = crate::widgets::ui::PickerState::new(labels);
1588 state.select_original(next);
1589 self.data_quality_picker = Some(PlanPicker {
1590 row: Some(row),
1591 title: row.label().to_string(),
1592 choices,
1593 state,
1594 });
1595 self.choose_plan_picker();
1596 }
1597
1598 pub fn cycle_quality_time_role(
1599 &mut self,
1600 role_index: usize,
1601 columns: &[String],
1602 forward: bool,
1603 ) {
1604 let Some(role) = TemporalRole::ALL.get(role_index).copied() else {
1605 return;
1606 };
1607 let current = self
1608 .data_quality_plan
1609 .temporal_roles
1610 .iter()
1611 .find(|assignment| assignment.role == role)
1612 .and_then(|assignment| columns.iter().position(|name| name == &assignment.column))
1613 .map(|index| index + 1)
1614 .unwrap_or(0);
1615 let choices = columns.len() + 1;
1616 let next = if forward {
1617 (current + 1) % choices
1618 } else if current == 0 {
1619 choices - 1
1620 } else {
1621 current - 1
1622 };
1623 self.data_quality_plan
1624 .temporal_roles
1625 .retain(|assignment| assignment.role != role);
1626 if next > 0 {
1627 self.data_quality_plan
1628 .temporal_roles
1629 .push(TemporalRoleAssignment {
1630 role,
1631 column: columns[next - 1].clone(),
1632 timezone: None,
1633 });
1634 }
1635 }
1636
1637 pub fn next_row(&mut self, max_rows: usize) {
1638 if self.focus == AnalysisFocus::Sidebar {
1639 self.next_tool();
1640 return;
1641 }
1642 match self.selected_tool {
1643 Some(AnalysisTool::Describe) => {
1644 if let Some(current) = self.table_state.selected() {
1645 let next = (current + 1).min(max_rows.saturating_sub(1));
1646 self.table_state.select(Some(next));
1647 } else {
1648 self.table_state.select(Some(0));
1649 }
1650 }
1651 Some(AnalysisTool::DistributionAnalysis) => {
1652 if let Some(current) = self.distribution_table_state.selected() {
1653 let next = (current + 1).min(max_rows.saturating_sub(1));
1654 self.distribution_table_state.select(Some(next));
1655 self.selected_distribution = Some(next);
1656 } else {
1657 self.distribution_table_state.select(Some(0));
1658 self.selected_distribution = Some(0);
1659 }
1660 }
1661 Some(AnalysisTool::CorrelationMatrix) => {
1662 if let Some((row, col)) = self.selected_correlation {
1663 let next_row = (row + 1).min(max_rows.saturating_sub(1));
1664 self.selected_correlation = Some((next_row, col));
1665 self.correlation_table_state.select(Some(next_row));
1666 }
1667 }
1668 Some(AnalysisTool::DataQuality) => {
1669 let current = self.data_quality_table_state.selected().unwrap_or(0);
1670 self.data_quality_table_state
1671 .select(Some((current + 1).min(max_rows.saturating_sub(1))));
1672 }
1673 None => {}
1674 }
1675 }
1676
1677 pub fn previous_row(&mut self) {
1678 if self.focus == AnalysisFocus::Sidebar {
1679 self.previous_tool();
1680 return;
1681 }
1682 match self.selected_tool {
1683 Some(AnalysisTool::Describe) => {
1684 if let Some(current) = self.table_state.selected()
1685 && current > 0
1686 {
1687 self.table_state.select(Some(current - 1));
1688 }
1689 }
1690 Some(AnalysisTool::DistributionAnalysis) => {
1691 if let Some(current) = self.distribution_table_state.selected()
1692 && current > 0
1693 {
1694 let prev = current - 1;
1695 self.distribution_table_state.select(Some(prev));
1696 self.selected_distribution = Some(prev);
1697 }
1698 }
1699 Some(AnalysisTool::CorrelationMatrix) => {
1700 if let Some((row, col)) = self.selected_correlation
1701 && row > 0
1702 {
1703 let prev_row = row - 1;
1704 self.selected_correlation = Some((prev_row, col));
1705 self.correlation_table_state.select(Some(prev_row));
1706 }
1707 }
1708 Some(AnalysisTool::DataQuality) => {
1709 let current = self.data_quality_table_state.selected().unwrap_or(0);
1710 self.data_quality_table_state
1711 .select(Some(current.saturating_sub(1)));
1712 }
1713 None => {}
1714 }
1715 }
1716
1717 pub fn page_down(&mut self, max_rows: usize, page_size: usize) {
1718 if self.focus == AnalysisFocus::Sidebar {
1719 return;
1720 }
1721
1722 match self.selected_tool {
1723 Some(AnalysisTool::Describe) => {
1724 if let Some(current) = self.table_state.selected() {
1725 let next = (current + page_size).min(max_rows.saturating_sub(1));
1726 self.table_state.select(Some(next));
1727 }
1728 }
1729 Some(AnalysisTool::DistributionAnalysis) => {
1730 if let Some(current) = self.distribution_table_state.selected() {
1731 let next = (current + page_size).min(max_rows.saturating_sub(1));
1732 self.distribution_table_state.select(Some(next));
1733 self.selected_distribution = Some(next);
1734 }
1735 }
1736 Some(AnalysisTool::CorrelationMatrix) => {
1737 if let Some((row, col)) = self.selected_correlation {
1738 let next_row = (row + page_size).min(max_rows.saturating_sub(1));
1739 self.selected_correlation = Some((next_row, col));
1740 self.correlation_table_state.select(Some(next_row));
1741 }
1742 }
1743 Some(AnalysisTool::DataQuality) => {
1744 let current = self.data_quality_table_state.selected().unwrap_or(0);
1745 self.data_quality_table_state
1746 .select(Some((current + page_size).min(max_rows.saturating_sub(1))));
1747 }
1748 None => {}
1749 }
1750 }
1751
1752 pub fn page_up(&mut self, page_size: usize) {
1753 if self.focus == AnalysisFocus::Sidebar {
1754 return;
1755 }
1756
1757 match self.selected_tool {
1758 Some(AnalysisTool::Describe) => {
1759 if let Some(current) = self.table_state.selected() {
1760 let next = current.saturating_sub(page_size);
1761 self.table_state.select(Some(next));
1762 }
1763 }
1764 Some(AnalysisTool::DistributionAnalysis) => {
1765 if let Some(current) = self.distribution_table_state.selected() {
1766 let next = current.saturating_sub(page_size);
1767 self.distribution_table_state.select(Some(next));
1768 self.selected_distribution = Some(next);
1769 }
1770 }
1771 Some(AnalysisTool::CorrelationMatrix) => {
1772 if let Some((row, col)) = self.selected_correlation {
1773 let prev_row = row.saturating_sub(page_size);
1774 self.selected_correlation = Some((prev_row, col));
1775 self.correlation_table_state.select(Some(prev_row));
1776 }
1777 }
1778 Some(AnalysisTool::DataQuality) => {
1779 let current = self.data_quality_table_state.selected().unwrap_or(0);
1780 self.data_quality_table_state
1781 .select(Some(current.saturating_sub(page_size)));
1782 }
1783 None => {}
1784 }
1785 }
1786
1787 pub fn install_correlations(&mut self, results: AnalysisResults) {
1791 let n = results
1792 .correlation_matrix
1793 .as_ref()
1794 .map_or(0, |matrix| matrix.columns.len());
1795 self.correlation_results = Some(results);
1796 let cell = match self.selected_correlation {
1797 Some((row, col)) if row < n && col < n => (row, col),
1798 _ if n >= 2 => (0, 1),
1799 _ => (0, 0),
1800 };
1801 self.selected_correlation = Some(cell);
1802 self.correlation_table_state.select(Some(cell.0));
1803 }
1804
1805 pub fn correlation_size(&self) -> usize {
1807 self.correlation_results
1808 .as_ref()
1809 .and_then(|results| results.correlation_matrix.as_ref())
1810 .map_or(0, |matrix| matrix.columns.len())
1811 }
1812
1813 pub fn distribution_choices(&self) -> usize {
1816 let row = self.distribution_table_state.selected().unwrap_or(0);
1817 self.distribution_results
1818 .as_ref()
1819 .and_then(|results| results.distribution_analyses.get(row))
1820 .map_or(0, |analysis| {
1821 crate::distribution_fit::listing_order(&analysis.fits).len()
1822 })
1823 }
1824
1825 pub fn move_correlation_cell(&mut self, (rows, cols): (isize, isize)) {
1828 let n = self.correlation_size();
1829 if n == 0 {
1830 return;
1831 }
1832 if let Some((row, col)) = self.selected_correlation {
1833 let row = row.saturating_add_signed(rows).min(n - 1);
1834 let col = col.saturating_add_signed(cols).min(n - 1);
1835 self.selected_correlation = Some((row, col));
1836 self.correlation_table_state.select(Some(row));
1837 }
1838 }
1839
1840 pub fn next_distribution(&mut self) {
1841 let max_idx = self.distribution_choices().saturating_sub(1);
1842
1843 if let Some(current) = self.distribution_selector_state.selected() {
1844 let next = (current + 1).min(max_idx);
1845 self.distribution_selector_state.select(Some(next));
1846 self.select_distribution();
1847 } else {
1848 self.distribution_selector_state.select(Some(0));
1849 self.select_distribution();
1850 }
1851 }
1852
1853 pub fn previous_distribution(&mut self) {
1854 if let Some(current) = self.distribution_selector_state.selected() {
1855 if current > 0 {
1856 self.distribution_selector_state.select(Some(current - 1));
1857 self.select_distribution();
1858 }
1859 } else {
1860 self.distribution_selector_state.select(Some(0));
1861 self.select_distribution();
1862 }
1863 }
1864
1865 pub fn select_distribution(&mut self) {
1866 if let Some(idx) = self.distribution_selector_state.selected()
1867 && let Some(results) = &self.distribution_results
1868 {
1869 let dist_analysis_idx = self.distribution_table_state.selected().unwrap_or(0);
1870 if let Some(dist_analysis) = results.distribution_analyses.get(dist_analysis_idx) {
1871 let distribution_scores =
1873 crate::distribution_fit::listing_order(&dist_analysis.fits);
1874 let valid_idx = idx.min(distribution_scores.len().saturating_sub(1));
1875 if let Some(dist_type) = distribution_scores.get(valid_idx) {
1876 self.selected_theoretical_distribution = *dist_type;
1877 if idx != valid_idx {
1878 self.distribution_selector_state.select(Some(valid_idx));
1879 }
1880 }
1881 }
1882 }
1883 }
1884}
1885
1886#[cfg(test)]
1887mod quality_scope_tests {
1888 use super::*;
1889
1890 #[test]
1893 fn expected_weekdays_read_as_every_window_on_weeks() {
1894 let theme =
1895 crate::config::Theme::from_config(&crate::config::ThemeConfig::default()).unwrap();
1896 let mut plan = DataQualityPlan {
1897 grain: QualityGrain::TimeWindows {
1898 column: "day".to_string(),
1899 every: "1d".to_string(),
1900 },
1901 expected: Some(crate::data_quality::ExpectedWindows {
1902 weekdays: true,
1903 ..Default::default()
1904 }),
1905 ..DataQualityPlan::default()
1906 };
1907 assert_eq!(
1908 ExpectedForm::new(&plan, &theme).cadence,
1909 ExpectedCadence::Weekdays
1910 );
1911 plan.grain = QualityGrain::TimeWindows {
1912 column: "day".to_string(),
1913 every: "1w".to_string(),
1914 };
1915 let form = ExpectedForm::new(&plan, &theme);
1916 assert_eq!(form.cadence, ExpectedCadence::Every);
1917 assert_eq!(form.cadence_label("1w"), "every week");
1918 assert!(!form.expected().unwrap().unwrap().weekdays);
1919 }
1920
1921 #[test]
1924 fn grain_choices_come_from_the_data() {
1925 let mut modal = AnalysisModal::new();
1926 let context = PlanContext {
1927 partitions: vec!["year".to_string()],
1928 time_columns: vec![("date".to_string(), false), ("stamp".to_string(), true)],
1929 files: true,
1930 text_columns: Vec::new(),
1931 };
1932 let labels = modal
1933 .plan_choices(SetupRow::Grain, &context)
1934 .into_iter()
1935 .map(|(label, _)| label)
1936 .collect::<Vec<_>>();
1937 assert_eq!(
1938 labels,
1939 [
1940 "whole dataset",
1941 "by file",
1942 "by year",
1943 "by day of date",
1944 "by week of date",
1945 "by month of date",
1946 "by hour of stamp",
1947 "by day of stamp",
1948 "by week of stamp",
1949 "by month of stamp",
1950 "in chunks of 100,000 rows",
1951 "in chunks of 1,000,000 rows",
1952 ]
1953 );
1954 modal.open_plan_picker(SetupRow::Grain, &context);
1956 let picker = modal.data_quality_picker.as_mut().unwrap();
1957 assert_eq!(picker.state.selected_original(), Some(0));
1958 picker.state.move_down();
1959 picker.state.move_down();
1960 picker.state.move_down();
1961 modal.choose_plan_picker();
1962 assert!(modal.data_quality_picker.is_none());
1963 assert_eq!(
1964 modal.data_quality_plan.grain,
1965 QualityGrain::TimeWindows {
1966 column: "date".to_string(),
1967 every: "1d".to_string()
1968 }
1969 );
1970 }
1971
1972 #[test]
1975 fn a_read_is_the_samples_kind() {
1976 let mut modal = AnalysisModal::new();
1977 let context = PlanContext::default();
1978 modal.data_quality_plan.compute = QualityCompute::Metadata;
1979 modal.open_plan_picker(SetupRow::Values, &context);
1980 modal
1981 .data_quality_picker
1982 .as_mut()
1983 .unwrap()
1984 .state
1985 .select_original(0);
1986 modal.choose_plan_picker();
1987 assert_eq!(modal.data_quality_plan.compute, QualityCompute::Sample);
1988 modal.data_quality_plan.method = crate::sampling::SampleMethod::EveryRow;
1989 modal.open_plan_picker(SetupRow::Values, &context);
1990 modal.choose_plan_picker();
1991 assert_eq!(modal.data_quality_plan.compute, QualityCompute::Full);
1992 modal.open_plan_picker(SetupRow::Latency, &context);
1993 assert!(
1994 modal.data_quality_picker.is_none(),
1995 "no threshold without an interval to measure"
1996 );
1997 }
1998
1999 #[test]
2003 fn text_is_read_as_time_through_a_chosen_format() {
2004 let mut modal = AnalysisModal::new();
2005 let context = PlanContext {
2006 text_columns: vec![(
2007 "created".to_string(),
2008 vec!["2024-01-31 08:15:00".to_string()],
2009 )],
2010 ..PlanContext::default()
2011 };
2012 modal.open_plan_picker(SetupRow::TextAsTime, &context);
2013 assert_eq!(modal.choose_plan_picker(), Some("created".to_string()));
2014 modal.open_format_picker("created", &["2024-01-31 08:15:00".to_string()]);
2015 let picker = modal.data_quality_picker.as_ref().unwrap();
2016 assert_eq!(picker.title, "Read created As");
2017 let first = picker.state.filtered()[0].1.to_string();
2018 assert_eq!(first, "reads 1 of 1 datetime %Y-%m-%d %H:%M:%S");
2019 assert_eq!(modal.choose_plan_picker(), None);
2020 let format = modal.data_quality_plan.time_format("created").unwrap();
2021 assert_eq!(format.kind, TimeKind::Datetime);
2022
2023 let windows = PlanContext {
2025 time_columns: vec![("created".to_string(), true)],
2026 ..context.clone()
2027 };
2028 modal.open_plan_picker(SetupRow::Grain, &windows);
2029 let picker = modal.data_quality_picker.as_mut().unwrap();
2030 picker.state.move_down();
2031 picker.state.move_down();
2032 modal.choose_plan_picker();
2033 assert!(matches!(
2034 modal.data_quality_plan.grain,
2035 QualityGrain::TimeWindows { .. }
2036 ));
2037 modal.open_format_picker("created", &[]);
2038 let picker = modal.data_quality_picker.as_mut().unwrap();
2039 let text = picker
2040 .state
2041 .filtered()
2042 .iter()
2043 .position(|(_, label)| *label == "text, not a time")
2044 .unwrap();
2045 for _ in 0..text {
2046 picker.state.move_down();
2047 }
2048 modal.choose_plan_picker();
2049 assert!(modal.data_quality_plan.time_formats.is_empty());
2050 assert_eq!(modal.data_quality_plan.grain, QualityGrain::Dataset);
2051 }
2052}