Skip to main content

datui_lib/widgets/
quality_intent.rs

1//! Column intent in Data Quality Setup: the list of the scope's columns with what
2//! each is declared to hold, and the form that declares it for one.
3
4use crate::glyphs;
5use crate::intent_modal::{IntentField, IntentForm};
6use crate::numfmt;
7use crate::widgets::data_quality::{
8    DataQualityWidgetConfig, centered_rect, dotted, fit, rule_line,
9};
10use crate::widgets::ui::{FormRow, FormValue, Surface};
11use polars::prelude::DataType;
12use ratatui::buffer::Buffer;
13use ratatui::layout::{Constraint, Direction, Layout, Rect};
14use ratatui::style::Style;
15use ratatui::text::{Line, Span};
16use ratatui::widgets::{Cell, Paragraph, Row, StatefulWidget, Table, TableState, Widget};
17
18/// The scope's columns Setup can declare intent on: the schema it reads, less
19/// datui's own bookkeeping.
20pub fn intent_columns(schema: &polars::prelude::Schema) -> Vec<(String, DataType)> {
21    schema
22        .iter()
23        .filter(|(name, _)| name.as_str() != crate::schema_union::DRIFT_COLUMN)
24        .map(|(name, dtype)| (name.to_string(), dtype.clone()))
25        .collect()
26}
27
28/// A column's type as the list names it: text read as time says how.
29fn type_label(config: &DataQualityWidgetConfig<'_>, column: &str, dtype: &DataType) -> String {
30    match config.plan.time_format(column) {
31        Some(format) => format!("text as {}", format.kind.label()),
32        None => crate::widgets::datatable::dtype_label(dtype),
33    }
34}
35
36/// Every column of the scope, each with its declared rules; the key named under it.
37pub fn render_list(
38    config: &DataQualityWidgetConfig<'_>,
39    table_state: &mut TableState,
40    area: Rect,
41    buf: &mut Buffer,
42) {
43    let theme = config.theme;
44    let plan = config.plan;
45    let dimmed = Style::default().fg(theme.get("dimmed"));
46    let columns = intent_columns(config.state.quality_schema(&plan.scope));
47    let [title, body, key] = Layout::default()
48        .direction(Direction::Vertical)
49        .constraints([
50            Constraint::Length(2),
51            Constraint::Fill(1),
52            Constraint::Length(2),
53        ])
54        .margin(1)
55        .areas(area);
56    let declared = plan.intent.declared_columns().len();
57    Paragraph::new(rule_line(
58        "Column intent",
59        (declared > 0)
60            .then(|| format!("{} declared", numfmt::group_chrome(declared)))
61            .as_deref(),
62        title.width,
63        theme,
64    ))
65    .render(title, buf);
66    if columns.is_empty() {
67        Paragraph::new(Span::styled("No columns in this scope", dimmed)).render(body, buf);
68        return;
69    }
70    let g = glyphs::get();
71    let types = columns
72        .iter()
73        .map(|(column, dtype)| type_label(config, column, dtype))
74        .collect::<Vec<_>>();
75    let name_width = columns
76        .iter()
77        .map(|(column, _)| glyphs::display_width(column))
78        .max()
79        .unwrap_or(0)
80        .clamp(6, 24) as u16
81        + 2;
82    let type_width = types
83        .iter()
84        .map(|dtype| glyphs::display_width(dtype))
85        .max()
86        .unwrap_or(0)
87        .clamp(4, 18) as u16
88        + 2;
89    let rules_width = (body.width as usize)
90        .saturating_sub(glyphs::display_width(g.selector) + (name_width + type_width) as usize);
91    let rows = columns.iter().zip(&types).map(|((column, _), dtype)| {
92        let mut rules = Vec::new();
93        if plan.intent.key.iter().any(|name| name == column) {
94            rules.push("key".to_string());
95        }
96        if let Some(intent) = plan.intent.column(column) {
97            rules.extend(intent.rules());
98        }
99        let rules = if rules.is_empty() {
100            Cell::from(Span::styled("any", dimmed))
101        } else {
102            Cell::from(fit(&rules.join(&format!(" {} ", g.middot)), rules_width))
103        };
104        Row::new(vec![
105            Cell::from(fit(column, name_width as usize - 2)),
106            Cell::from(Span::styled(fit(dtype, type_width as usize - 2), dimmed)),
107            rules,
108        ])
109    });
110    table_state.select(Some(config.plan_field.min(columns.len().saturating_sub(1))));
111    let table = Table::new(
112        rows,
113        [
114            Constraint::Length(name_width),
115            Constraint::Length(type_width),
116            Constraint::Fill(1),
117        ],
118    )
119    .header(Row::new(["Column", "Type", "Must hold"]).style(dimmed))
120    .row_highlight_style(theme.highlight_style())
121    .highlight_symbol(g.selector);
122    StatefulWidget::render(table, body, buf, table_state);
123    let key_line = if plan.intent.key.is_empty() {
124        "Key: none declared".to_string()
125    } else {
126        format!("Key: {}", plan.intent.key.join(", "))
127    };
128    Paragraph::new(Line::styled(fit(&key_line, key.width as usize), dimmed)).render(
129        Rect {
130            y: key.y + 1,
131            height: 1,
132            ..key
133        },
134        buf,
135    );
136}
137
138/// Where the form's values start, past the rail gutter and its longest label.
139const LABEL_WIDTH: u16 = 11;
140
141/// One column's declaration, over the list: a row per rule its type takes, and a
142/// line saying what the focused row expects, or why Enter did not apply.
143pub fn render_form(
144    form: &IntentForm,
145    config: &DataQualityWidgetConfig<'_>,
146    area: Rect,
147    buf: &mut Buffer,
148) {
149    let ctx = config.ctx;
150    let fields = form.fields();
151    let width = 64.min(area.width.saturating_sub(2));
152    // The type, the rows, a blank and the status line, inside the frame.
153    let height = fields.len() as u16 + 3 + 2;
154    let popup = centered_rect(width, height, area);
155    let title = format!("Intent: {}", form.column);
156    let content = Surface::new(&title).render(popup, buf, ctx);
157    if content.height < 2 || content.width < 8 {
158        return;
159    }
160    let line = |index: u16| Rect {
161        y: content.y + index,
162        height: 1,
163        ..content
164    };
165    FormRow {
166        label: "Type:",
167        value: FormValue::Choice(&crate::widgets::datatable::dtype_label(&form.dtype)),
168        focused: false,
169        label_width: LABEL_WIDTH,
170    }
171    .render(line(0), buf, ctx);
172    let reading = form.reading_label();
173    for (index, field) in fields.iter().enumerate() {
174        let row = index as u16 + 1;
175        if row >= content.height.saturating_sub(1) {
176            break;
177        }
178        let value = match field {
179            IntentField::Key => FormValue::Toggle(form.in_key),
180            IntentField::Required => FormValue::Toggle(form.required),
181            IntentField::ReadAs => FormValue::Choice(&reading),
182            text => match form.input(*text) {
183                Some(input) => FormValue::Input(input),
184                None => continue,
185            },
186        };
187        FormRow {
188            label: field.label(),
189            value,
190            focused: form.field == *field,
191            label_width: LABEL_WIDTH,
192        }
193        .render(line(row), buf, ctx);
194        crate::pointer::record_field::<crate::intent_modal::IntentForm>(line(row), *field);
195    }
196    // What the focused row takes, or why Enter refused: the form's own line.
197    let (status, warn) = match &form.error {
198        Some(error) => (error.clone(), true),
199        None => (
200            match form.field {
201                IntentField::Key => "Key column: together the key names one row".to_string(),
202                IntentField::Required => "Every row has a value".to_string(),
203                IntentField::ReadAs if form.time.is_some() => {
204                    "Read by Text as time · change in Setup".to_string()
205                }
206                IntentField::ReadAs => "Text that does not read is counted".to_string(),
207                IntentField::Allowed => "Comma-separated · \"a, b\" holds a comma".to_string(),
208                IntentField::Minimum | IntentField::Maximum => format!(
209                    "{} · empty for no bound",
210                    upper_first(form.value_kind().bound_hint())
211                ),
212            },
213            false,
214        ),
215    };
216    Paragraph::new(Line::styled(
217        fit(&dotted(&status), content.width as usize),
218        Style::default().fg(if warn { ctx.warning } else { ctx.dimmed }),
219    ))
220    .render(line(content.height - 1), buf);
221}
222
223fn upper_first(text: &str) -> String {
224    let mut chars = text.chars();
225    match chars.next() {
226        Some(first) => first.to_uppercase().chain(chars).collect(),
227        None => String::new(),
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::analysis_modal::{AnalysisFocus, DetailScroll, SetupRow};
235    use crate::config::Theme;
236    use crate::data_quality::{
237        DataQualityPlan, DataQualityResults, QualityCompute, QualityMetric, QualityPage,
238    };
239    use crate::quality_export::ExportForm;
240    use crate::quality_intent::{ColumnIntent, DeclaredIntent};
241    use crate::render::context::RenderContext;
242    use crate::widgets::data_quality::{SetupView, render};
243    use crate::widgets::datatable::DataTableState;
244    use polars::prelude::*;
245    use std::sync::Arc;
246
247    fn frame() -> LazyFrame {
248        let rows = 2_000i64;
249        df!(
250            "id" => (0..rows).map(|row| row % 1_500).collect::<Vec<_>>(),
251            "status" => (0..rows).map(|row| ["open", "closed", "void"][row as usize % 3]).collect::<Vec<_>>(),
252            "amount" => (0..rows).map(|row| (row % 130) as f64 - 10.0).collect::<Vec<_>>(),
253        )
254        .unwrap()
255        .lazy()
256    }
257
258    struct Screen {
259        state: DataTableState,
260        plan: DataQualityPlan,
261        results: DataQualityResults,
262        theme: Theme,
263        ctx: RenderContext,
264        findings: crate::quality_report::FindingsView,
265    }
266
267    /// What a draw puts over the page: the intent form or the export dialog.
268    #[derive(Default)]
269    struct Over<'a> {
270        intent: Option<&'a IntentForm>,
271        export: Option<&'a ExportForm>,
272        access: bool,
273    }
274
275    impl Screen {
276        fn new(compute: QualityCompute) -> Self {
277            let lf = frame();
278            let schema = Arc::new((*lf.clone().collect_schema().unwrap()).clone());
279            let state = DataTableState::from_schema_and_lazyframe(
280                schema,
281                lf.clone(),
282                &crate::OpenOptions::default(),
283                None,
284            )
285            .unwrap();
286            let plan = DataQualityPlan {
287                compute,
288                dataset_rows: 500,
289                intent: DeclaredIntent {
290                    key: vec!["id".to_string()],
291                    columns: vec![
292                        ColumnIntent {
293                            required: true,
294                            allowed: vec!["open".to_string(), "closed".to_string()],
295                            ..ColumnIntent::new("status")
296                        },
297                        ColumnIntent {
298                            min: Some("0".to_string()),
299                            max: Some("100".to_string()),
300                            ..ColumnIntent::new("amount")
301                        },
302                    ],
303                },
304                ..DataQualityPlan::default()
305            };
306            let results =
307                crate::data_quality::compute_data_quality(&lf, Some(2_000), &plan, None, false)
308                    .unwrap();
309            Self {
310                state,
311                plan,
312                results,
313                theme: Theme::from_config(&crate::config::ThemeConfig::default()).unwrap(),
314                ctx: RenderContext::for_test(),
315                findings: crate::quality_report::FindingsView::default(),
316            }
317        }
318
319        fn draw(
320            &self,
321            page: QualityPage,
322            field: usize,
323            over: Over<'_>,
324            size: (u16, u16),
325        ) -> String {
326            let config = DataQualityWidgetConfig {
327                checks_expanded: false,
328                state: &self.state,
329                plan: &self.plan,
330                measured: &self.plan,
331                results: Some(&self.results),
332                from_cache: false,
333                metric: QualityMetric::NullRate,
334                column_index: 0,
335                segment_index: 0,
336                interval_index: 0,
337                trend_line: 0,
338                expected_form: None,
339                segments_by_change: false,
340                page,
341                setup: SetupView::default(),
342                plan_field: field,
343                show_access: over.access,
344                observation_detail: false,
345                focus: AnalysisFocus::Main,
346                theme: &self.theme,
347                ctx: &self.ctx,
348                findings: &self.findings,
349                rows_kept: true,
350                evidence_read: None,
351                intent_form: over.intent,
352                export_form: over.export,
353            };
354            let (width, height) = size;
355            let area = Rect::new(0, 0, width, height);
356            let mut buf = Buffer::empty(area);
357            let mut table = TableState::default();
358            let mut sidebar = TableState::default();
359            render(
360                config,
361                &mut table,
362                &mut sidebar,
363                &mut DetailScroll::default(),
364                area,
365                &mut buf,
366            );
367            (0..height)
368                .map(|y| {
369                    (0..width)
370                        .map(|x| buf[(x, y)].symbol().to_string())
371                        .collect::<String>()
372                })
373                .collect::<Vec<_>>()
374                .join("\n")
375        }
376    }
377
378    /// Every character outside ASCII is a glyph slot, which `LANG=C` swaps for its
379    /// ASCII twin, or the frame.
380    fn assert_glyph_slots(text: &str) {
381        let g = glyphs::get();
382        let slots = [
383            g.rail,
384            g.rule_h,
385            g.rule_h_focused,
386            g.middot,
387            g.ellipsis,
388            g.selector,
389            g.checkbox_on,
390            g.checkbox_off,
391            g.warning,
392            g.check,
393            g.arrow_left,
394            g.arrow_right,
395        ]
396        .concat();
397        for c in text.chars().filter(|c| !c.is_ascii()) {
398            assert!(
399                slots.contains(c) || "╭╮╰╯│─".contains(c),
400                "{c:?} is not a glyph slot:\n{text}"
401            );
402        }
403    }
404
405    const SIZES: [(u16, u16); 2] = [(80, 24), (60, 20)];
406
407    /// The list names each column, its type and what it must hold, with the key
408    /// under it; the form takes the rows the type takes. Both fit 80x24 and 60x20.
409    #[test]
410    fn the_intent_list_and_form_fit_80x24_and_60x20() {
411        let screen = Screen::new(QualityCompute::Sample);
412        let mut form = IntentForm::new(
413            "amount",
414            DataType::Float64,
415            None,
416            &screen.plan.intent,
417            &screen.theme,
418        );
419        form.field = IntentField::Minimum;
420        for size in SIZES {
421            let text = screen.draw(QualityPage::Intent, 2, Over::default(), size);
422            for expected in ["Column intent", "Must hold", "key", "0 to 100", "Key: id"] {
423                assert!(text.contains(expected), "{expected} at {size:?}:\n{text}");
424            }
425            assert_glyph_slots(&text);
426
427            let over = Over {
428                intent: Some(&form),
429                ..Over::default()
430            };
431            let text = screen.draw(QualityPage::Intent, 2, over, size);
432            for expected in [
433                "Intent: amount",
434                "Key:",
435                "Required:",
436                "Minimum:",
437                "Maximum:",
438                "100",
439                "A number · empty for no bound",
440            ] {
441                assert!(text.contains(expected), "{expected} at {size:?}:\n{text}");
442            }
443            assert!(!text.contains("Allowed:"), "a float takes no set:\n{text}");
444            assert_glyph_slots(&text);
445        }
446        // A refused Enter says why on the form's own line.
447        form.error = Some("Minimum is above maximum".to_string());
448        let over = Over {
449            intent: Some(&form),
450            ..Over::default()
451        };
452        let text = screen.draw(QualityPage::Intent, 2, over, (80, 24));
453        assert!(text.contains("Minimum is above maximum"), "{text}");
454    }
455
456    /// Setup names the declared intent on its row, and the Read section and the
457    /// access plan say what it costs: nothing past the sample, and a key that speaks
458    /// only for the sampled rows; on a full scan, the key's own pass.
459    #[test]
460    fn setup_discloses_what_intent_costs() {
461        let screen = Screen::new(QualityCompute::Sample);
462        let field = SetupRow::Intent.index();
463        for size in SIZES {
464            let text = screen.draw(QualityPage::Setup, field, Over::default(), size);
465            assert!(text.contains("Column intent"), "{text}");
466            assert!(text.contains("key id"), "{text}");
467            assert_glyph_slots(&text);
468        }
469        let text = screen.draw(QualityPage::Setup, field, Over::default(), (120, 50));
470        assert!(
471            text.contains("Column intent: on the rows read · no extra read"),
472            "{text}"
473        );
474        assert!(
475            text.contains("Key: repeats among the 500 sampled rows only"),
476            "{text}"
477        );
478        let over = Over {
479            access: true,
480            ..Over::default()
481        };
482        let text = screen.draw(QualityPage::Setup, field, over, (100, 30));
483        assert!(text.contains("Column intent"), "{text}");
484        assert!(text.contains("sampled rows only"), "{text}");
485
486        let full = Screen::new(QualityCompute::Full);
487        let text = full.draw(QualityPage::Setup, field, Over::default(), (120, 50));
488        assert!(text.contains("key adds 1 pass"), "{text}");
489    }
490
491    /// The report lists the declared rules' violations as problems, and the export
492    /// dialog fits over it at both sizes.
493    #[test]
494    fn the_report_and_export_dialog_fit_80x24_and_60x20() {
495        let screen = Screen::new(QualityCompute::Full);
496        let mut export = ExportForm::new("orders", &screen.theme);
497        for size in SIZES {
498            let text = screen.draw(QualityPage::Overview, 0, Over::default(), size);
499            for title in ["Repeated key", "Not allowed", "Out of range"] {
500                assert!(text.contains(title), "{title} at {size:?}:\n{text}");
501            }
502            assert_glyph_slots(&text);
503            let over = Over {
504                export: Some(&export),
505                ..Over::default()
506            };
507            let text = screen.draw(QualityPage::Overview, 0, over, size);
508            for expected in [
509                "Export Report",
510                "Path:",
511                "orders-quality.json",
512                "Format:",
513                "JSON",
514            ] {
515                assert!(text.contains(expected), "{expected} at {size:?}:\n{text}");
516            }
517            assert_glyph_slots(&text);
518        }
519        export.error = Some("Type a path to write to".to_string());
520        let over = Over {
521            export: Some(&export),
522            ..Over::default()
523        };
524        let text = screen.draw(QualityPage::Overview, 0, over, (80, 24));
525        assert!(text.contains("Type a path to write to"), "{text}");
526    }
527}