1use 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
18pub 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
28fn 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
36pub 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
138const LABEL_WIDTH: u16 = 11;
140
141pub 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 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 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 #[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 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 #[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 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 #[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 #[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}