1use crate::analysis::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::analysis::quality_report::{EvidenceRows, Finding, FindingsView, QualityReport};
7use crate::analysis::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 title: String,
113 pub choices: Vec<PlanChoice>,
114 pub state: crate::widgets::ui::PickerState,
115}
116
117#[derive(Debug, Clone, Default)]
121pub struct PlanContext {
122 pub partitions: Vec<String>,
123 pub time_columns: Vec<(String, bool)>,
124 pub files: bool,
125 pub text_columns: Vec<(String, Vec<String>)>,
126}
127
128#[derive(Debug, Clone)]
131pub struct EvidenceRead {
132 pub rows: EvidenceRows,
133 pub label: String,
135 pub sample: Option<crate::analysis::sampling::Sample>,
137 pub scope: crate::analysis::data_quality::QualityScope,
139 pub summary: Vec<(&'static str, String)>,
141}
142
143pub fn threshold_label(seconds: Option<i64>) -> String {
145 match seconds {
146 None => "none".to_string(),
147 Some(3_600) => "1 hour".to_string(),
148 Some(86_400) => "1 day".to_string(),
149 Some(604_800) => "1 week".to_string(),
150 Some(seconds) => crate::numfmt::duration(seconds),
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum ExpectedCadence {
157 None,
159 Every,
161 Weekdays,
163}
164
165pub const EXPECTED_ROWS: [&str; 3] = ["Windows", "From", "Before"];
167
168#[derive(Debug, Clone)]
171pub struct ExpectedForm {
172 pub field: usize,
174 pub cadence: ExpectedCadence,
175 pub from: crate::widgets::text_input::TextInput,
176 pub before: crate::widgets::text_input::TextInput,
177 pub error: Option<String>,
179}
180
181impl ExpectedForm {
182 pub fn new(plan: &DataQualityPlan, theme: &crate::config::Theme) -> Self {
183 let input = || crate::widgets::text_input::TextInput::new().with_theme(theme);
184 let (mut from, mut before) = (input(), input());
185 let cadence = match &plan.expected {
186 None => ExpectedCadence::None,
187 Some(expected) => {
188 from.set_value(expected.from.as_deref().unwrap_or_default());
189 before.set_value(expected.before.as_deref().unwrap_or_default());
190 let every = match &plan.grain {
193 crate::analysis::data_quality::QualityGrain::TimeWindows { every, .. } => {
194 every.as_str()
195 }
196 _ => "",
197 };
198 if expected.weekdays
199 && crate::analysis::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::analysis::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::analysis::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(
273 &self,
274 ) -> Result<Option<crate::analysis::data_quality::ExpectedWindows>, String> {
275 if self.cadence == ExpectedCadence::None {
276 return Ok(None);
277 }
278 let typed = |input: &crate::widgets::text_input::TextInput| {
279 let text = input.value().trim();
280 (!text.is_empty()).then(|| text.to_string())
281 };
282 let expected = crate::analysis::data_quality::ExpectedWindows {
283 weekdays: self.cadence == ExpectedCadence::Weekdays,
284 from: typed(&self.from),
285 before: typed(&self.before),
286 };
287 match expected.problem() {
288 Some(problem) => Err(problem),
289 None => Ok(Some(expected)),
290 }
291 }
292}
293
294impl crate::app::form::Form for ExpectedForm {
295 type Field = usize;
297
298 fn fields(&self) -> Vec<(usize, crate::app::form::FieldKind)> {
299 use crate::app::form::FieldKind;
300 (0..EXPECTED_ROWS.len())
301 .map(|row| {
302 let kind = if row == 0 {
303 FieldKind::Choice
304 } else {
305 FieldKind::Text
306 };
307 (row, kind)
308 })
309 .collect()
310 }
311
312 fn focused(&self) -> usize {
313 self.field
314 }
315
316 fn set_focused(&mut self, field: usize) {
317 self.field = field;
318 self.sync_focus();
319 }
320}
321
322pub fn set_time_format(plan: &mut DataQualityPlan, column: &str, format: Option<(TimeKind, &str)>) {
325 plan.time_formats
326 .retain(|interpretation| interpretation.column != column);
327 match format {
328 Some((kind, format)) => plan.time_formats.push(TimeInterpretation {
329 column: column.to_string(),
330 kind,
331 format: format.to_string(),
332 }),
333 None => {
334 if matches!(&plan.grain, QualityGrain::TimeWindows { column: on, .. } if on == column) {
335 plan.grain = QualityGrain::Dataset;
336 plan.baseline_segment = None;
337 }
338 }
339 }
340}
341
342#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
343pub enum AnalysisView {
344 #[default]
345 Main, DistributionDetail, CorrelationDetail, }
349
350#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
351pub enum AnalysisTool {
352 #[default]
353 Describe, DistributionAnalysis, CorrelationMatrix, DataQuality, }
358
359impl AnalysisTool {
360 pub const ALL: [Self; 4] = [
362 Self::Describe,
363 Self::DistributionAnalysis,
364 Self::CorrelationMatrix,
365 Self::DataQuality,
366 ];
367
368 pub fn index(self) -> usize {
370 Self::ALL.iter().position(|tool| *tool == self).unwrap_or(0)
371 }
372}
373
374#[derive(Debug, Clone)]
376pub struct AnalysisProgress {
377 pub phase: String,
378 pub started: std::time::Instant,
381 pub reads_source: Option<bool>,
384 pub interruptible: Option<bool>,
387 pub read: Option<crate::analysis::sampling::ReadWatch>,
389 pub reuse: Option<String>,
391}
392
393impl AnalysisProgress {
394 pub fn new(phase: &str) -> Self {
395 Self {
396 phase: phase.to_string(),
397 started: std::time::Instant::now(),
398 reads_source: None,
399 interruptible: None,
400 read: None,
401 reuse: None,
402 }
403 }
404
405 pub fn read_runs_out(&self) -> bool {
408 self.reads_source == Some(true) && self.interruptible == Some(false)
409 }
410}
411
412#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
413pub enum AnalysisFocus {
414 #[default]
415 Main, Sidebar, DistributionSelector, }
419
420#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
422pub struct DetailScroll {
423 pub offset: u16,
424 pub max: u16,
425}
426
427#[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 scroll_position: usize,
450 pub selected_column: Option<usize>,
451 pub describe_columns: ColumnScroll,
452 pub distribution_columns: ColumnScroll,
453 pub correlation_columns: ColumnScroll,
455 pub sample: crate::analysis::sampling::Sample,
458 pub sample_dataset: Option<u64>,
461 pub sample_run_for: Option<u64>,
464 pub sample_form: Option<crate::analysis::sample_modal::SampleForm>,
466 pub own_sample: Option<crate::analysis::sampling::Sample>,
469 pub table_state: TableState, pub distribution_table_state: TableState, pub correlation_table_state: TableState, pub sidebar_state: TableState, pub describe_results: Option<AnalysisResults>,
475 pub distribution_results: Option<AnalysisResults>,
476 pub correlation_results: Option<AnalysisResults>,
477 pub computing: Option<AnalysisProgress>,
479 pub view: AnalysisView,
480 pub focus: AnalysisFocus,
481 pub selected_tool: Option<AnalysisTool>,
483 pub selected_distribution: Option<usize>, pub selected_correlation: Option<(usize, usize)>, pub correlation_method: crate::analysis::statistics::CorrelationMethod,
487 pub selected_theoretical_distribution: DistributionType, pub distribution_selector_state: TableState, pub histogram_scale: HistogramScale,
490 pub quality: QualityState, pub results_view: Option<u64>,
494 kept: Option<Kept>,
497}
498
499#[derive(Default)]
502pub struct QualityState {
503 pub results: Option<DataQualityResults>,
504 pub page: QualityPage,
505 pub plan: DataQualityPlan,
507 pub setup_before: Option<DataQualityPlan>,
509 pub setup_return: QualityPage,
511 pub setup_note: Option<String>,
513 pub table_state: TableState,
514 pub picker: Option<PlanPicker>,
516 pub plan_field: usize,
518 pub show_access: bool,
519 pub observation_detail: bool,
520 pub detail_scroll: DetailScroll,
522 pub segment_index: usize,
524 pub segments_by_change: bool,
526 pub checks_expanded: bool,
528 pub plan_before_edit: Option<DataQualityPlan>,
529 pub last_plan: Option<DataQualityPlan>,
530 pub from_cache: bool,
531 pub metric: QualityMetric,
532 pub column_index: usize,
533 pub interval_index: usize,
535 pub findings: FindingsView,
537 pub evidence_read: Option<EvidenceRead>,
539 pub trend_line: usize,
541 pub expected_form: Option<ExpectedForm>,
543 pub intent_form: Option<crate::analysis::intent_modal::IntentForm>,
545 pub export: Option<crate::analysis::quality_export::ExportForm>,
547}
548
549impl QualityState {
550 pub(crate) fn reset_for_dataset(&mut self) {
553 self.findings = FindingsView::default();
554 self.evidence_read = None;
555 }
556
557 fn close(&mut self) {
559 *self = Self {
560 plan: std::mem::take(&mut self.plan),
561 segments_by_change: self.segments_by_change,
562 ..Self::default()
563 };
564 }
565}
566
567struct Kept {
569 view: Option<u64>,
570 tool: Option<AnalysisTool>,
571 describe: Option<AnalysisResults>,
572 distribution: Option<AnalysisResults>,
573 correlation: Option<AnalysisResults>,
574 row: Option<usize>,
575 distribution_row: Option<usize>,
576 cell: Option<(usize, usize)>,
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
580pub enum HistogramScale {
581 #[default]
582 Linear,
583 Log,
584}
585
586impl AnalysisModal {
587 pub fn with_sample_rows(rows: usize) -> Self {
590 let mut modal = Self::default();
591 modal.sample.seed = crate::analysis::sample_modal::new_seed();
592 if rows == 0 {
593 modal.sample.method = crate::analysis::sampling::SampleMethod::EveryRow;
594 } else {
595 modal.sample.rows = rows;
596 }
597 modal
598 }
599
600 pub fn open(&mut self, view: Option<u64>) {
603 let kept = self
604 .kept
605 .take()
606 .filter(|kept| view.is_some() && kept.view == view);
607 self.results_view = view;
608 self.scroll_position = 0;
609 self.selected_column = None;
610 self.describe_columns = ColumnScroll::default();
611 self.distribution_columns = ColumnScroll::default();
612 self.correlation_columns = ColumnScroll::default();
613 self.table_state.select(Some(0));
614 self.distribution_table_state.select(Some(0));
615 self.correlation_table_state.select(Some(0));
616 self.sidebar_state.select(Some(0)); self.view = AnalysisView::Main;
618 self.focus = AnalysisFocus::Sidebar; self.selected_tool = None; self.selected_distribution = Some(0);
621 self.selected_correlation = None;
622 self.computing = None;
623 self.describe_results = None;
624 self.distribution_results = None;
625 self.correlation_results = None;
626 self.quality.close();
627 self.sample_form = None;
628 if let Some(kept) = kept {
629 self.describe_results = kept.describe;
630 self.distribution_results = kept.distribution;
631 self.correlation_results = kept.correlation;
632 self.selected_tool = kept.tool;
633 if self.current_results().is_none() {
634 self.selected_tool = None;
635 }
636 if let Some(tool) = self.selected_tool {
637 self.sidebar_state.select(Some(tool.index()));
638 }
639 self.table_state.select(kept.row.or(Some(0)));
640 self.distribution_table_state
641 .select(kept.distribution_row.or(Some(0)));
642 self.selected_distribution = kept.distribution_row.or(Some(0));
643 if let Some(cell) = kept.cell {
644 self.selected_correlation = Some(cell);
645 self.correlation_table_state.select(Some(cell.0));
646 }
647 }
648 if self.correlation_results.is_none() {
649 self.selected_correlation = None;
650 }
651 }
652
653 pub fn close(&mut self) {
655 self.kept = Some(Kept {
656 view: self.results_view.take(),
657 tool: self
658 .selected_tool
659 .filter(|tool| *tool != AnalysisTool::DataQuality),
660 describe: self.describe_results.take(),
661 distribution: self.distribution_results.take(),
662 correlation: self.correlation_results.take(),
663 row: self.table_state.selected(),
664 distribution_row: self.distribution_table_state.selected(),
665 cell: self.selected_correlation,
666 });
667 self.scroll_position = 0;
668 self.selected_column = None;
669 self.describe_columns = ColumnScroll::default();
670 self.distribution_columns = ColumnScroll::default();
671 self.correlation_columns = ColumnScroll::default();
672 self.view = AnalysisView::Main;
673 self.focus = AnalysisFocus::Main;
674 self.selected_tool = None;
675 self.selected_distribution = None;
676 self.selected_correlation = None;
677 self.computing = None;
678 self.describe_results = None;
679 self.distribution_results = None;
680 self.correlation_results = None;
681 self.quality.results = None;
682 self.quality.page = QualityPage::Setup;
683 self.quality.setup_before = None;
684 self.quality.setup_note = None;
685 self.quality.picker = None;
686 self.quality.show_access = false;
687 self.quality.observation_detail = false;
688 self.quality.plan_before_edit = None;
689 self.quality.last_plan = None;
690 self.quality.from_cache = false;
691 self.quality.metric = QualityMetric::NullRate;
692 self.quality.column_index = 0;
693 self.quality.interval_index = 0;
694 self.quality.trend_line = 0;
695 self.quality.expected_form = None;
696 self.quality.intent_form = None;
697 self.quality.export = None;
698 }
699
700 pub fn current_results(&self) -> Option<&AnalysisResults> {
702 match self.selected_tool {
703 Some(AnalysisTool::Describe) => self.describe_results.as_ref(),
704 Some(AnalysisTool::DistributionAnalysis) => self.distribution_results.as_ref(),
705 Some(AnalysisTool::CorrelationMatrix) => self.correlation_results.as_ref(),
706 Some(AnalysisTool::DataQuality) => None,
707 None => None,
708 }
709 }
710
711 pub fn switch_focus(&mut self) {
714 self.focus = match self.focus {
715 AnalysisFocus::Sidebar if self.selected_tool.is_some() => AnalysisFocus::Main,
716 _ => AnalysisFocus::Sidebar,
717 };
718 }
719
720 pub fn highlighted_tool(&self) -> Option<AnalysisTool> {
722 AnalysisTool::ALL
723 .get(self.sidebar_state.selected()?)
724 .copied()
725 }
726
727 pub fn select_tool(&mut self) {
729 if self.sidebar_state.selected().is_some() {
730 self.selected_tool = Some(self.highlighted_tool().unwrap_or_default());
731 }
732 }
733
734 pub fn next_tool(&mut self) {
735 if let Some(current) = self.sidebar_state.selected() {
736 let next = (current + 1).min(AnalysisTool::ALL.len() - 1);
737 self.sidebar_state.select(Some(next));
738 }
739 }
740
741 pub fn previous_tool(&mut self) {
742 if let Some(current) = self.sidebar_state.selected()
743 && current > 0
744 {
745 self.sidebar_state.select(Some(current - 1));
746 }
747 }
748
749 pub fn open_distribution_detail(&mut self) {
750 if self.focus == AnalysisFocus::Main
751 && self.selected_tool == Some(AnalysisTool::DistributionAnalysis)
752 && let Some(idx) = self.distribution_table_state.selected()
753 {
754 if let Some(results) = &self.distribution_results
755 && let Some(dist_analysis) = results.distribution_analyses.get(idx)
756 {
757 self.selected_theoretical_distribution = dist_analysis.distribution_type;
758 }
759 self.view = AnalysisView::DistributionDetail;
760 self.focus = AnalysisFocus::DistributionSelector;
761 if self.selected_theoretical_distribution == DistributionType::Unknown {
762 self.selected_theoretical_distribution = DistributionType::Normal;
763 }
764 self.distribution_selector_state.select(None);
765 }
766 }
767
768 pub fn open_correlation_detail(&mut self) {
769 if self.focus == AnalysisFocus::Main
770 && self.selected_tool == Some(AnalysisTool::CorrelationMatrix)
771 && let Some((row, col)) = self.selected_correlation
772 && row != col
773 {
774 self.view = AnalysisView::CorrelationDetail;
775 }
776 }
777
778 pub fn close_detail(&mut self) {
779 self.view = AnalysisView::Main;
780 self.focus = AnalysisFocus::Main;
781 }
782
783 pub fn scroll_left(&mut self) {
784 if let Some(columns) = self.column_scroll_mut() {
785 columns.left();
786 }
787 }
788
789 pub fn scroll_right(&mut self) {
790 if let Some(columns) = self.column_scroll_mut() {
791 columns.right();
792 }
793 }
794
795 pub fn column_scroll(&self) -> Option<&ColumnScroll> {
797 match self.selected_tool {
798 Some(AnalysisTool::Describe) => Some(&self.describe_columns),
799 Some(AnalysisTool::DistributionAnalysis) => Some(&self.distribution_columns),
800 _ => None,
801 }
802 }
803
804 fn column_scroll_mut(&mut self) -> Option<&mut ColumnScroll> {
805 match self.selected_tool {
806 Some(AnalysisTool::Describe) => Some(&mut self.describe_columns),
807 Some(AnalysisTool::DistributionAnalysis) => Some(&mut self.distribution_columns),
808 _ => None,
809 }
810 }
811
812 pub fn follow_view_sample(&mut self, sampled: bool) {
815 match (sampled, self.own_sample.is_some()) {
816 (true, false) => {
817 let every = crate::analysis::sampling::Sample {
818 scope: crate::analysis::data_quality::QualityScope::CurrentView,
819 method: crate::analysis::sampling::SampleMethod::EveryRow,
820 ..self.sample.clone()
821 };
822 self.own_sample = Some(std::mem::replace(&mut self.sample, every));
823 }
824 (false, true) => {
825 if let Some(own) = self.own_sample.take() {
826 self.sample = own;
827 }
828 }
829 _ => {}
830 }
831 }
832
833 pub fn quality_row_count(&self) -> usize {
834 let Some(results) = self.quality.results.as_ref() else {
835 return 0;
836 };
837 match self.quality.page {
838 QualityPage::Setup => SetupRow::ALL.len(),
839 QualityPage::TimeRoles => TemporalRole::ALL.len(),
840 QualityPage::IntervalPairs => self.quality.plan.candidate_pairs().len(),
841 QualityPage::Intent => 0,
843 QualityPage::Intervals => results.temporal.len(),
844 QualityPage::IntervalDetail => self.interval_facts().len(),
845 QualityPage::Overview => self.quality.findings.shown(results.report()).len(),
846 QualityPage::Columns | QualityPage::Detail => results.columns.len(),
847 QualityPage::Segments => results.segments.len(),
848 QualityPage::SegmentDetail => {
849 crate::analysis::data_quality::segment_changes(results, self.quality.segment_index)
850 .len()
851 }
852 QualityPage::Trends => {
854 crate::analysis::quality_trends::trend_view(results, self.quality.metric, 1)
855 .lines
856 .len()
857 }
858 QualityPage::TrendDetail => crate::analysis::quality_trends::trend_slots(results).len(),
861 QualityPage::Gaps => {
862 match crate::analysis::quality_trends::expected_gaps(
863 self.quality_result_plan(),
864 results,
865 ) {
866 Some(crate::analysis::quality_trends::Gaps::Checked(check)) => check.runs.len(),
867 _ => 0,
868 }
869 }
870 QualityPage::ExpectedWindows => EXPECTED_ROWS.len(),
871 }
872 }
873
874 pub fn quality_selected_is_clean(&self) -> bool {
877 self.quality.page == QualityPage::Overview
878 && self
879 .selected_finding()
880 .is_some_and(|(_, finding)| finding.kind.is_none())
881 }
882
883 pub fn selected_finding(&self) -> Option<(&QualityReport, Finding)> {
886 let report = self.quality.results.as_ref()?.report();
887 let finding = self
888 .quality
889 .findings
890 .selected(report, self.quality.table_state.selected()?)?
891 .clone();
892 Some((report, finding))
893 }
894
895 pub fn open_findings_picker(&mut self, by_column: bool) {
898 let Some(results) = self.quality.results.as_ref() else {
899 return;
900 };
901 let report = results.report();
902 let view = &self.quality.findings;
903 let findings = |count: usize| match count {
904 0 => "none".to_string(),
905 1 => "1 finding".to_string(),
906 count => format!("{} findings", crate::numfmt::group_chrome(count)),
907 };
908 let (title, choices, current) = if by_column {
909 let columns = crate::analysis::quality_report::column_choices(report, results);
910 let width = columns
911 .iter()
912 .map(|(name, _)| crate::glyphs::display_width(name))
913 .max()
914 .unwrap_or(0);
915 let current = view
916 .column
917 .as_ref()
918 .and_then(|column| columns.iter().position(|(name, _)| name == column))
919 .map_or(0, |position| position + 1);
920 let mut choices = vec![("All columns".to_string(), PlanChoice::FindingColumn(None))];
921 choices.extend(columns.into_iter().map(|(name, count)| {
922 let pad = width.saturating_sub(crate::glyphs::display_width(&name));
923 (
924 format!("{name}{} {}", " ".repeat(pad), findings(count)),
925 PlanChoice::FindingColumn(Some(name)),
926 )
927 }));
928 ("Findings by Column", choices, current)
929 } else {
930 let checks = crate::analysis::quality_report::check_choices(report);
931 let width = checks
932 .iter()
933 .map(|(name, _)| crate::glyphs::display_width(name))
934 .max()
935 .unwrap_or(0);
936 let current = view
937 .check
938 .and_then(|check| checks.iter().position(|(name, _)| *name == check))
939 .map_or(0, |position| position + 1);
940 let mut choices = vec![("All types".to_string(), PlanChoice::FindingCheck(None))];
941 choices.extend(checks.into_iter().map(|(name, count)| {
942 let pad = width.saturating_sub(crate::glyphs::display_width(name));
943 (
944 format!("{name}{} {}", " ".repeat(pad), findings(count)),
945 PlanChoice::FindingCheck(Some(name)),
946 )
947 }));
948 ("Findings by Type", choices, current)
949 };
950 let (labels, choices): (Vec<_>, Vec<_>) = choices.into_iter().unzip();
951 let mut state = crate::widgets::ui::PickerState::new(labels);
952 state.select_original(current);
953 self.quality.picker = Some(PlanPicker {
954 title: title.to_string(),
955 choices,
956 state,
957 });
958 }
959
960 pub fn cycle_findings_order(&mut self) {
962 let selected = self.selected_finding().map(|(_, finding)| finding);
963 self.quality.findings.order = self.quality.findings.order.next();
964 self.reselect_finding(selected);
965 }
966
967 pub fn clear_findings_narrowing(&mut self) {
969 let selected = self.selected_finding().map(|(_, finding)| finding);
970 self.quality.findings.column = None;
971 self.quality.findings.check = None;
972 self.reselect_finding(selected);
973 }
974
975 fn reselect_finding(&mut self, finding: Option<Finding>) {
977 let position = self.quality.results.as_ref().and_then(|results| {
978 let report = results.report();
979 let finding = finding?;
980 let shown = self.quality.findings.shown(report);
981 shown.iter().position(|index| {
982 let listed = &report.findings[*index];
983 listed.same_as(&finding)
984 })
985 });
986 self.quality.table_state.select(Some(position.unwrap_or(0)));
987 *self.quality.table_state.offset_mut() = 0;
988 }
989
990 pub fn sample_key_opens_form(&self) -> bool {
993 self.view == AnalysisView::Main
994 && self.selected_tool.is_some()
995 && self.computing.is_none()
996 && self.quality.picker.is_none()
997 && !matches!(
998 self.quality.page,
999 QualityPage::TimeRoles
1000 | QualityPage::IntervalPairs
1001 | QualityPage::ExpectedWindows
1002 | QualityPage::Intent
1003 )
1004 && !self.quality.show_access
1005 && !self.quality.observation_detail
1006 && self.quality.evidence_read.is_none()
1007 && self.quality.export.is_none()
1008 && self.quality.intent_form.is_none()
1009 && self.sample_form.is_none()
1010 }
1011
1012 pub fn export_typing(&self) -> bool {
1014 self.quality
1015 .export
1016 .as_ref()
1017 .is_some_and(|form| !form.on_format)
1018 }
1019
1020 pub fn intent_typing(&self) -> bool {
1022 self.quality
1023 .intent_form
1024 .as_ref()
1025 .is_some_and(crate::analysis::intent_modal::IntentForm::typing)
1026 }
1027
1028 pub fn sample_scope_typing(&self) -> bool {
1031 self.sample_form.as_ref().is_some_and(|form| {
1032 form.field.is_text() && (!form.inline || self.focus == AnalysisFocus::Main)
1033 })
1034 }
1035
1036 pub fn quality_expected_typing(&self) -> bool {
1039 self.quality.page == QualityPage::ExpectedWindows
1040 && self
1041 .quality
1042 .expected_form
1043 .as_ref()
1044 .is_some_and(ExpectedForm::typing)
1045 }
1046
1047 pub fn open_trend_detail(&mut self) {
1049 let line = self.quality.table_state.selected().unwrap_or(0);
1050 self.quality.trend_line = line;
1051 self.set_quality_page(QualityPage::TrendDetail);
1052 }
1053
1054 pub fn cycle_trend_detail_metric(&mut self) {
1057 let Some(results) = self.quality.results.as_ref() else {
1058 return;
1059 };
1060 let view = crate::analysis::quality_trends::trend_view(results, self.quality.metric, 1);
1061 let line = view
1062 .lines
1063 .get(self.quality.trend_line)
1064 .map(|line| (line.measure, line.names.clone()));
1065 self.cycle_quality_metric();
1066 let Some(results) = self.quality.results.as_ref() else {
1067 return;
1068 };
1069 let view = crate::analysis::quality_trends::trend_view(results, self.quality.metric, 1);
1070 let found = line.and_then(|(measure, names)| {
1071 view.lines.iter().position(|candidate| {
1072 candidate.measure == measure
1073 || candidate
1074 .names
1075 .iter()
1076 .any(|name| !candidate.rows() && names.contains(name))
1077 })
1078 });
1079 match found {
1080 Some(index) => self.quality.trend_line = index,
1081 None => {
1082 self.quality.trend_line = 0;
1083 self.close_to_trends();
1084 }
1085 }
1086 }
1087
1088 pub fn close_to_trends(&mut self) {
1090 let line =
1091 (self.quality.page == QualityPage::TrendDetail).then_some(self.quality.trend_line);
1092 self.set_quality_page(QualityPage::Trends);
1093 self.quality.table_state.select(Some(line.unwrap_or(0)));
1094 }
1095
1096 pub fn set_quality_page(&mut self, page: QualityPage) {
1097 self.quality.page = page;
1098 self.quality.observation_detail = false;
1099 self.quality.evidence_read = None;
1100 self.quality.table_state.select(Some(0));
1101 }
1102
1103 pub fn set_quality_column_page(&mut self, page: QualityPage) {
1105 if matches!(
1107 self.quality.page,
1108 QualityPage::Columns | QualityPage::Detail
1109 ) {
1110 self.quality.column_index = self.quality.table_state.selected().unwrap_or(0);
1111 }
1112 self.set_quality_page(page);
1113 self.quality
1114 .table_state
1115 .select(Some(self.quality.column_index));
1116 }
1117
1118 pub fn open_segment_detail(&mut self) {
1121 if let Some(segment) = self.selected_segment() {
1122 self.quality.segment_index = segment;
1123 self.set_quality_page(QualityPage::SegmentDetail);
1124 }
1125 }
1126
1127 pub fn close_segment_detail(&mut self) {
1128 self.set_quality_page(QualityPage::Segments);
1129 let position = self
1130 .segment_order()
1131 .iter()
1132 .position(|segment| *segment == self.quality.segment_index);
1133 self.quality.table_state.select(Some(position.unwrap_or(0)));
1134 }
1135
1136 pub fn segment_order(&self) -> Vec<usize> {
1138 self.quality
1139 .results
1140 .as_ref()
1141 .map(|results| {
1142 crate::analysis::data_quality::segment_order(
1143 results,
1144 self.quality.segments_by_change,
1145 )
1146 })
1147 .unwrap_or_default()
1148 }
1149
1150 pub fn selected_segment(&self) -> Option<usize> {
1152 let position = self.quality.table_state.selected()?;
1153 self.segment_order().get(position).copied()
1154 }
1155
1156 pub fn toggle_segment_order(&mut self) {
1159 let segment = self.selected_segment();
1160 self.quality.segments_by_change = !self.quality.segments_by_change;
1161 let position = segment
1162 .and_then(|segment| self.segment_order().iter().position(|s| *s == segment))
1163 .unwrap_or(0);
1164 self.quality.table_state.select(Some(position));
1165 }
1166
1167 pub fn open_interval_detail(&mut self) {
1169 let Some(index) = self.quality.table_state.selected() else {
1170 return;
1171 };
1172 if self
1173 .quality
1174 .results
1175 .as_ref()
1176 .is_some_and(|results| index < results.temporal.len())
1177 {
1178 self.quality.interval_index = index;
1179 self.set_quality_page(QualityPage::IntervalDetail);
1180 }
1181 }
1182
1183 pub fn close_interval_detail(&mut self) {
1185 self.set_quality_page(QualityPage::Intervals);
1186 self.quality
1187 .table_state
1188 .select(Some(self.quality.interval_index));
1189 }
1190
1191 pub fn interval_facts(&self) -> Vec<IntervalFact> {
1194 let plan = self.quality_result_plan();
1195 self.quality
1196 .results
1197 .as_ref()
1198 .and_then(|results| results.temporal.get(self.quality.interval_index))
1199 .map(|profile| {
1200 IntervalFact::ALL
1201 .into_iter()
1202 .filter(|fact| profile.count(*fact, plan).is_some())
1203 .collect()
1204 })
1205 .unwrap_or_default()
1206 }
1207
1208 pub fn selected_interval_fact(&self) -> Option<IntervalFact> {
1210 let facts = self.interval_facts();
1211 facts
1212 .get(self.quality.table_state.selected().unwrap_or(0))
1213 .copied()
1214 }
1215
1216 pub fn interval_evidence(
1220 &self,
1221 schema: Option<&polars::prelude::Schema>,
1222 ) -> Option<(polars::prelude::Expr, String, usize)> {
1223 let results = self.quality.results.as_ref()?;
1224 if !matches!(
1225 results.precision,
1226 crate::analysis::data_quality::QualityPrecision::Exact
1227 | crate::analysis::data_quality::QualityPrecision::Sampled
1228 ) {
1229 return None;
1230 }
1231 let plan = self.quality_result_plan();
1232 let profile = results.temporal.get(self.quality.interval_index)?;
1233 let fact = self.selected_interval_fact()?;
1234 let (count, _) = profile.count(fact, plan)?;
1235 if count == 0 {
1236 return None;
1237 }
1238 let predicate = profile.evidence_predicate(fact, plan, schema)?;
1239 Some((
1240 predicate,
1241 format!(
1242 "Data Quality / {} / {} / {}",
1243 profile.label(),
1244 profile.segment,
1245 fact.short()
1246 ),
1247 count,
1248 ))
1249 }
1250
1251 pub fn scroll_quality_detail(&mut self, rows: i32) {
1253 let scroll = &mut self.quality.detail_scroll;
1254 scroll.offset = (scroll.offset as i32 + rows).clamp(0, scroll.max as i32) as u16;
1255 }
1256
1257 pub fn show_quality_tab(&mut self, page: QualityPage) {
1259 if matches!(
1260 self.quality.page,
1261 QualityPage::Columns | QualityPage::Detail
1262 ) {
1263 self.quality.column_index = self.quality.table_state.selected().unwrap_or(0);
1264 }
1265 self.set_quality_page(page);
1266 if page == QualityPage::Columns {
1267 self.quality
1268 .table_state
1269 .select(Some(self.quality.column_index));
1270 }
1271 }
1272
1273 pub fn step_quality_tab(&mut self, forward: bool) {
1275 let tabs = QualityPage::TABS;
1276 let Some(at) = tabs
1277 .iter()
1278 .position(|page| *page == self.quality.page.tab())
1279 else {
1280 return;
1281 };
1282 let next = if forward {
1283 (at + 1).min(tabs.len() - 1)
1284 } else {
1285 at.saturating_sub(1)
1286 };
1287 if next != at {
1288 self.show_quality_tab(tabs[next]);
1289 }
1290 }
1291
1292 pub fn cycle_quality_metric(&mut self) {
1293 let current = QualityMetric::ALL
1294 .iter()
1295 .position(|metric| *metric == self.quality.metric)
1296 .unwrap_or(0);
1297 self.quality.metric = QualityMetric::ALL[(current + 1) % QualityMetric::ALL.len()];
1298 }
1299
1300 pub fn quality_result_plan(&self) -> &DataQualityPlan {
1303 self.quality
1304 .last_plan
1305 .as_ref()
1306 .unwrap_or(&self.quality.plan)
1307 }
1308
1309 pub fn quality_plan_pending(&self) -> bool {
1311 self.quality.results.is_some()
1312 && self.quality.last_plan.as_ref() != Some(&self.quality.plan)
1313 }
1314
1315 pub fn setup_row(&self) -> SetupRow {
1317 SetupRow::at(self.quality.plan_field)
1318 }
1319
1320 pub fn setup_edited(&self) -> bool {
1322 self.quality
1323 .setup_before
1324 .as_ref()
1325 .is_some_and(|before| *before != self.quality.plan)
1326 }
1327
1328 pub fn plan_choices(&self, row: SetupRow, context: &PlanContext) -> Vec<(String, PlanChoice)> {
1330 match row {
1331 SetupRow::Grain => {
1332 let mut grains = vec![QualityGrain::Dataset];
1333 if context.files {
1334 grains.push(QualityGrain::File);
1335 }
1336 grains.extend(
1337 context
1338 .partitions
1339 .iter()
1340 .cloned()
1341 .map(QualityGrain::Partition),
1342 );
1343 for (column, has_time) in &context.time_columns {
1345 for every in QUALITY_WINDOW_WIDTHS {
1346 if every == "1h" && !has_time {
1347 continue;
1348 }
1349 grains.push(QualityGrain::TimeWindows {
1350 column: column.clone(),
1351 every: every.to_string(),
1352 });
1353 }
1354 }
1355 grains.extend([100_000, 1_000_000].map(QualityGrain::RowChunks));
1356 if !grains.contains(&self.quality.plan.grain) {
1357 grains.insert(0, self.quality.plan.grain.clone());
1358 }
1359 grains
1360 .into_iter()
1361 .map(|grain| (grain.label(), PlanChoice::Grain(grain)))
1362 .collect()
1363 }
1364 SetupRow::Values => {
1365 let read = if self.quality.plan.method
1367 == crate::analysis::sampling::SampleMethod::EveryRow
1368 {
1369 QualityCompute::Full
1370 } else {
1371 QualityCompute::Sample
1372 };
1373 vec![
1374 ("read".to_string(), PlanChoice::Values(read)),
1375 (
1376 "file metadata only".to_string(),
1377 PlanChoice::Values(QualityCompute::Metadata),
1378 ),
1379 ]
1380 }
1381 SetupRow::Compare => [
1382 QualityComparison::None,
1383 QualityComparison::Previous,
1384 QualityComparison::Baseline,
1385 ]
1386 .into_iter()
1387 .map(|comparison| {
1388 (
1389 comparison.choice_label().to_string(),
1390 PlanChoice::Compare(comparison),
1391 )
1392 })
1393 .collect(),
1394 SetupRow::WindowBy if !self.quality.plan.windows_intervals() => Vec::new(),
1395 SetupRow::WindowBy => IntervalClock::ALL
1396 .into_iter()
1397 .map(|clock| (clock.label().to_string(), PlanChoice::Clock(clock)))
1398 .collect(),
1399 SetupRow::Latency if self.quality.plan.interval_pairs().is_empty() => Vec::new(),
1400 SetupRow::Latency => [None, Some(3_600), Some(86_400), Some(604_800)]
1401 .into_iter()
1402 .map(|seconds| (threshold_label(seconds), PlanChoice::Latency(seconds)))
1403 .collect(),
1404 SetupRow::TextAsTime => context
1407 .text_columns
1408 .iter()
1409 .map(|(column, examples)| {
1410 let label = match (self.quality.plan.time_format(column), examples.first()) {
1411 (Some(format), _) => format!("{column} as {}", format.label()),
1412 (None, Some(example)) => format!("{column} {example}"),
1413 (None, None) => column.clone(),
1414 };
1415 (label, PlanChoice::TextColumn(column.clone()))
1416 })
1417 .collect(),
1418 SetupRow::Sample
1419 | SetupRow::TimeRoles
1420 | SetupRow::Intervals
1421 | SetupRow::Expected
1422 | SetupRow::Intent => Vec::new(),
1423 }
1424 }
1425
1426 pub fn open_plan_picker(&mut self, row: SetupRow, context: &PlanContext) {
1428 let choices = self.plan_choices(row, context);
1429 self.quality.plan_field = row.index();
1430 self.show_picker(row.label().to_string(), choices);
1431 }
1432
1433 fn show_picker(&mut self, title: String, choices: Vec<(String, PlanChoice)>) {
1434 if choices.is_empty() {
1435 return;
1436 }
1437 let current = choices
1438 .iter()
1439 .position(|(_, choice)| choice.is_current(&self.quality.plan))
1440 .unwrap_or(0);
1441 let (labels, choices): (Vec<_>, Vec<_>) = choices.into_iter().unzip();
1442 let mut state = crate::widgets::ui::PickerState::new(labels);
1443 state.select_original(current);
1444 self.quality.picker = Some(PlanPicker {
1445 title,
1446 choices,
1447 state,
1448 });
1449 }
1450
1451 pub fn open_format_picker(&mut self, column: &str, examples: &[String]) {
1454 let mut formats = TIME_FORMATS
1455 .iter()
1456 .enumerate()
1457 .map(|(order, (kind, format))| {
1458 let interpretation = TimeInterpretation {
1459 column: column.to_string(),
1460 kind: *kind,
1461 format: format.to_string(),
1462 };
1463 let read = examples
1464 .iter()
1465 .filter(|value| interpretation.reads(value))
1466 .count();
1467 (read, order, *kind, *format)
1468 })
1469 .collect::<Vec<_>>();
1470 formats.sort_by_key(|(read, order, _, _)| (std::cmp::Reverse(*read), *order));
1472 let mut choices = formats
1473 .into_iter()
1474 .map(|(read, _, kind, format)| {
1475 let label = if examples.is_empty() {
1477 format!("{} {format}", kind.label())
1478 } else {
1479 format!(
1480 "reads {read} of {} {} {format}",
1481 examples.len(),
1482 kind.label()
1483 )
1484 };
1485 (
1486 label,
1487 PlanChoice::Format(column.to_string(), Some((kind, format))),
1488 )
1489 })
1490 .collect::<Vec<_>>();
1491 if self.quality.plan.time_format(column).is_some() {
1492 choices.push((
1493 "text, not a time".to_string(),
1494 PlanChoice::Format(column.to_string(), None),
1495 ));
1496 }
1497 self.show_picker(format!("Read {column} As"), choices);
1498 }
1499
1500 pub fn choose_plan_picker(&mut self) -> Option<String> {
1503 let picker = self.quality.picker.take()?;
1504 let choice = picker
1505 .state
1506 .selected_original()
1507 .and_then(|index| picker.choices.get(index))?;
1508 let plan = &mut self.quality.plan;
1509 match choice.clone() {
1510 PlanChoice::Grain(grain) => {
1511 if plan.grain != grain {
1512 plan.baseline_segment = None;
1513 }
1514 plan.grain = grain;
1515 }
1516 PlanChoice::Values(compute) => plan.compute = compute,
1517 PlanChoice::Compare(comparison) => {
1518 plan.comparison = comparison;
1519 if comparison != QualityComparison::Baseline {
1520 plan.baseline_segment = None;
1521 }
1522 }
1523 PlanChoice::Latency(seconds) => plan.latency_threshold_seconds = seconds,
1524 PlanChoice::Clock(clock) => plan.interval_clock = clock,
1525 PlanChoice::TextColumn(column) => return Some(column),
1526 PlanChoice::Format(column, format) => set_time_format(plan, &column, format),
1527 PlanChoice::FindingColumn(column) => {
1528 let selected = self.selected_finding().map(|(_, finding)| finding);
1529 self.quality.findings.column = column;
1530 self.reselect_finding(selected);
1531 }
1532 PlanChoice::FindingCheck(check) => {
1533 let selected = self.selected_finding().map(|(_, finding)| finding);
1534 self.quality.findings.check = check;
1535 self.reselect_finding(selected);
1536 }
1537 }
1538 None
1539 }
1540
1541 pub fn cycle_setup_choice(&mut self, row: SetupRow, context: &PlanContext, forward: bool) {
1544 let choices = self.plan_choices(row, context);
1545 if choices.is_empty() || matches!(row, SetupRow::TextAsTime) {
1546 return;
1547 }
1548 let current = choices
1549 .iter()
1550 .position(|(_, choice)| choice.is_current(&self.quality.plan))
1551 .unwrap_or(0);
1552 let next = if forward {
1553 (current + 1).min(choices.len() - 1)
1554 } else {
1555 current.saturating_sub(1)
1556 };
1557 let (labels, choices): (Vec<_>, Vec<_>) = choices.into_iter().unzip();
1558 let mut state = crate::widgets::ui::PickerState::new(labels);
1559 state.select_original(next);
1560 self.quality.picker = Some(PlanPicker {
1561 title: row.label().to_string(),
1562 choices,
1563 state,
1564 });
1565 self.choose_plan_picker();
1566 }
1567
1568 pub fn cycle_quality_time_role(
1569 &mut self,
1570 role_index: usize,
1571 columns: &[String],
1572 forward: bool,
1573 ) {
1574 let Some(role) = TemporalRole::ALL.get(role_index).copied() else {
1575 return;
1576 };
1577 let current = self
1578 .quality
1579 .plan
1580 .temporal_roles
1581 .iter()
1582 .find(|assignment| assignment.role == role)
1583 .and_then(|assignment| columns.iter().position(|name| name == &assignment.column))
1584 .map(|index| index + 1)
1585 .unwrap_or(0);
1586 let choices = columns.len() + 1;
1587 let next = if forward {
1588 (current + 1) % choices
1589 } else if current == 0 {
1590 choices - 1
1591 } else {
1592 current - 1
1593 };
1594 self.quality
1595 .plan
1596 .temporal_roles
1597 .retain(|assignment| assignment.role != role);
1598 if next > 0 {
1599 self.quality
1600 .plan
1601 .temporal_roles
1602 .push(TemporalRoleAssignment {
1603 role,
1604 column: columns[next - 1].clone(),
1605 timezone: None,
1606 });
1607 }
1608 }
1609
1610 pub fn move_row(&mut self, step: crate::app::form::ListMove, rows: usize) {
1614 use crate::app::form::ListMove;
1615 const PAGE: usize = 10;
1616 let to = |at: Option<usize>| match (at, step) {
1617 (Some(at), _) => Some(step.apply(at, rows, PAGE)),
1618 (None, ListMove::Down | ListMove::Home) => Some(0),
1621 (None, ListMove::End) => rows.checked_sub(1),
1622 (None, _) => None,
1623 };
1624 match self.selected_tool {
1625 Some(AnalysisTool::Describe) => {
1626 if let Some(next) = to(self.table_state.selected()) {
1627 self.table_state.select(Some(next));
1628 }
1629 }
1630 Some(AnalysisTool::DistributionAnalysis) => {
1631 if let Some(next) = to(self.distribution_table_state.selected()) {
1632 self.distribution_table_state.select(Some(next));
1633 self.selected_distribution = Some(next);
1634 }
1635 }
1636 Some(AnalysisTool::CorrelationMatrix) => {
1637 let n = self.correlation_size();
1638 let cell = match (step, self.selected_correlation) {
1639 (ListMove::Down, _) => return self.move_correlation_cell((1, 0)),
1640 (ListMove::Home, _) if n > 0 => (0, 1.min(n - 1)),
1641 (ListMove::End, _) if n > 0 => (n - 1, (n - 1).saturating_sub(1)),
1642 (ListMove::Up | ListMove::PageUp | ListMove::PageDown, Some((row, col))) => {
1643 (step.apply(row, rows, PAGE), col)
1644 }
1645 _ => return,
1646 };
1647 self.selected_correlation = Some(cell);
1648 self.correlation_table_state.select(Some(cell.0));
1649 }
1650 Some(AnalysisTool::DataQuality) => {
1651 let at = self.quality.table_state.selected().unwrap_or(0);
1652 self.quality
1653 .table_state
1654 .select(Some(step.apply(at, rows, PAGE)));
1655 }
1656 None => {}
1657 }
1658 }
1659
1660 pub fn install_correlations(&mut self, results: AnalysisResults) {
1663 let n = results
1664 .correlation_matrix
1665 .as_ref()
1666 .map_or(0, |matrix| matrix.columns.len());
1667 self.correlation_results = Some(results);
1668 let cell = match self.selected_correlation {
1669 Some((row, col)) if row < n && col < n => (row, col),
1670 _ if n >= 2 => (0, 1),
1671 _ => (0, 0),
1672 };
1673 self.selected_correlation = Some(cell);
1674 self.correlation_table_state.select(Some(cell.0));
1675 }
1676
1677 pub fn correlation_size(&self) -> usize {
1679 self.correlation_results
1680 .as_ref()
1681 .and_then(|results| results.correlation_matrix.as_ref())
1682 .map_or(0, |matrix| matrix.columns.len())
1683 }
1684
1685 pub fn distribution_choices(&self) -> usize {
1688 let row = self.distribution_table_state.selected().unwrap_or(0);
1689 self.distribution_results
1690 .as_ref()
1691 .and_then(|results| results.distribution_analyses.get(row))
1692 .map_or(0, |analysis| {
1693 crate::analysis::distribution_fit::listing_order(&analysis.fits).len()
1694 })
1695 }
1696
1697 pub fn move_correlation_cell(&mut self, (rows, cols): (isize, isize)) {
1700 let n = self.correlation_size();
1701 if n == 0 {
1702 return;
1703 }
1704 if let Some((row, col)) = self.selected_correlation {
1705 let row = row.saturating_add_signed(rows).min(n - 1);
1706 let col = col.saturating_add_signed(cols).min(n - 1);
1707 self.selected_correlation = Some((row, col));
1708 self.correlation_table_state.select(Some(row));
1709 }
1710 }
1711
1712 pub fn next_distribution(&mut self) {
1713 let max_idx = self.distribution_choices().saturating_sub(1);
1714
1715 if let Some(current) = self.distribution_selector_state.selected() {
1716 let next = (current + 1).min(max_idx);
1717 self.distribution_selector_state.select(Some(next));
1718 self.select_distribution();
1719 } else {
1720 self.distribution_selector_state.select(Some(0));
1721 self.select_distribution();
1722 }
1723 }
1724
1725 pub fn previous_distribution(&mut self) {
1726 if let Some(current) = self.distribution_selector_state.selected() {
1727 if current > 0 {
1728 self.distribution_selector_state.select(Some(current - 1));
1729 self.select_distribution();
1730 }
1731 } else {
1732 self.distribution_selector_state.select(Some(0));
1733 self.select_distribution();
1734 }
1735 }
1736
1737 pub fn select_distribution(&mut self) {
1738 if let Some(idx) = self.distribution_selector_state.selected()
1739 && let Some(results) = &self.distribution_results
1740 {
1741 let dist_analysis_idx = self.distribution_table_state.selected().unwrap_or(0);
1742 if let Some(dist_analysis) = results.distribution_analyses.get(dist_analysis_idx) {
1743 let distribution_scores =
1745 crate::analysis::distribution_fit::listing_order(&dist_analysis.fits);
1746 let valid_idx = idx.min(distribution_scores.len().saturating_sub(1));
1747 if let Some(dist_type) = distribution_scores.get(valid_idx) {
1748 self.selected_theoretical_distribution = *dist_type;
1749 if idx != valid_idx {
1750 self.distribution_selector_state.select(Some(valid_idx));
1751 }
1752 }
1753 }
1754 }
1755 }
1756}
1757
1758#[cfg(test)]
1759mod quality_scope_tests {
1760 use super::*;
1761
1762 #[test]
1765 fn expected_weekdays_read_as_every_window_on_weeks() {
1766 let theme =
1767 crate::config::Theme::from_config(&crate::config::ThemeConfig::default()).unwrap();
1768 let mut plan = DataQualityPlan {
1769 grain: QualityGrain::TimeWindows {
1770 column: "day".to_string(),
1771 every: "1d".to_string(),
1772 },
1773 expected: Some(crate::analysis::data_quality::ExpectedWindows {
1774 weekdays: true,
1775 ..Default::default()
1776 }),
1777 ..DataQualityPlan::default()
1778 };
1779 assert_eq!(
1780 ExpectedForm::new(&plan, &theme).cadence,
1781 ExpectedCadence::Weekdays
1782 );
1783 plan.grain = QualityGrain::TimeWindows {
1784 column: "day".to_string(),
1785 every: "1w".to_string(),
1786 };
1787 let form = ExpectedForm::new(&plan, &theme);
1788 assert_eq!(form.cadence, ExpectedCadence::Every);
1789 assert_eq!(form.cadence_label("1w"), "every week");
1790 assert!(!form.expected().unwrap().unwrap().weekdays);
1791 }
1792
1793 #[test]
1796 fn grain_choices_come_from_the_data() {
1797 let mut modal = AnalysisModal::default();
1798 let context = PlanContext {
1799 partitions: vec!["year".to_string()],
1800 time_columns: vec![("date".to_string(), false), ("stamp".to_string(), true)],
1801 files: true,
1802 text_columns: Vec::new(),
1803 };
1804 let labels = modal
1805 .plan_choices(SetupRow::Grain, &context)
1806 .into_iter()
1807 .map(|(label, _)| label)
1808 .collect::<Vec<_>>();
1809 assert_eq!(
1810 labels,
1811 [
1812 "whole dataset",
1813 "by file",
1814 "by year",
1815 "by day of date",
1816 "by week of date",
1817 "by month of date",
1818 "by hour of stamp",
1819 "by day of stamp",
1820 "by week of stamp",
1821 "by month of stamp",
1822 "in chunks of 100,000 rows",
1823 "in chunks of 1,000,000 rows",
1824 ]
1825 );
1826 modal.open_plan_picker(SetupRow::Grain, &context);
1828 let picker = modal.quality.picker.as_mut().unwrap();
1829 assert_eq!(picker.state.selected_original(), Some(0));
1830 picker.state.move_down();
1831 picker.state.move_down();
1832 picker.state.move_down();
1833 modal.choose_plan_picker();
1834 assert!(modal.quality.picker.is_none());
1835 assert_eq!(
1836 modal.quality.plan.grain,
1837 QualityGrain::TimeWindows {
1838 column: "date".to_string(),
1839 every: "1d".to_string()
1840 }
1841 );
1842 }
1843
1844 #[test]
1847 fn a_read_is_the_samples_kind() {
1848 let mut modal = AnalysisModal::default();
1849 let context = PlanContext::default();
1850 modal.quality.plan.compute = QualityCompute::Metadata;
1851 modal.open_plan_picker(SetupRow::Values, &context);
1852 modal
1853 .quality
1854 .picker
1855 .as_mut()
1856 .unwrap()
1857 .state
1858 .select_original(0);
1859 modal.choose_plan_picker();
1860 assert_eq!(modal.quality.plan.compute, QualityCompute::Sample);
1861 modal.quality.plan.method = crate::analysis::sampling::SampleMethod::EveryRow;
1862 modal.open_plan_picker(SetupRow::Values, &context);
1863 modal.choose_plan_picker();
1864 assert_eq!(modal.quality.plan.compute, QualityCompute::Full);
1865 modal.open_plan_picker(SetupRow::Latency, &context);
1866 assert!(
1867 modal.quality.picker.is_none(),
1868 "no threshold without an interval to measure"
1869 );
1870 }
1871
1872 #[test]
1876 fn text_is_read_as_time_through_a_chosen_format() {
1877 let mut modal = AnalysisModal::default();
1878 let context = PlanContext {
1879 text_columns: vec![(
1880 "created".to_string(),
1881 vec!["2024-01-31 08:15:00".to_string()],
1882 )],
1883 ..PlanContext::default()
1884 };
1885 modal.open_plan_picker(SetupRow::TextAsTime, &context);
1886 assert_eq!(modal.choose_plan_picker(), Some("created".to_string()));
1887 modal.open_format_picker("created", &["2024-01-31 08:15:00".to_string()]);
1888 let picker = modal.quality.picker.as_ref().unwrap();
1889 assert_eq!(picker.title, "Read created As");
1890 let first = picker.state.filtered()[0].1.to_string();
1891 assert_eq!(first, "reads 1 of 1 datetime %Y-%m-%d %H:%M:%S");
1892 assert_eq!(modal.choose_plan_picker(), None);
1893 let format = modal.quality.plan.time_format("created").unwrap();
1894 assert_eq!(format.kind, TimeKind::Datetime);
1895
1896 let windows = PlanContext {
1898 time_columns: vec![("created".to_string(), true)],
1899 ..context.clone()
1900 };
1901 modal.open_plan_picker(SetupRow::Grain, &windows);
1902 let picker = modal.quality.picker.as_mut().unwrap();
1903 picker.state.move_down();
1904 picker.state.move_down();
1905 modal.choose_plan_picker();
1906 assert!(matches!(
1907 modal.quality.plan.grain,
1908 QualityGrain::TimeWindows { .. }
1909 ));
1910 modal.open_format_picker("created", &[]);
1911 let picker = modal.quality.picker.as_mut().unwrap();
1912 let text = picker
1913 .state
1914 .filtered()
1915 .iter()
1916 .position(|(_, label)| *label == "text, not a time")
1917 .unwrap();
1918 for _ in 0..text {
1919 picker.state.move_down();
1920 }
1921 modal.choose_plan_picker();
1922 assert!(modal.quality.plan.time_formats.is_empty());
1923 assert_eq!(modal.quality.plan.grain, QualityGrain::Dataset);
1924 }
1925}