1use rich::{
58 ColumnOptions, Console, ConsoleOptions, Justify, Renderable, Segment, Style, Table, Text,
59};
60use rich_ext::chart::{BarChart, Heatmap, Histogram};
61use rich_ext::sanitize::sanitize_single_line;
62use serde::{Deserialize, Serialize};
63
64use crate::infer::{parse_float, Inferrer, DEFAULT_NULL_TOKENS};
65use crate::stats::{number, Stats, StatsOptions};
66use crate::{DataError, Row, RowSource, Rows, Value};
67
68pub const DEFAULT_SAMPLE: usize = 10_000;
70pub const DEFAULT_TOP: usize = 5;
72pub const DEFAULT_BINS: usize = 10;
74pub const DEFAULT_BUCKETS: usize = 20;
76const LABEL_CHARS: usize = 24;
78const BAR_WIDTH: usize = 24;
80
81pub const STYLES: &[(&str, &str)] = &[
84 ("profile.title", "bold"),
85 ("profile.column", "bold"),
86 ("profile.type", "cyan"),
87 ("profile.note", "dim"),
88 ("profile.null", "yellow"),
89];
90
91pub(crate) fn style(console: &Console, key: &str, styles: &[(&str, &str)]) -> Style {
93 if let Some(style) = console.theme().get(key) {
94 return style.clone();
95 }
96 styles
97 .iter()
98 .find(|(name, _)| *name == key)
99 .and_then(|(_, spec)| Style::parse(spec).ok())
100 .unwrap_or_default()
101}
102
103#[derive(Clone, Debug, PartialEq)]
105pub struct ProfileOptions {
106 pub sample: usize,
109 pub top: usize,
112 pub bins: usize,
114 pub buckets: usize,
117 pub columns: Option<Vec<String>>,
119 pub nulls: Vec<String>,
122}
123
124impl Default for ProfileOptions {
125 fn default() -> Self {
126 ProfileOptions {
127 sample: DEFAULT_SAMPLE,
128 top: DEFAULT_TOP,
129 bins: DEFAULT_BINS,
130 buckets: DEFAULT_BUCKETS,
131 columns: None,
132 nulls: DEFAULT_NULL_TOKENS.map(String::from).to_vec(),
133 }
134 }
135}
136
137#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
139pub struct ColumnProfile {
140 pub name: String,
142 #[serde(rename = "type")]
145 pub data_type: String,
146 pub nulls: u64,
148 pub null_rate: f64,
150 pub count: usize,
152 pub distinct: usize,
154 pub min: Option<String>,
156 pub max: Option<String>,
158 pub mean: Option<f64>,
160 pub median: Option<f64>,
162 pub p25: Option<f64>,
164 pub p75: Option<f64>,
166 pub distribution: Distribution,
168}
169
170#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
172#[serde(tag = "kind", rename_all = "snake_case")]
173pub enum Distribution {
174 Histogram {
176 bins: Vec<Bin>,
178 },
179 Top {
181 values: Vec<TopValue>,
183 other: usize,
185 },
186 Empty,
188}
189
190#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
192pub struct Bin {
193 pub label: String,
195 pub low: f64,
197 pub high: f64,
199 pub count: usize,
201}
202
203#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
205pub struct TopValue {
206 pub value: String,
208 pub count: usize,
210}
211
212#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
215pub struct MissingMap {
216 pub bucket_rows: u64,
218 pub buckets: Vec<MissingBucket>,
220}
221
222#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
224pub struct MissingBucket {
225 pub first: u64,
227 pub rows: u64,
229 pub nulls: Vec<u64>,
231}
232
233#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
235pub struct Profile {
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub name: Option<String>,
239 rows: u64,
240 sample: usize,
241 columns: Vec<ColumnProfile>,
242 missing: MissingMap,
243 #[serde(default, skip_serializing_if = "Vec::is_empty")]
244 notes: Vec<String>,
245}
246
247impl Profile {
248 pub fn of(rows: &Rows) -> Self {
250 let mut profiler = Profiler::new(rows.columns(), ProfileOptions::default())
251 .expect("every column of the rows exists");
252 for row in rows.rows() {
253 profiler.push(row.clone());
254 }
255 profiler.finish()
256 }
257
258 pub fn from_source(
261 mut source: impl RowSource,
262 options: ProfileOptions,
263 ) -> Result<Self, DataError> {
264 let mut profiler = Profiler::new(source.columns(), options)?;
265 while let Some(row) = source.next_row() {
266 profiler.push(row?);
267 }
268 Ok(profiler.finish())
269 }
270
271 pub fn with_name(mut self, name: impl Into<String>) -> Self {
273 self.name = Some(name.into());
274 self
275 }
276
277 pub fn note(&mut self, note: impl Into<String>) {
279 self.notes.push(note.into());
280 }
281
282 pub fn rows(&self) -> u64 {
284 self.rows
285 }
286
287 pub fn sample_size(&self) -> usize {
289 self.sample
290 }
291
292 pub fn sampled(&self) -> bool {
294 (self.sample as u64) < self.rows
295 }
296
297 pub fn columns(&self) -> &[ColumnProfile] {
299 &self.columns
300 }
301
302 pub fn column(&self, name: &str) -> Option<&ColumnProfile> {
304 self.columns.iter().find(|c| c.name == name)
305 }
306
307 pub fn missing(&self) -> &MissingMap {
309 &self.missing
310 }
311
312 pub fn notes(&self) -> &[String] {
314 &self.notes
315 }
316
317 pub fn to_json(&self) -> serde_json::Value {
320 let mut value = serde_json::to_value(self).unwrap_or_default();
321 if let Some(object) = value.as_object_mut() {
322 object.insert("sampled".into(), self.sampled().into());
323 }
324 value
325 }
326
327 pub fn heading(&self) -> String {
330 let rows = if self.sampled() {
331 format!(
332 "sampled {} of {} rows",
333 grouped(self.sample as u64),
334 grouped(self.rows)
335 )
336 } else {
337 format!(
338 "{} {}",
339 grouped(self.rows),
340 plural(self.rows, "row", "rows")
341 )
342 };
343 let columns = self.columns.len() as u64;
344 let body = format!("{rows}, {columns} {}", plural(columns, "column", "columns"));
345 match &self.name {
346 Some(name) => format!("{}: {body}", sanitize_single_line(name)),
347 None => body,
348 }
349 }
350
351 pub fn to_table(&self) -> Table {
355 self.table(true)
356 }
357
358 fn table(&self, names_first: bool) -> Table {
361 let mut table = Table::new();
362 let widest = self
364 .columns
365 .iter()
366 .map(|c| c.name.chars().count())
367 .max()
368 .unwrap_or(0);
369 table.add_column_with(
370 Text::new("column"),
371 ColumnOptions {
372 min_width: names_first.then(|| widest.clamp(6, 16)),
373 ..ColumnOptions::default()
374 },
375 );
376 table.add_column("type");
377 for heading in ["nulls", "distinct", "min", "max", "mean"] {
378 table.add_column_justify(heading, Justify::Right);
379 }
380 for c in &self.columns {
381 let optional = |v: Option<f64>| v.map(number).unwrap_or_default();
382 let text = |v: &Option<String>| v.as_deref().map(label).unwrap_or_default();
383 table.add_row_text(
384 [
385 sanitize_single_line(&c.name),
386 c.data_type.clone(),
387 nulls(c.nulls, c.null_rate),
388 c.distinct.to_string(),
389 text(&c.min),
390 text(&c.max),
391 optional(c.mean),
392 ]
393 .into_iter()
394 .map(Text::new)
395 .collect(),
396 );
397 }
398 table
399 }
400
401 pub fn missing_heatmap(&self) -> Option<Heatmap> {
405 if self.columns.iter().all(|c| c.nulls == 0) {
406 return None;
407 }
408 let widest = self
409 .columns
410 .iter()
411 .map(|c| c.name.chars().count())
412 .max()
413 .unwrap_or(1);
414 let mut map = Heatmap::new()
415 .columns(self.columns.iter().map(|c| sanitize_single_line(&c.name)))
416 .range(0.0, 100.0)
417 .cell_width((widest + 1).clamp(2, 12));
418 for bucket in &self.missing.buckets {
419 let last = bucket.first + bucket.rows.saturating_sub(1);
420 let label = if bucket.rows <= 1 {
421 bucket.first.to_string()
422 } else {
423 format!("{}-{last}", bucket.first)
424 };
425 let rows = bucket.rows.max(1) as f64;
426 map = map.row(label, bucket.nulls.iter().map(|&n| n as f64 * 100.0 / rows));
427 }
428 Some(map)
429 }
430}
431
432fn distribution_chart(distribution: &Distribution) -> Option<BarChart> {
435 let bars: Vec<(String, f64)> = match distribution {
436 Distribution::Histogram { bins } => bins
437 .iter()
438 .map(|b| (b.label.clone(), b.count as f64))
439 .collect(),
440 Distribution::Top { values, .. } => values
441 .iter()
442 .map(|v| (label(&v.value), v.count as f64))
443 .collect(),
444 Distribution::Empty => return None,
445 };
446 Some(BarChart::from_pairs(bars).bar_width(BAR_WIDTH))
447}
448
449impl Renderable for Profile {
450 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
451 let width = options.max_width;
452 if width == 0 {
453 return Vec::new();
454 }
455 let options = options.reset_height();
456 let note = style(console, "profile.note", STYLES);
457 let mut lines: Vec<Vec<Segment>> = Vec::new();
458 let text = |text: Text, indent: usize, lines: &mut Vec<Vec<Segment>>| {
459 block(console, &options, &text, indent, lines);
460 };
461
462 text(
463 Text::styled(self.heading(), style(console, "profile.title", STYLES)),
464 0,
465 &mut lines,
466 );
467 if self.sampled() {
468 text(
469 Text::styled(
470 "types, distinct values, statistics and distributions are of a uniform \
471 sample; nulls and the missing-value map count every row",
472 note.clone(),
473 ),
474 0,
475 &mut lines,
476 );
477 }
478 for line in &self.notes {
479 text(
480 Text::styled(sanitize_single_line(line), note.clone()),
481 0,
482 &mut lines,
483 );
484 }
485 if self.columns.is_empty() {
486 return join(lines);
487 }
488 lines.push(Vec::new());
489 let mut table = self.to_table();
492 let drawn = console.render(&table, Some(&options));
493 if Segment::split_lines(&drawn)
494 .iter()
495 .any(|line| line.iter().map(Segment::cell_length).sum::<usize>() > options.max_width)
496 {
497 table = self.table(false);
498 }
499 block(console, &options, &table, 0, &mut lines);
500
501 for c in &self.columns {
502 lines.push(Vec::new());
503 let mut title = Text::styled(
504 sanitize_single_line(&c.name),
505 style(console, "profile.column", STYLES),
506 );
507 title.append(" · ", None);
508 title.append(
509 &c.data_type,
510 Some(style(console, "profile.type", STYLES).into()),
511 );
512 if c.nulls > 0 {
513 title.append(" · ", None);
514 title.append(
515 &format!(
516 "{} {} ({})",
517 grouped(c.nulls),
518 plural(c.nulls, "null", "nulls"),
519 percent(c.null_rate)
520 ),
521 Some(style(console, "profile.null", STYLES).into()),
522 );
523 }
524 if matches!(c.distribution, Distribution::Histogram { .. }) {
525 let quartiles = [c.p25, c.median, c.p75].map(|q| q.map(number));
526 if let [Some(p25), Some(median), Some(p75)] = quartiles {
527 title.append(&format!(" · median {median} (p25 {p25}, p75 {p75})"), None);
528 }
529 } else {
530 title.append(&format!(" · {} distinct", grouped(c.distinct as u64)), None);
531 }
532 text(title, 0, &mut lines);
533 match distribution_chart(&c.distribution) {
534 Some(chart) => block(console, &options, &chart, 2, &mut lines),
535 None => text(Text::styled("no values", note.clone()), 2, &mut lines),
536 }
537 if let Distribution::Top { values, other } = &c.distribution {
538 if *other > 0 {
539 let more = c.distinct.saturating_sub(values.len()) as u64;
540 text(
541 Text::styled(
542 format!(
543 "+ {} other {} ({} {})",
544 grouped(more),
545 plural(more, "value", "values"),
546 grouped(*other as u64),
547 plural(*other as u64, "cell", "cells"),
548 ),
549 note.clone(),
550 ),
551 2,
552 &mut lines,
553 );
554 }
555 }
556 }
557
558 lines.push(Vec::new());
559 match self.missing_heatmap() {
560 None => text(
561 Text::styled("no missing values", note.clone()),
562 0,
563 &mut lines,
564 ),
565 Some(map) => {
566 let rows = self.missing.bucket_rows;
567 let per = if rows <= 1 {
568 "a row each".to_string()
569 } else {
570 format!("{} rows each", grouped(rows))
571 };
572 text(
573 Text::styled(
574 format!("missing values (% null; rows top to bottom, {per})"),
575 style(console, "profile.column", STYLES),
576 ),
577 0,
578 &mut lines,
579 );
580 block(console, &options, &map, 0, &mut lines);
581 }
582 }
583 join(lines)
584 }
585
586 fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
588 let widest = Segment::split_lines(&self.rich_render(console, options))
589 .iter()
590 .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
591 .max()
592 .unwrap_or(0);
593 rich::measure::Measurement::new(widest, widest)
594 }
595}
596
597pub(crate) fn block(
599 console: &Console,
600 options: &ConsoleOptions,
601 renderable: &dyn Renderable,
602 indent: usize,
603 lines: &mut Vec<Vec<Segment>>,
604) {
605 let width = options.max_width.saturating_sub(indent).max(1);
606 let rendered = console.render(renderable, Some(&options.update_width(width)));
607 for line in Segment::split_lines(&rendered) {
608 let mut row = Vec::with_capacity(line.len() + 1);
609 if indent > 0 {
610 row.push(Segment::new(" ".repeat(indent), None));
611 }
612 row.extend(line);
613 lines.push(row);
614 }
615}
616
617pub(crate) fn join(lines: Vec<Vec<Segment>>) -> Vec<Segment> {
619 let mut out = Vec::new();
620 for (i, line) in lines.into_iter().enumerate() {
621 if i > 0 {
622 out.push(Segment::line());
623 }
624 out.extend(line);
625 }
626 out
627}
628
629#[derive(Clone, Debug)]
647pub struct Profiler {
648 options: ProfileOptions,
649 names: Vec<String>,
650 indices: Vec<usize>,
652 rows: u64,
653 reservoir: Vec<Row>,
654 rng: u64,
655 nulls: Vec<u64>,
656 bucket_rows: u64,
657 buckets: Vec<(u64, Vec<u64>)>,
658}
659
660impl Profiler {
661 pub fn new(columns: &[String], options: ProfileOptions) -> Result<Self, DataError> {
664 let indices: Vec<usize> = match &options.columns {
665 None => (0..columns.len()).collect(),
666 Some(wanted) => wanted
667 .iter()
668 .map(|name| {
669 columns.iter().position(|c| c == name).ok_or_else(|| {
670 DataError::new(format!(
671 "no column {:?}; the columns are {}",
672 sanitize_single_line(name),
673 columns
674 .iter()
675 .map(|c| sanitize_single_line(c))
676 .collect::<Vec<_>>()
677 .join(", ")
678 ))
679 })
680 })
681 .collect::<Result<_, _>>()?,
682 };
683 Ok(Profiler {
684 names: indices.iter().map(|&i| columns[i].clone()).collect(),
685 nulls: vec![0; indices.len()],
686 indices,
687 rows: 0,
688 reservoir: Vec::new(),
689 rng: 0x9E37_79B9_7F4A_7C15,
690 bucket_rows: 1,
691 buckets: Vec::new(),
692 options,
693 })
694 }
695
696 pub fn push(&mut self, row: Row) {
698 let mut cells: Row = self
699 .indices
700 .iter()
701 .map(|&i| row.get(i).cloned().unwrap_or(Value::Null))
702 .collect();
703 if self
706 .buckets
707 .last()
708 .is_none_or(|(rows, _)| *rows >= self.bucket_rows)
709 {
710 if self.buckets.len() >= self.options.buckets.max(1) {
711 self.merge_buckets();
712 }
713 if self
714 .buckets
715 .last()
716 .is_none_or(|(rows, _)| *rows >= self.bucket_rows)
717 {
718 self.buckets.push((0, vec![0; self.indices.len()]));
719 }
720 }
721 let bucket = self.buckets.last_mut().expect("a bucket was just added");
722 bucket.0 += 1;
723 for (column, cell) in cells.iter_mut().enumerate() {
726 if is_null(&self.options.nulls, cell) {
727 self.nulls[column] += 1;
728 bucket.1[column] += 1;
729 *cell = Value::Null;
730 }
731 }
732 let sample = self.options.sample.max(1);
735 self.rows += 1;
736 if self.reservoir.len() < sample {
737 self.reservoir.push(cells);
738 } else {
739 let slot = self.next_random() % self.rows;
740 if let Some(kept) = self.reservoir.get_mut(slot as usize) {
741 *kept = cells;
742 }
743 }
744 }
745
746 pub fn finish(self) -> Profile {
748 let mut sample = Rows::new(self.names.clone());
749 let size = self.reservoir.len();
750 for row in self.reservoir {
751 sample.push(row);
752 }
753 let inference = Inferrer::new()
754 .null_tokens(self.options.nulls.iter().cloned())
755 .infer(&sample);
756 inference.apply(&mut sample);
757 let stats = Stats::with_options(
758 &sample,
759 &StatsOptions {
760 quantiles: vec![0.25, 0.75],
761 top: self.options.top,
762 },
763 );
764 let rows = self.rows;
765 let columns = stats
766 .columns()
767 .iter()
768 .zip(inference.columns())
769 .enumerate()
770 .map(|(index, (s, inferred))| {
771 let distribution = if s.count == 0 {
772 Distribution::Empty
773 } else if s.numeric {
774 let values = sample.column(index).filter_map(|v| {
775 v.as_f64()
776 .or_else(|| parse_float(v.plain().trim()))
777 .filter(|n| n.is_finite())
778 });
779 histogram(Histogram::new(values).bins(self.options.bins.max(1)))
780 } else {
781 let shown: usize = s.top.iter().map(|(_, n)| n).sum();
782 Distribution::Top {
783 values: s
784 .top
785 .iter()
786 .map(|(value, count)| TopValue {
787 value: value.clone(),
788 count: *count,
789 })
790 .collect(),
791 other: s.count - shown,
792 }
793 };
794 let quantile = |q: f64| s.quantiles.iter().find(|(at, _)| *at == q).map(|v| v.1);
795 ColumnProfile {
796 name: s.name.clone(),
797 data_type: inferred.data_type().name().to_string(),
798 nulls: self.nulls[index],
799 null_rate: if rows == 0 {
800 0.0
801 } else {
802 self.nulls[index] as f64 / rows as f64
803 },
804 count: s.count,
805 distinct: s.distinct,
806 min: s.min.clone(),
807 max: s.max.clone(),
808 mean: s.mean,
809 median: s.median,
810 p25: quantile(0.25),
811 p75: quantile(0.75),
812 distribution,
813 }
814 })
815 .collect();
816 let mut first = 1;
817 let buckets = self
818 .buckets
819 .into_iter()
820 .map(|(rows, nulls)| {
821 let bucket = MissingBucket { first, rows, nulls };
822 first += rows;
823 bucket
824 })
825 .collect();
826 Profile {
827 name: None,
828 rows,
829 sample: size,
830 columns,
831 missing: MissingMap {
832 bucket_rows: self.bucket_rows,
833 buckets,
834 },
835 notes: Vec::new(),
836 }
837 }
838
839 fn merge_buckets(&mut self) {
843 let merged = self
844 .buckets
845 .chunks(2)
846 .map(|pair| {
847 let mut rows = 0;
848 let mut nulls = vec![0; self.indices.len()];
849 for (r, n) in pair {
850 rows += r;
851 for (total, n) in nulls.iter_mut().zip(n) {
852 *total += n;
853 }
854 }
855 (rows, nulls)
856 })
857 .collect();
858 self.buckets = merged;
859 self.bucket_rows *= 2;
860 }
861
862 fn next_random(&mut self) -> u64 {
864 self.rng = self.rng.wrapping_add(0x9E37_79B9_7F4A_7C15);
865 let mut z = self.rng;
866 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
867 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
868 z ^ (z >> 31)
869 }
870}
871
872fn is_null(tokens: &[String], value: &Value) -> bool {
874 match value {
875 Value::Null => true,
876 Value::Int(_) | Value::Float(_) => false,
877 Value::Str(text) => tokens.iter().any(|t| t == text.trim()),
878 Value::Text(text) => {
879 let plain = text.plain();
880 tokens.iter().any(|t| t == plain.trim())
881 }
882 }
883}
884
885fn histogram(histogram: Histogram) -> Distribution {
887 let edges = histogram.edges();
888 let counts = histogram.counts();
889 let chart = histogram.to_bar_chart();
890 let bins = chart
891 .bars()
892 .iter()
893 .zip(counts)
894 .enumerate()
895 .map(|(i, (bar, count))| Bin {
896 label: bar.label.clone(),
897 low: edges[i],
898 high: edges[i + 1],
899 count,
900 })
901 .collect();
902 Distribution::Histogram { bins }
903}
904
905fn label(value: &str) -> String {
907 let line = sanitize_single_line(value);
908 if line.chars().count() <= LABEL_CHARS {
909 return line;
910 }
911 let mut cut: String = line.chars().take(LABEL_CHARS - 1).collect();
912 cut.push('…');
913 cut
914}
915
916fn nulls(count: u64, rate: f64) -> String {
918 if count == 0 {
919 "0".to_string()
920 } else {
921 format!("{} ({})", grouped(count), percent(rate))
922 }
923}
924
925fn percent(rate: f64) -> String {
928 let p = rate * 100.0;
929 if (99.5..100.0).contains(&p) {
930 return ">99%".to_string();
931 }
932 if p > 0.0 && p < 0.1 {
933 return "<0.1%".to_string();
934 }
935 if p < 1.0 {
936 format!("{}%", number((p * 10.0).round() / 10.0))
937 } else {
938 format!("{}%", p.round())
939 }
940}
941
942fn grouped(n: u64) -> String {
944 let digits = n.to_string();
945 let mut out = String::new();
946 for (i, c) in digits.chars().enumerate() {
947 if i > 0 && (digits.len() - i).is_multiple_of(3) {
948 out.push(',');
949 }
950 out.push(c);
951 }
952 out
953}
954
955fn plural(n: u64, one: &'static str, many: &'static str) -> &'static str {
956 if n == 1 {
957 one
958 } else {
959 many
960 }
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966
967 fn profile(columns: &[&str], rows: &[&[&str]], options: ProfileOptions) -> Profile {
968 let names: Vec<String> = columns.iter().map(|c| c.to_string()).collect();
969 let mut profiler = Profiler::new(&names, options).unwrap();
970 for row in rows {
971 profiler.push(row.iter().map(|c| Value::from(*c)).collect());
972 }
973 profiler.finish()
974 }
975
976 #[test]
977 fn numbers_group_and_rates_round() {
978 assert_eq!(grouped(0), "0");
979 assert_eq!(grouped(999), "999");
980 assert_eq!(grouped(1_000), "1,000");
981 assert_eq!(grouped(1_234_567), "1,234,567");
982 assert_eq!(percent(0.25), "25%");
983 assert_eq!(percent(0.004), "0.4%");
984 assert_eq!(percent(0.000_01), "<0.1%");
985 assert_eq!(percent(0.999), ">99%");
986 assert_eq!(percent(1.0), "100%");
987 }
988
989 #[test]
990 fn the_map_merges_buckets_in_order() {
991 let options = ProfileOptions {
992 buckets: 4,
993 ..Default::default()
994 };
995 let rows: Vec<Vec<&str>> = (0..10)
996 .map(|i| vec![if i % 3 == 0 { "" } else { "x" }])
997 .collect();
998 let rows: Vec<&[&str]> = rows.iter().map(Vec::as_slice).collect();
999 let p = profile(&["a"], &rows, options);
1000 let map = p.missing();
1001 assert_eq!(map.bucket_rows, 4);
1002 let shape: Vec<(u64, u64, Vec<u64>)> = map
1003 .buckets
1004 .iter()
1005 .map(|b| (b.first, b.rows, b.nulls.clone()))
1006 .collect();
1007 assert_eq!(shape, [(1, 4, vec![2]), (5, 4, vec![1]), (9, 2, vec![1])]);
1009 assert_eq!(p.columns()[0].nulls, 4);
1010 }
1011
1012 #[test]
1013 fn a_large_input_is_sampled_with_exact_nulls() {
1014 let options = ProfileOptions {
1015 sample: 50,
1016 ..Default::default()
1017 };
1018 let names = vec!["n".to_string(), "s".to_string()];
1019 let mut profiler = Profiler::new(&names, options).unwrap();
1020 for i in 0..5_000i64 {
1021 let s = if i % 10 == 0 {
1022 Value::Null
1023 } else {
1024 Value::from("x")
1025 };
1026 profiler.push(vec![Value::Int(i), s]);
1027 }
1028 let p = profiler.finish();
1029 assert_eq!((p.rows(), p.sample_size(), p.sampled()), (5_000, 50, true));
1030 assert_eq!(p.columns()[1].nulls, 500);
1031 assert_eq!(p.columns()[1].null_rate, 0.1);
1032 let max: f64 = p.columns()[0].max.as_deref().unwrap().parse().unwrap();
1034 assert!(max > 2_500.0, "{max}");
1035 assert_eq!(p.to_json()["sampled"], true);
1036 assert_eq!(p.to_json()["sample"], 50);
1037 }
1038
1039 #[test]
1040 fn ragged_and_empty_input_never_panic() {
1041 let names = vec!["a".to_string(), "b".to_string()];
1042 let mut profiler = Profiler::new(&names, ProfileOptions::default()).unwrap();
1043 profiler.push(vec![]);
1044 profiler.push(vec![Value::from("1"), Value::from("2"), Value::from("3")]);
1045 let p = profiler.finish();
1046 assert_eq!(p.columns()[0].nulls, 1);
1047 let console = Console::builder().width(30).color_system(None).build();
1048 console.render_export(&p);
1049 let empty = Profiler::new(&[], ProfileOptions::default())
1050 .unwrap()
1051 .finish();
1052 assert_eq!(console.render_export(&empty), "0 rows, 0 columns\n");
1053 let none = Profiler::new(&names, ProfileOptions::default())
1054 .unwrap()
1055 .finish();
1056 assert!(console.render_export(&none).contains("no values"));
1057 }
1058
1059 #[test]
1060 fn unknown_columns_are_named() {
1061 let options = ProfileOptions {
1062 columns: Some(vec!["b".into(), "z".into()]),
1063 ..Default::default()
1064 };
1065 let error = Profiler::new(&["a".into(), "b".into()], options).unwrap_err();
1066 assert_eq!(error.to_string(), "no column \"z\"; the columns are a, b");
1067 }
1068
1069 #[test]
1070 fn the_model_round_trips_through_json() {
1071 let p = profile(
1072 &["k", "v"],
1073 &[&["a", "1"], &["b", "2.5"], &["a", ""]],
1074 ProfileOptions::default(),
1075 )
1076 .with_name("x.csv");
1077 let json = serde_json::to_string(&p).unwrap();
1078 let back: Profile = serde_json::from_str(&json).unwrap();
1079 assert_eq!(back, p);
1080 assert_eq!(p.columns()[1].data_type, "float");
1081 match &p.columns()[0].distribution {
1082 Distribution::Top { values, other } => {
1083 assert_eq!(values[0].value, "a");
1084 assert_eq!((values[0].count, *other), (2, 0));
1085 }
1086 other => panic!("{other:?}"),
1087 }
1088 }
1089}