Skip to main content

rich_data/
profile.rs

1//! Data profiles (#343, #344, #345, #346): what every column of a CSV, JSON
2//! Lines or any other [`RowSource`] holds, in one bounded pass.
3//!
4//! A [`Profile`] gives each column its inferred type ([`infer`](crate::infer)),
5//! null count and rate, distinct values, statistics ([`stats`](crate::stats))
6//! and a distribution: a histogram (from `rich_ext`'s
7//! [`chart::Histogram`](rich_ext::chart::Histogram)) for a numeric column, the
8//! most common values for any other. A missing-value map shows where the
9//! nulls are: the rows in order, bucketed, against the columns, drawn as a
10//! [`chart::Heatmap`](rich_ext::chart::Heatmap). The model serialises to JSON
11//! (`serde`), and renders as a heading, a table of the columns, each
12//! distribution and the map.
13//!
14//! **Bounded.** A [`Profiler`] reads rows one at a time and keeps at most
15//! [`ProfileOptions::sample`] of them (default [`DEFAULT_SAMPLE`]): a uniform
16//! reservoir sample (Algorithm R) drawn with a fixed seed, so the same input
17//! always gives the same profile. Types, distinct values, statistics and
18//! distributions describe the sample; the row count, every null count and
19//! the missing-value map count every row (the map holds at most
20//! [`ProfileOptions::buckets`] buckets, merging neighbours as rows arrive).
21//! [`Profile::sampled`] says whether the sample is all of it, and the
22//! heading prints the sample size. Memory is the sample plus the map, however
23//! long the input.
24//!
25//! Nulls are [`Value::Null`] and the null tokens (by default
26//! [`DEFAULT_NULL_TOKENS`]: an empty cell, `null`, `NULL`, `NA`, `N/A`).
27//!
28//! **JSON records** have more shape than rows: [`Profile::from_json_lines`]
29//! and [`JsonProfiler`] (#705) profile them path by path (`user.plan`,
30//! `tags[]`, `items[].sku`), each path with the records that lack it apart
31//! from `null`, and the mix of JSON types its values had ([`TypeCounts`]).
32//! See the [`nested`] module for paths, bounds and quoting.
33//!
34//! **Conditional styles** ([`Profile::style_rules`]) style the table of
35//! columns with `rich_ext`'s [`table::rules`](rich_ext::table::rules), by
36//! its headings ([`Profile::table_headers`]).
37//!
38//! ```
39//! use rich::Console;
40//! use rich_data::profile::Profile;
41//! use rich_data::{csv::CsvReader, RowSource};
42//!
43//! let text = "service,p99\nweb,120\napi,35\ndb,\nweb,80\n";
44//! let profile = Profile::from_source(
45//!     CsvReader::new().header(true).source(text.as_bytes()).unwrap(),
46//!     Default::default(),
47//! )
48//! .unwrap();
49//! assert_eq!(profile.rows(), 4);
50//! assert!(!profile.sampled());
51//! let p99 = profile.column("p99").unwrap();
52//! assert_eq!((p99.data_type.as_str(), p99.nulls), ("integer", 1));
53//! assert_eq!(p99.mean, Some(78.33333333333333));
54//! let service = profile.column("service").unwrap();
55//! assert_eq!(service.distinct, 3);
56//!
57//! let console = Console::builder().width(60).color_system(None).build();
58//! let out = console.render_export(&profile);
59//! assert!(out.starts_with("4 rows, 2 columns\n"));
60//! assert!(out.contains("p99 · integer · 1 null (25%)"));
61//! let json = profile.to_json();
62//! assert_eq!(json["columns"][1]["distribution"]["kind"], "histogram");
63//! ```
64//!
65//! [`DEFAULT_NULL_TOKENS`]: crate::infer::DEFAULT_NULL_TOKENS
66
67use rich::{
68    Cell, ColumnOptions, Console, ConsoleOptions, Justify, Renderable, Segment, Style, Table, Text,
69};
70use rich_ext::chart::{BarChart, Heatmap, Histogram};
71use rich_ext::sanitize::sanitize_single_line;
72use rich_ext::table::StyleRules;
73use serde::{Deserialize, Serialize};
74
75use crate::infer::{DEFAULT_NULL_TOKENS, Inferrer, parse_float};
76use crate::stats::{ColumnStats, Stats, StatsOptions, number};
77use crate::{DataError, Row, RowSource, Rows, Value};
78
79pub mod nested;
80
81pub use nested::{
82    DEFAULT_DEPTH, DEFAULT_MAX_PATHS, JsonProfiler, MAX_MALFORMED_LINES, PathSummary, TypeCounts,
83    path_key,
84};
85
86/// The rows a profile keeps by default.
87pub const DEFAULT_SAMPLE: usize = 10_000;
88/// The most common values a categorical column shows by default.
89pub const DEFAULT_TOP: usize = 5;
90/// Histogram bins by default.
91pub const DEFAULT_BINS: usize = 10;
92/// Rows of the missing-value map by default.
93pub const DEFAULT_BUCKETS: usize = 20;
94/// The longest value label a distribution writes, in characters.
95const LABEL_CHARS: usize = 24;
96/// Cells for the longest bar of a distribution.
97const BAR_WIDTH: usize = 24;
98
99/// The theme keys a profile draws with, and their defaults. A console theme
100/// that defines a key wins.
101pub const STYLES: &[(&str, &str)] = &[
102    ("profile.title", "bold"),
103    ("profile.column", "bold"),
104    ("profile.type", "cyan"),
105    ("profile.note", "dim"),
106    ("profile.null", "yellow"),
107    ("profile.mixed", "magenta"),
108];
109
110/// The console theme's style for `key`, else its default from [`STYLES`].
111pub(crate) fn style(console: &Console, key: &str, styles: &[(&str, &str)]) -> Style {
112    if let Some(style) = console.theme().get(key) {
113        return style.clone();
114    }
115    styles
116        .iter()
117        .find(|(name, _)| *name == key)
118        .and_then(|(_, spec)| Style::parse(spec).ok())
119        .unwrap_or_default()
120}
121
122/// What a [`Profiler`] keeps and shows.
123#[derive(Clone, Debug, PartialEq)]
124pub struct ProfileOptions {
125    /// The most rows kept for types, statistics and distributions (default
126    /// [`DEFAULT_SAMPLE`], at least 1).
127    pub sample: usize,
128    /// The most common values a categorical column keeps (default
129    /// [`DEFAULT_TOP`]).
130    pub top: usize,
131    /// Histogram bins for a numeric column (default [`DEFAULT_BINS`]).
132    pub bins: usize,
133    /// The most rows of the missing-value map (default [`DEFAULT_BUCKETS`],
134    /// at least 1).
135    pub buckets: usize,
136    /// Only these columns, by name, in this order (default every column).
137    pub columns: Option<Vec<String>>,
138    /// The cells that count as null, compared after trimming (default
139    /// [`DEFAULT_NULL_TOKENS`]). A nested profile ([`JsonProfiler`]) has
140    /// JSON's own `null` and ignores them.
141    pub nulls: Vec<String>,
142    /// For a nested profile ([`JsonProfiler`]), the deepest path profiled,
143    /// in steps (a key or `[]`; default [`DEFAULT_DEPTH`], at least 1).
144    pub depth: usize,
145    /// For a nested profile, the most paths profiled (default
146    /// [`DEFAULT_MAX_PATHS`], at least 1).
147    pub max_paths: usize,
148}
149
150impl Default for ProfileOptions {
151    fn default() -> Self {
152        ProfileOptions {
153            sample: DEFAULT_SAMPLE,
154            top: DEFAULT_TOP,
155            bins: DEFAULT_BINS,
156            buckets: DEFAULT_BUCKETS,
157            columns: None,
158            nulls: DEFAULT_NULL_TOKENS.map(String::from).to_vec(),
159            depth: DEFAULT_DEPTH,
160            max_paths: DEFAULT_MAX_PATHS,
161        }
162    }
163}
164
165/// One column of a [`Profile`].
166#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
167pub struct ColumnProfile {
168    /// The column name.
169    pub name: String,
170    /// The inferred type's name (`integer`, `float`, `boolean`, `date`,
171    /// `timestamp`, `text`, or `null` when every sampled cell is null).
172    #[serde(rename = "type")]
173    pub data_type: String,
174    /// Null cells, over every row.
175    pub nulls: u64,
176    /// `nulls` over the row count, `0.0` for no rows.
177    pub null_rate: f64,
178    /// Non-null cells in the sample.
179    pub count: usize,
180    /// Distinct non-null values in the sample.
181    pub distinct: usize,
182    /// The smallest value in the sample (numerically for a number column).
183    pub min: Option<String>,
184    /// The largest value in the sample.
185    pub max: Option<String>,
186    /// The mean, for a number column.
187    pub mean: Option<f64>,
188    /// The median, for a number column.
189    pub median: Option<f64>,
190    /// The first quartile, for a number column.
191    pub p25: Option<f64>,
192    /// The third quartile, for a number column.
193    pub p75: Option<f64>,
194    /// How the sampled values are spread.
195    pub distribution: Distribution,
196    /// For a nested profile: the records that hold the path at least once.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub present: Option<u64>,
199    /// For a nested profile: the records that do not hold the path (an
200    /// explicit `null` is present, and counted in `nulls`).
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub absent: Option<u64>,
203    /// For a nested profile: the values seen at the path, `null`s included
204    /// (more than `present` inside arrays).
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub values: Option<u64>,
207    /// For a nested profile: how many of those values were each JSON type.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub types: Option<TypeCounts>,
210    /// For a nested profile: whether the values were more than one JSON
211    /// type besides `null`.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub mixed: Option<bool>,
214}
215
216impl ColumnProfile {
217    /// `absent` over the row count, for a nested profile.
218    pub fn absent_rate(&self, rows: u64) -> Option<f64> {
219        self.absent.map(|absent| {
220            if rows == 0 {
221                0.0
222            } else {
223                absent as f64 / rows as f64
224            }
225        })
226    }
227}
228
229/// How a column's values are spread.
230#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
231#[serde(tag = "kind", rename_all = "snake_case")]
232pub enum Distribution {
233    /// A number column: values counted into bins.
234    Histogram {
235        /// The bins, low to high.
236        bins: Vec<Bin>,
237    },
238    /// Any other column: its most common values.
239    Top {
240        /// The most common values, most common first (ties in text order).
241        values: Vec<TopValue>,
242        /// Non-null cells holding any other value.
243        other: usize,
244    },
245    /// No non-null values.
246    Empty,
247}
248
249/// One histogram bin: `[low, high)`, the last `[low, high]`.
250#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
251pub struct Bin {
252    /// `[low, high)` as the chart writes it.
253    pub label: String,
254    /// The low edge.
255    pub low: f64,
256    /// The high edge.
257    pub high: f64,
258    /// Values in the bin.
259    pub count: usize,
260}
261
262/// One of a column's most common values.
263#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
264pub struct TopValue {
265    /// The value as text.
266    pub value: String,
267    /// How many sampled cells hold it.
268    pub count: usize,
269}
270
271/// Where the nulls are: the rows in order, in buckets of
272/// [`bucket_rows`](Self::bucket_rows), each with its null count per column.
273#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
274pub struct MissingMap {
275    /// Rows per bucket (the last may hold fewer).
276    pub bucket_rows: u64,
277    /// The buckets, first rows first.
278    pub buckets: Vec<MissingBucket>,
279}
280
281/// One bucket of a [`MissingMap`].
282#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
283pub struct MissingBucket {
284    /// The first row, counted from 1.
285    pub first: u64,
286    /// Rows in the bucket.
287    pub rows: u64,
288    /// Null cells per column, in the profile's column order.
289    pub nulls: Vec<u64>,
290}
291
292/// A profile of rows. See the [module docs](self).
293#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
294pub struct Profile {
295    /// What was profiled (a file name), for the heading.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub name: Option<String>,
298    rows: u64,
299    sample: usize,
300    columns: Vec<ColumnProfile>,
301    missing: MissingMap,
302    #[serde(default, skip_serializing_if = "Vec::is_empty")]
303    notes: Vec<String>,
304    /// Set for a nested profile ([`JsonProfiler`]): its limits and the paths
305    /// past them.
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    paths: Option<PathSummary>,
308    /// Lines that were not JSON, skipped (a nested profile only).
309    #[serde(default, skip_serializing_if = "is_zero")]
310    malformed: u64,
311    /// The first of them, by line number.
312    #[serde(default, skip_serializing_if = "Vec::is_empty")]
313    malformed_lines: Vec<usize>,
314    /// Conditional styles for the table; not part of the model.
315    #[serde(skip)]
316    rules: Rules,
317}
318
319fn is_zero(n: &u64) -> bool {
320    *n == 0
321}
322
323/// [`StyleRules`] held by a [`Profile`]: presentation, so two profiles with
324/// different rules are still equal.
325#[derive(Clone, Debug, Default)]
326struct Rules(StyleRules);
327
328impl PartialEq for Rules {
329    fn eq(&self, _: &Self) -> bool {
330        true
331    }
332}
333
334impl Profile {
335    /// A profile of rows in memory, with the default options.
336    pub fn of(rows: &Rows) -> Self {
337        let mut profiler = Profiler::new(rows.columns(), ProfileOptions::default())
338            .expect("every column of the rows exists");
339        for row in rows.rows() {
340            profiler.push(row.clone());
341        }
342        profiler.finish()
343    }
344
345    /// Profile every row of `source`, stopping at the first that cannot be
346    /// read.
347    pub fn from_source(
348        mut source: impl RowSource,
349        options: ProfileOptions,
350    ) -> Result<Self, DataError> {
351        let mut profiler = Profiler::new(source.columns(), options)?;
352        while let Some(row) = source.next_row() {
353            profiler.push(row?);
354        }
355        Ok(profiler.finish())
356    }
357
358    /// Name what was profiled, for the heading.
359    #[must_use]
360    pub fn with_name(mut self, name: impl Into<String>) -> Self {
361        self.name = Some(name.into());
362        self
363    }
364
365    /// Add a note shown under the heading (rows cut to fit, keys seen late).
366    pub fn note(&mut self, note: impl Into<String>) {
367        self.notes.push(note.into());
368    }
369
370    /// Every row read.
371    pub fn rows(&self) -> u64 {
372        self.rows
373    }
374
375    /// The rows the types, statistics and distributions describe.
376    pub fn sample_size(&self) -> usize {
377        self.sample
378    }
379
380    /// Whether the sample is fewer rows than were read; for a nested
381    /// profile, whether any path kept fewer of its values than it had (then
382    /// [`sample_size`](Self::sample_size) is the most values a path keeps).
383    pub fn sampled(&self) -> bool {
384        match &self.paths {
385            Some(paths) => paths.sampled,
386            None => (self.sample as u64) < self.rows,
387        }
388    }
389
390    /// One entry per column, in order.
391    pub fn columns(&self) -> &[ColumnProfile] {
392        &self.columns
393    }
394
395    /// The column called `name`.
396    pub fn column(&self, name: &str) -> Option<&ColumnProfile> {
397        self.columns.iter().find(|c| c.name == name)
398    }
399
400    /// Where the nulls are.
401    pub fn missing(&self) -> &MissingMap {
402        &self.missing
403    }
404
405    /// The notes, in order.
406    pub fn notes(&self) -> &[String] {
407        &self.notes
408    }
409
410    /// Whether this is a nested profile of JSON paths ([`JsonProfiler`]):
411    /// its columns are paths, each with its presence and type mix.
412    pub fn is_nested(&self) -> bool {
413        self.paths.is_some()
414    }
415
416    /// A nested profile's limits, and the paths past its cap; `None` for a
417    /// profile of rows.
418    pub fn paths(&self) -> Option<&PathSummary> {
419        self.paths.as_ref()
420    }
421
422    /// Lines skipped because they were not JSON (a nested profile only).
423    pub fn malformed(&self) -> u64 {
424        self.malformed
425    }
426
427    /// The first of them by line number (at most
428    /// [`MAX_MALFORMED_LINES`]).
429    pub fn malformed_lines(&self) -> &[usize] {
430        &self.malformed_lines
431    }
432
433    /// Style the table of columns by value (`rich_ext`'s
434    /// [`table::rules`](rich_ext::table::rules)). A rule names a heading
435    /// ([`table_headers`](Self::table_headers)) and compares the model's
436    /// value, not the cell's text: `nulls`, `absent` and `distinct` are
437    /// counts, `mean` a number, the rest text.
438    ///
439    /// ```
440    /// use rich::Console;
441    /// use rich_data::profile::Profile;
442    /// use rich_data::Rows;
443    /// use rich_ext::table::{StyleRule, StyleRules};
444    ///
445    /// let mut rows = Rows::new(["a", "b"]);
446    /// rows.push(["1".into(), "".into()]);
447    /// let rules = StyleRules::new().rule(StyleRule::parse("nulls>0:row:bold red").unwrap());
448    /// let profile = Profile::of(&rows).style_rules(rules);
449    /// let console = Console::builder().width(60).force_terminal(true).build();
450    /// let out = console.render_export(&profile.to_table());
451    /// assert!(out.contains("\x1b[1;31mb"), "{out:?}");
452    /// ```
453    #[must_use]
454    pub fn style_rules(mut self, rules: StyleRules) -> Self {
455        self.rules = Rules(rules);
456        self
457    }
458
459    /// The style rules set with [`style_rules`](Self::style_rules).
460    pub fn rules(&self) -> &StyleRules {
461        &self.rules.0
462    }
463
464    /// The headings of the table of columns, which style rules name:
465    /// `column`, `type`, `nulls`, `distinct`, `min`, `max` and `mean`; for
466    /// a nested profile `path`, `types` (the mix, `null` among them),
467    /// `absent`, `distinct`, `min`, `max` and `mean`.
468    pub fn table_headers(&self) -> &'static [&'static str] {
469        if self.is_nested() {
470            &["path", "types", "absent", "distinct", "min", "max", "mean"]
471        } else {
472            &["column", "type", "nulls", "distinct", "min", "max", "mean"]
473        }
474    }
475
476    /// One row of the table as style rules compare it, in
477    /// [`table_headers`](Self::table_headers) order.
478    fn rule_values(&self, c: &ColumnProfile) -> Vec<Value> {
479        let text = |v: &Option<String>| v.clone().map_or(Value::Null, Value::Str);
480        let count = |n: u64| Value::Int(i64::try_from(n).unwrap_or(i64::MAX));
481        let mut values = vec![Value::Str(c.name.clone())];
482        if self.is_nested() {
483            values.push(Value::Str(c.types.map(|t| t.mix()).unwrap_or_default()));
484            values.push(count(c.absent.unwrap_or(0)));
485        } else {
486            values.push(Value::Str(c.data_type.clone()));
487            values.push(count(c.nulls));
488        }
489        values.extend([
490            count(c.distinct as u64),
491            text(&c.min),
492            text(&c.max),
493            c.mean.map_or(Value::Null, Value::Float),
494        ]);
495        values
496    }
497
498    /// The profile as JSON: `rows`, `sample`, `sampled`, `columns`,
499    /// `missing`, and `name` and `notes` when set. A nested profile adds
500    /// `paths` (its `depth`, `max_paths`, whether it was `capped` and the
501    /// paths `dropped`), `malformed` and `malformed_lines` when lines were
502    /// skipped, and per column `present`, `absent`, `values`, `types` and
503    /// `mixed`.
504    pub fn to_json(&self) -> serde_json::Value {
505        let mut value = serde_json::to_value(self).unwrap_or_default();
506        if let Some(object) = value.as_object_mut() {
507            object.insert("sampled".into(), self.sampled().into());
508        }
509        value
510    }
511
512    /// The heading: the name, the rows (and the sample, when smaller) and
513    /// the columns.
514    pub fn heading(&self) -> String {
515        let count = self.columns.len() as u64;
516        let body = if self.is_nested() {
517            // Records and paths; each path is sampled on its own.
518            let mut body = format!(
519                "{} {}, {} {}",
520                grouped(self.rows),
521                plural(self.rows, "record", "records"),
522                grouped(count),
523                plural(count, "path", "paths")
524            );
525            if self.sampled() {
526                body.push_str(&format!(
527                    ", sampled to {} values a path",
528                    grouped(self.sample as u64)
529                ));
530            }
531            body
532        } else {
533            let rows = if self.sampled() {
534                format!(
535                    "sampled {} of {} rows",
536                    grouped(self.sample as u64),
537                    grouped(self.rows)
538                )
539            } else {
540                format!(
541                    "{} {}",
542                    grouped(self.rows),
543                    plural(self.rows, "row", "rows")
544                )
545            };
546            format!("{rows}, {count} {}", plural(count, "column", "columns"))
547        };
548        match &self.name {
549            Some(name) => format!("{}: {body}", sanitize_single_line(name)),
550            None => body,
551        }
552    }
553
554    /// The table of columns: type, nulls, distinct, min, max and mean (the
555    /// median and quartiles are in the model and under each numeric
556    /// column's heading).
557    ///
558    /// A nested profile's table has each path's type mix (`str 90% · null
559    /// 10%`, with `!` before the mix of a path holding more than one type
560    /// besides `null`) and the records that lack it (`absent`), in place of
561    /// the inferred type and the nulls (the mix has their share).
562    pub fn to_table(&self) -> Table {
563        self.table(None, Hold::NamesAndMixes)
564    }
565
566    /// [`to_table`](Self::to_table), its columns held as `hold` says, and
567    /// mixed type mixes styled from `console`'s theme when there is one.
568    fn table(&self, console: Option<&Console>, hold: Hold) -> Table {
569        let names_first = hold != Hold::Nothing;
570        let mut table = Table::new();
571        let headers = self.table_headers();
572        // Names give way last.
573        let widest = self
574            .columns
575            .iter()
576            .map(|c| c.name.chars().count())
577            .max()
578            .unwrap_or(0);
579        // A nested profile's paths are held whole (up to 24) and not
580        // narrowed before the other columns: paths differ at their ends.
581        let nested_names = names_first && self.is_nested();
582        table.add_column_with(
583            Text::new(headers[0]),
584            ColumnOptions {
585                min_width: names_first.then(|| widest.clamp(6, if nested_names { 24 } else { 16 })),
586                no_wrap: nested_names,
587                ..ColumnOptions::default()
588            },
589        );
590        // A nested profile's type mixes give way after the names.
591        let mixes = hold == Hold::NamesAndMixes && self.is_nested();
592        table.add_column_with(
593            Text::new(headers[1]),
594            ColumnOptions {
595                no_wrap: mixes,
596                ..ColumnOptions::default()
597            },
598        );
599        for heading in &headers[2..] {
600            table.add_column_justify(*heading, Justify::Right);
601        }
602        let mixed = console.map(|console| style(console, "profile.mixed", STYLES));
603        let rules = &self.rules.0;
604        let values: Vec<Vec<Value>> = if rules.is_empty() {
605            Vec::new()
606        } else {
607            self.columns.iter().map(|c| self.rule_values(c)).collect()
608        };
609        let resolved = (!rules.is_empty()).then(|| rules.resolve(headers, &values));
610        for (index, c) in self.columns.iter().enumerate() {
611            let optional = |v: Option<f64>| v.map(number).unwrap_or_default();
612            let text = |v: &Option<String>| v.as_deref().map(label).unwrap_or_default();
613            let mut cells = vec![Text::new(sanitize_single_line(&c.name))];
614            if self.is_nested() {
615                let mix = c.types.unwrap_or_default().mix();
616                cells.push(match (c.mixed == Some(true), &mixed) {
617                    (true, Some(style)) => Text::styled(format!("! {mix}"), style.clone()),
618                    (true, None) => Text::new(format!("! {mix}")),
619                    (false, _) => Text::new(mix),
620                });
621                cells.push(Text::new(nulls(
622                    c.absent.unwrap_or(0),
623                    c.absent_rate(self.rows).unwrap_or(0.0),
624                )));
625            } else {
626                cells.push(Text::new(c.data_type.clone()));
627                // A nested path's nulls are in its type mix.
628                cells.push(Text::new(nulls(c.nulls, c.null_rate)));
629            }
630            // An object or array walked into has no values of its own.
631            let types = c.types.unwrap_or_default();
632            let container = c.count == 0 && types.object + types.array > 0;
633            cells.extend(
634                [
635                    if container {
636                        String::new()
637                    } else {
638                        c.distinct.to_string()
639                    },
640                    text(&c.min),
641                    text(&c.max),
642                    optional(c.mean),
643                ]
644                .into_iter()
645                .map(Text::new),
646            );
647            let (Some(resolved), Some(row)) = (&resolved, values.get(index)) else {
648                table.add_row_text(cells);
649                continue;
650            };
651            let cells = cells
652                .into_iter()
653                .enumerate()
654                .map(|(column, mut text)| {
655                    if let Some(style) = resolved.cell_style(row, column) {
656                        let len = text.plain().len();
657                        text.stylize(style, 0, len);
658                    }
659                    Cell::Text(text)
660                })
661                .collect();
662            table.add_row_with(cells, resolved.row_style(row).map(Into::into), false);
663        }
664        // A note row when a nested profile met its cap.
665        if let Some(paths) = self.paths.as_ref().filter(|p| p.capped) {
666            let more = paths.dropped.len() as u64;
667            let count = if paths.more_dropped {
668                format!("{}+", grouped(more))
669            } else {
670                grouped(more)
671            };
672            let note = format!(
673                "{count} more {} not profiled (max_paths {})",
674                plural(more, "path", "paths"),
675                grouped(paths.max_paths as u64)
676            );
677            let dim = console.map(|console| style(console, "profile.note", STYLES));
678            let cell = |text: String| match &dim {
679                Some(style) => Text::styled(text, style.clone()),
680                None => Text::new(text),
681            };
682            table.add_row_text(vec![cell("…".into()), cell(note)]);
683        }
684        table
685    }
686
687    /// The missing-value map as a heatmap: a row per bucket, a column per
688    /// column, each cell the percentage of the bucket's cells that are null.
689    /// `None` when nothing is null.
690    ///
691    /// A nested profile counts a record as missing a path when it lacks the
692    /// path or holds only `null` there, and maps only the paths something is
693    /// missing from.
694    pub fn missing_heatmap(&self) -> Option<Heatmap> {
695        if !self.is_nested() && self.columns.iter().all(|c| c.nulls == 0) {
696            return None;
697        }
698        let missing = |i: usize| {
699            self.missing
700                .buckets
701                .iter()
702                .any(|b| b.nulls.get(i).is_some_and(|&n| n > 0))
703        };
704        // Every column, or for a nested profile those with anything missing.
705        let shown: Vec<usize> = (0..self.columns.len())
706            .filter(|&i| !self.is_nested() || missing(i))
707            .collect();
708        if shown.is_empty() {
709            return None;
710        }
711        let widest = shown
712            .iter()
713            .map(|&i| self.columns[i].name.chars().count())
714            .max()
715            .unwrap_or(1);
716        let mut map = Heatmap::new()
717            .columns(
718                shown
719                    .iter()
720                    .map(|&i| sanitize_single_line(&self.columns[i].name)),
721            )
722            .range(0.0, 100.0)
723            .cell_width((widest + 1).clamp(2, 12));
724        for bucket in &self.missing.buckets {
725            let last = bucket.first + bucket.rows.saturating_sub(1);
726            let label = if bucket.rows <= 1 {
727                bucket.first.to_string()
728            } else {
729                format!("{}-{last}", bucket.first)
730            };
731            let rows = bucket.rows.max(1) as f64;
732            let share =
733                |&i: &usize| bucket.nulls.get(i).copied().unwrap_or(0) as f64 * 100.0 / rows;
734            map = map.row(label, shown.iter().map(share));
735        }
736        Some(map)
737    }
738}
739
740/// Which columns of the table of columns keep their width when it is tight.
741#[derive(Clone, Copy, PartialEq, Eq)]
742enum Hold {
743    /// The names (up to 16) and a nested profile's type mixes (up to 20).
744    NamesAndMixes,
745    /// The names alone.
746    Names,
747    /// Neither.
748    Nothing,
749}
750
751/// A column's distribution as bars: the histogram's bins, or the most
752/// common values.
753fn distribution_chart(distribution: &Distribution) -> Option<BarChart> {
754    let bars: Vec<(String, f64)> = match distribution {
755        Distribution::Histogram { bins } => bins
756            .iter()
757            .map(|b| (b.label.clone(), b.count as f64))
758            .collect(),
759        Distribution::Top { values, .. } => values
760            .iter()
761            .map(|v| (label(&v.value), v.count as f64))
762            .collect(),
763        Distribution::Empty => return None,
764    };
765    Some(BarChart::from_pairs(bars).bar_width(BAR_WIDTH))
766}
767
768impl Renderable for Profile {
769    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
770        let width = options.max_width;
771        if width == 0 {
772            return Vec::new();
773        }
774        let options = options.reset_height();
775        let note = style(console, "profile.note", STYLES);
776        let mut lines: Vec<Vec<Segment>> = Vec::new();
777        let text = |text: Text, indent: usize, lines: &mut Vec<Vec<Segment>>| {
778            block(console, &options, &text, indent, lines);
779        };
780
781        text(
782            Text::styled(self.heading(), style(console, "profile.title", STYLES)),
783            0,
784            &mut lines,
785        );
786        if self.sampled() {
787            let says = if self.is_nested() {
788                "types, distinct values, statistics and distributions are of a uniform sample \
789                 of each path's values; presence, type mixes, nulls and the missing-value map \
790                 count every record"
791            } else {
792                "types, distinct values, statistics and distributions are of a uniform sample; \
793                 nulls and the missing-value map count every row"
794            };
795            text(Text::styled(says, note.clone()), 0, &mut lines);
796        }
797        for line in &self.notes {
798            text(
799                Text::styled(sanitize_single_line(line), note.clone()),
800                0,
801                &mut lines,
802            );
803        }
804        if self.columns.is_empty() {
805            return join(lines);
806        }
807        lines.push(Vec::new());
808        // A name column (and a nested profile's type mixes) held wide can
809        // push the table past a narrow width; then the mixes give way, then
810        // the names too. A nested profile's columns also give way rather
811        // than cut another column's heading.
812        let fits = |table: &Table| {
813            let drawn = console.render(table, Some(&options));
814            let lines = Segment::split_lines(&drawn);
815            let within = lines.iter().all(|line| {
816                line.iter().map(Segment::cell_length).sum::<usize>() <= options.max_width
817            });
818            let headings = || {
819                let heading: String = lines
820                    .get(1)
821                    .map(|line| line.iter().map(|s| s.text.to_string()).collect())
822                    .unwrap_or_default();
823                self.table_headers().iter().all(|h| heading.contains(h))
824            };
825            within && (!self.is_nested() || headings())
826        };
827        let mut table = self.table(Some(console), Hold::NamesAndMixes);
828        let mut fit = fits(&table);
829        if self.is_nested() && !fit {
830            // Whole paths with the mixes wrapped, while the six other
831            // columns keep about eight cells each.
832            let names = self
833                .columns
834                .iter()
835                .map(|c| c.name.chars().count().min(24))
836                .max()
837                .unwrap_or(0);
838            if options.max_width >= names + 3 + 48 {
839                table = self.table(Some(console), Hold::Names);
840                let drawn = console.render(&table, Some(&options));
841                fit = Segment::split_lines(&drawn).iter().all(|line| {
842                    line.iter().map(Segment::cell_length).sum::<usize>() <= options.max_width
843                });
844            }
845        }
846        if !fit {
847            table = self.table(Some(console), Hold::Nothing);
848        }
849        block(console, &options, &table, 0, &mut lines);
850
851        for c in &self.columns {
852            lines.push(Vec::new());
853            let mut title = Text::styled(
854                sanitize_single_line(&c.name),
855                style(console, "profile.column", STYLES),
856            );
857            title.append(" · ", None);
858            title.append(
859                &c.data_type,
860                Some(style(console, "profile.type", STYLES).into()),
861            );
862            // A nested path: its type mix when it holds more than one type
863            // besides null, and the records that lack it.
864            let types = c.types.unwrap_or_default();
865            if c.mixed == Some(true) {
866                title.append(" · ", None);
867                title.append(
868                    &format!("mixed types: {}", types.mix()),
869                    Some(style(console, "profile.mixed", STYLES).into()),
870                );
871            }
872            if let Some(absent) = c.absent.filter(|&n| n > 0) {
873                title.append(" · ", None);
874                title.append(
875                    &format!(
876                        "{} absent ({})",
877                        grouped(absent),
878                        percent(c.absent_rate(self.rows).unwrap_or(0.0))
879                    ),
880                    Some(style(console, "profile.null", STYLES).into()),
881                );
882            }
883            if c.nulls > 0 {
884                title.append(" · ", None);
885                title.append(
886                    &format!(
887                        "{} {} ({})",
888                        grouped(c.nulls),
889                        plural(c.nulls, "null", "nulls"),
890                        percent(c.null_rate)
891                    ),
892                    Some(style(console, "profile.null", STYLES).into()),
893                );
894            }
895            // A nested object or array whose own paths are profiled has no
896            // values of its own to show.
897            let container = c.count == 0 && types.object + types.array > 0;
898            if matches!(c.distribution, Distribution::Histogram { .. }) {
899                let quartiles = [c.p25, c.median, c.p75].map(|q| q.map(number));
900                if let [Some(p25), Some(median), Some(p75)] = quartiles {
901                    title.append(&format!(" · median {median} (p25 {p25}, p75 {p75})"), None);
902                }
903            } else if !container {
904                title.append(&format!(" · {} distinct", grouped(c.distinct as u64)), None);
905            }
906            text(title, 0, &mut lines);
907            match distribution_chart(&c.distribution) {
908                Some(chart) => block(console, &options, &chart, 2, &mut lines),
909                None if container => {}
910                None => text(Text::styled("no values", note.clone()), 2, &mut lines),
911            }
912            if let Distribution::Top { values, other } = &c.distribution
913                && *other > 0
914            {
915                let more = c.distinct.saturating_sub(values.len()) as u64;
916                text(
917                    Text::styled(
918                        format!(
919                            "+ {} other {} ({} {})",
920                            grouped(more),
921                            plural(more, "value", "values"),
922                            grouped(*other as u64),
923                            plural(*other as u64, "cell", "cells"),
924                        ),
925                        note.clone(),
926                    ),
927                    2,
928                    &mut lines,
929                );
930            }
931        }
932
933        lines.push(Vec::new());
934        match self.missing_heatmap() {
935            None => text(
936                Text::styled("no missing values", note.clone()),
937                0,
938                &mut lines,
939            ),
940            Some(map) => {
941                let rows = self.missing.bucket_rows;
942                let (what, row, rows_name) = if self.is_nested() {
943                    ("% of records absent or null", "a record", "records")
944                } else {
945                    ("% null", "a row", "rows")
946                };
947                let per = if rows <= 1 {
948                    format!("{row} each")
949                } else {
950                    format!("{} {rows_name} each", grouped(rows))
951                };
952                text(
953                    Text::styled(
954                        format!("missing values ({what}; {rows_name} top to bottom, {per})"),
955                        style(console, "profile.column", STYLES),
956                    ),
957                    0,
958                    &mut lines,
959                );
960                block(console, &options, &map, 0, &mut lines);
961            }
962        }
963        join(lines)
964    }
965
966    /// As wide as its widest line at the width given.
967    fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
968        let widest = Segment::split_lines(&self.rich_render(console, options))
969            .iter()
970            .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
971            .max()
972            .unwrap_or(0);
973        rich::measure::Measurement::new(widest, widest)
974    }
975}
976
977/// Render `renderable` `indent` cells in, adding its lines to `lines`.
978pub(crate) fn block(
979    console: &Console,
980    options: &ConsoleOptions,
981    renderable: &dyn Renderable,
982    indent: usize,
983    lines: &mut Vec<Vec<Segment>>,
984) {
985    let width = options.max_width.saturating_sub(indent).max(1);
986    let rendered = console.render(renderable, Some(&options.update_width(width)));
987    for line in Segment::split_lines(&rendered) {
988        let mut row = Vec::with_capacity(line.len() + 1);
989        if indent > 0 {
990            row.push(Segment::new(" ".repeat(indent), None));
991        }
992        row.extend(line);
993        lines.push(row);
994    }
995}
996
997/// Lines separated by newlines.
998pub(crate) fn join(lines: Vec<Vec<Segment>>) -> Vec<Segment> {
999    let mut out = Vec::new();
1000    for (i, line) in lines.into_iter().enumerate() {
1001        if i > 0 {
1002            out.push(Segment::line());
1003        }
1004        out.extend(line);
1005    }
1006    out
1007}
1008
1009/// Profiles rows one at a time in bounded memory. See the
1010/// [module docs](self).
1011///
1012/// ```
1013/// use rich_data::profile::{ProfileOptions, Profiler};
1014/// use rich_data::Value;
1015///
1016/// let options = ProfileOptions { sample: 100, ..Default::default() };
1017/// let mut profiler = Profiler::new(&["n".to_string()], options).unwrap();
1018/// for n in 0..10_000 {
1019///     profiler.push(vec![Value::Int(n)]);
1020/// }
1021/// let profile = profiler.finish();
1022/// assert_eq!((profile.rows(), profile.sample_size()), (10_000, 100));
1023/// assert!(profile.sampled());
1024/// assert!(profile.heading().starts_with("sampled 100 of 10,000 rows"));
1025/// ```
1026#[derive(Clone, Debug)]
1027pub struct Profiler {
1028    options: ProfileOptions,
1029    names: Vec<String>,
1030    /// The source column of each profiled column.
1031    indices: Vec<usize>,
1032    rows: u64,
1033    reservoir: Vec<Row>,
1034    rng: u64,
1035    nulls: Vec<u64>,
1036    bucket_rows: u64,
1037    buckets: Vec<(u64, Vec<u64>)>,
1038}
1039
1040impl Profiler {
1041    /// A profiler for rows with `columns`. Fails when
1042    /// [`ProfileOptions::columns`] names a column there is not.
1043    pub fn new(columns: &[String], options: ProfileOptions) -> Result<Self, DataError> {
1044        let indices: Vec<usize> = match &options.columns {
1045            None => (0..columns.len()).collect(),
1046            Some(wanted) => wanted
1047                .iter()
1048                .map(|name| {
1049                    columns.iter().position(|c| c == name).ok_or_else(|| {
1050                        DataError::new(format!(
1051                            "no column {:?}; the columns are {}",
1052                            sanitize_single_line(name),
1053                            columns
1054                                .iter()
1055                                .map(|c| sanitize_single_line(c))
1056                                .collect::<Vec<_>>()
1057                                .join(", ")
1058                        ))
1059                    })
1060                })
1061                .collect::<Result<_, _>>()?,
1062        };
1063        Ok(Profiler {
1064            names: indices.iter().map(|&i| columns[i].clone()).collect(),
1065            nulls: vec![0; indices.len()],
1066            indices,
1067            rows: 0,
1068            reservoir: Vec::new(),
1069            rng: 0x9E37_79B9_7F4A_7C15,
1070            bucket_rows: 1,
1071            buckets: Vec::new(),
1072            options,
1073        })
1074    }
1075
1076    /// Add a row (missing cells are null; extra cells are ignored).
1077    pub fn push(&mut self, row: Row) {
1078        let mut cells: Row = self
1079            .indices
1080            .iter()
1081            .map(|&i| row.get(i).cloned().unwrap_or(Value::Null))
1082            .collect();
1083        // The missing-value map: a new bucket when the last is full, and
1084        // neighbours merged when there would be too many.
1085        if self
1086            .buckets
1087            .last()
1088            .is_none_or(|(rows, _)| *rows >= self.bucket_rows)
1089        {
1090            if self.buckets.len() >= self.options.buckets.max(1) {
1091                self.merge_buckets();
1092            }
1093            if self
1094                .buckets
1095                .last()
1096                .is_none_or(|(rows, _)| *rows >= self.bucket_rows)
1097            {
1098                self.buckets.push((0, vec![0; self.indices.len()]));
1099            }
1100        }
1101        let bucket = self.buckets.last_mut().expect("a bucket was just added");
1102        bucket.0 += 1;
1103        // Null tokens become `Null` in the sample too, so a text column does
1104        // not count `NA` as a value.
1105        for (column, cell) in cells.iter_mut().enumerate() {
1106            if is_null(&self.options.nulls, cell) {
1107                self.nulls[column] += 1;
1108                bucket.1[column] += 1;
1109                *cell = Value::Null;
1110            }
1111        }
1112        // Algorithm R: keep the first `sample` rows, then replace a kept row
1113        // with probability sample / rows seen.
1114        let sample = self.options.sample.max(1);
1115        self.rows += 1;
1116        if self.reservoir.len() < sample {
1117            self.reservoir.push(cells);
1118        } else {
1119            let slot = self.next_random() % self.rows;
1120            if let Some(kept) = self.reservoir.get_mut(slot as usize) {
1121                *kept = cells;
1122            }
1123        }
1124    }
1125
1126    /// The profile of every row pushed.
1127    pub fn finish(self) -> Profile {
1128        let mut sample = Rows::new(self.names.clone());
1129        let size = self.reservoir.len();
1130        for row in self.reservoir {
1131            sample.push(row);
1132        }
1133        let inference = Inferrer::new()
1134            .null_tokens(self.options.nulls.iter().cloned())
1135            .infer(&sample);
1136        inference.apply(&mut sample);
1137        let stats = Stats::with_options(
1138            &sample,
1139            &StatsOptions {
1140                quantiles: vec![0.25, 0.75],
1141                top: self.options.top,
1142            },
1143        );
1144        let rows = self.rows;
1145        let columns = stats
1146            .columns()
1147            .iter()
1148            .zip(inference.columns())
1149            .enumerate()
1150            .map(|(index, (s, inferred))| {
1151                let mut column = describe(
1152                    &sample,
1153                    index,
1154                    s,
1155                    inferred.data_type().name(),
1156                    self.options.bins,
1157                );
1158                column.nulls = self.nulls[index];
1159                column.null_rate = if rows == 0 {
1160                    0.0
1161                } else {
1162                    self.nulls[index] as f64 / rows as f64
1163                };
1164                column
1165            })
1166            .collect();
1167        let mut first = 1;
1168        let buckets = self
1169            .buckets
1170            .into_iter()
1171            .map(|(rows, nulls)| {
1172                let bucket = MissingBucket { first, rows, nulls };
1173                first += rows;
1174                bucket
1175            })
1176            .collect();
1177        Profile {
1178            name: None,
1179            rows,
1180            sample: size,
1181            columns,
1182            missing: MissingMap {
1183                bucket_rows: self.bucket_rows,
1184                buckets,
1185            },
1186            notes: Vec::new(),
1187            paths: None,
1188            malformed: 0,
1189            malformed_lines: Vec::new(),
1190            rules: Rules::default(),
1191        }
1192    }
1193
1194    /// Merge neighbouring buckets, doubling the rows per bucket. Every
1195    /// bucket is full, so each new one covers twice the rows, in order; an
1196    /// odd last one carries on filling.
1197    fn merge_buckets(&mut self) {
1198        let merged = self
1199            .buckets
1200            .chunks(2)
1201            .map(|pair| {
1202                let mut rows = 0;
1203                let mut nulls = vec![0; self.indices.len()];
1204                for (r, n) in pair {
1205                    rows += r;
1206                    for (total, n) in nulls.iter_mut().zip(n) {
1207                        *total += n;
1208                    }
1209                }
1210                (rows, nulls)
1211            })
1212            .collect();
1213        self.buckets = merged;
1214        self.bucket_rows *= 2;
1215    }
1216
1217    /// SplitMix64: a fixed sequence, so a profile is reproducible.
1218    fn next_random(&mut self) -> u64 {
1219        self.rng = self.rng.wrapping_add(0x9E37_79B9_7F4A_7C15);
1220        let mut z = self.rng;
1221        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1222        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1223        z ^ (z >> 31)
1224    }
1225}
1226
1227/// Column `index` of `sample` described from its statistics `s`: its type,
1228/// statistics and distribution, with no nulls counted yet (the caller
1229/// counts them over every row, not the sample).
1230fn describe(
1231    sample: &Rows,
1232    index: usize,
1233    s: &ColumnStats,
1234    data_type: &str,
1235    bins: usize,
1236) -> ColumnProfile {
1237    let distribution = if s.count == 0 {
1238        Distribution::Empty
1239    } else if s.numeric {
1240        let values = sample.column(index).filter_map(|v| {
1241            v.as_f64()
1242                .or_else(|| parse_float(v.plain().trim()))
1243                .filter(|n| n.is_finite())
1244        });
1245        histogram(Histogram::new(values).bins(bins.max(1)))
1246    } else {
1247        let shown: usize = s.top.iter().map(|(_, n)| n).sum();
1248        Distribution::Top {
1249            values: s
1250                .top
1251                .iter()
1252                .map(|(value, count)| TopValue {
1253                    value: value.clone(),
1254                    count: *count,
1255                })
1256                .collect(),
1257            other: s.count - shown,
1258        }
1259    };
1260    let quantile = |q: f64| s.quantiles.iter().find(|(at, _)| *at == q).map(|v| v.1);
1261    ColumnProfile {
1262        name: s.name.clone(),
1263        data_type: data_type.to_string(),
1264        nulls: 0,
1265        null_rate: 0.0,
1266        count: s.count,
1267        distinct: s.distinct,
1268        min: s.min.clone(),
1269        max: s.max.clone(),
1270        mean: s.mean,
1271        median: s.median,
1272        p25: quantile(0.25),
1273        p75: quantile(0.75),
1274        distribution,
1275        present: None,
1276        absent: None,
1277        values: None,
1278        types: None,
1279        mixed: None,
1280    }
1281}
1282
1283/// Whether `value` is null: [`Value::Null`] or one of the `tokens`.
1284fn is_null(tokens: &[String], value: &Value) -> bool {
1285    match value {
1286        Value::Null => true,
1287        Value::Int(_) | Value::Float(_) => false,
1288        Value::Str(text) => tokens.iter().any(|t| t == text.trim()),
1289        Value::Text(text) => {
1290            let plain = text.plain();
1291            tokens.iter().any(|t| t == plain.trim())
1292        }
1293    }
1294}
1295
1296/// A histogram's bins, labelled as its chart labels them.
1297fn histogram(histogram: Histogram) -> Distribution {
1298    let edges = histogram.edges();
1299    let counts = histogram.counts();
1300    let chart = histogram.to_bar_chart();
1301    let bins = chart
1302        .bars()
1303        .iter()
1304        .zip(counts)
1305        .enumerate()
1306        .map(|(i, (bar, count))| Bin {
1307            label: bar.label.clone(),
1308            low: edges[i],
1309            high: edges[i + 1],
1310            count,
1311        })
1312        .collect();
1313    Distribution::Histogram { bins }
1314}
1315
1316/// A value as a label: one line, at most [`LABEL_CHARS`] characters.
1317fn label(value: &str) -> String {
1318    let line = sanitize_single_line(value);
1319    if line.chars().count() <= LABEL_CHARS {
1320        return line;
1321    }
1322    let mut cut: String = line.chars().take(LABEL_CHARS - 1).collect();
1323    cut.push('…');
1324    cut
1325}
1326
1327/// `3 (25%)`, or `0`.
1328fn nulls(count: u64, rate: f64) -> String {
1329    if count == 0 {
1330        "0".to_string()
1331    } else {
1332        format!("{} ({})", grouped(count), percent(rate))
1333    }
1334}
1335
1336/// A rate as a percentage: whole numbers from 1%, one decimal below, and
1337/// never `0%` or `100%` unless exactly so.
1338fn percent(rate: f64) -> String {
1339    let p = rate * 100.0;
1340    if (99.5..100.0).contains(&p) {
1341        return ">99%".to_string();
1342    }
1343    if p > 0.0 && p < 0.1 {
1344        return "<0.1%".to_string();
1345    }
1346    if p < 1.0 {
1347        format!("{}%", number((p * 10.0).round() / 10.0))
1348    } else {
1349        format!("{}%", p.round())
1350    }
1351}
1352
1353/// `1234567` as `1,234,567`.
1354fn grouped(n: u64) -> String {
1355    let digits = n.to_string();
1356    let mut out = String::new();
1357    for (i, c) in digits.chars().enumerate() {
1358        if i > 0 && (digits.len() - i).is_multiple_of(3) {
1359            out.push(',');
1360        }
1361        out.push(c);
1362    }
1363    out
1364}
1365
1366fn plural(n: u64, one: &'static str, many: &'static str) -> &'static str {
1367    if n == 1 { one } else { many }
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372    use super::*;
1373
1374    fn profile(columns: &[&str], rows: &[&[&str]], options: ProfileOptions) -> Profile {
1375        let names: Vec<String> = columns.iter().map(|c| c.to_string()).collect();
1376        let mut profiler = Profiler::new(&names, options).unwrap();
1377        for row in rows {
1378            profiler.push(row.iter().map(|c| Value::from(*c)).collect());
1379        }
1380        profiler.finish()
1381    }
1382
1383    #[test]
1384    fn numbers_group_and_rates_round() {
1385        assert_eq!(grouped(0), "0");
1386        assert_eq!(grouped(999), "999");
1387        assert_eq!(grouped(1_000), "1,000");
1388        assert_eq!(grouped(1_234_567), "1,234,567");
1389        assert_eq!(percent(0.25), "25%");
1390        assert_eq!(percent(0.004), "0.4%");
1391        assert_eq!(percent(0.000_01), "<0.1%");
1392        assert_eq!(percent(0.999), ">99%");
1393        assert_eq!(percent(1.0), "100%");
1394    }
1395
1396    #[test]
1397    fn the_map_merges_buckets_in_order() {
1398        let options = ProfileOptions {
1399            buckets: 4,
1400            ..Default::default()
1401        };
1402        let rows: Vec<Vec<&str>> = (0..10)
1403            .map(|i| vec![if i % 3 == 0 { "" } else { "x" }])
1404            .collect();
1405        let rows: Vec<&[&str]> = rows.iter().map(Vec::as_slice).collect();
1406        let p = profile(&["a"], &rows, options);
1407        let map = p.missing();
1408        assert_eq!(map.bucket_rows, 4);
1409        let shape: Vec<(u64, u64, Vec<u64>)> = map
1410            .buckets
1411            .iter()
1412            .map(|b| (b.first, b.rows, b.nulls.clone()))
1413            .collect();
1414        // Nulls at rows 1, 4, 7 and 10.
1415        assert_eq!(shape, [(1, 4, vec![2]), (5, 4, vec![1]), (9, 2, vec![1])]);
1416        assert_eq!(p.columns()[0].nulls, 4);
1417    }
1418
1419    #[test]
1420    fn a_large_input_is_sampled_with_exact_nulls() {
1421        let options = ProfileOptions {
1422            sample: 50,
1423            ..Default::default()
1424        };
1425        let names = vec!["n".to_string(), "s".to_string()];
1426        let mut profiler = Profiler::new(&names, options).unwrap();
1427        for i in 0..5_000i64 {
1428            let s = if i % 10 == 0 {
1429                Value::Null
1430            } else {
1431                Value::from("x")
1432            };
1433            profiler.push(vec![Value::Int(i), s]);
1434        }
1435        let p = profiler.finish();
1436        assert_eq!((p.rows(), p.sample_size(), p.sampled()), (5_000, 50, true));
1437        assert_eq!(p.columns()[1].nulls, 500);
1438        assert_eq!(p.columns()[1].null_rate, 0.1);
1439        // The sample spreads over the input, not just its start.
1440        let max: f64 = p.columns()[0].max.as_deref().unwrap().parse().unwrap();
1441        assert!(max > 2_500.0, "{max}");
1442        assert_eq!(p.to_json()["sampled"], true);
1443        assert_eq!(p.to_json()["sample"], 50);
1444    }
1445
1446    #[test]
1447    fn ragged_and_empty_input_never_panic() {
1448        let names = vec!["a".to_string(), "b".to_string()];
1449        let mut profiler = Profiler::new(&names, ProfileOptions::default()).unwrap();
1450        profiler.push(vec![]);
1451        profiler.push(vec![Value::from("1"), Value::from("2"), Value::from("3")]);
1452        let p = profiler.finish();
1453        assert_eq!(p.columns()[0].nulls, 1);
1454        let console = Console::builder().width(30).color_system(None).build();
1455        console.render_export(&p);
1456        let empty = Profiler::new(&[], ProfileOptions::default())
1457            .unwrap()
1458            .finish();
1459        assert_eq!(console.render_export(&empty), "0 rows, 0 columns\n");
1460        let none = Profiler::new(&names, ProfileOptions::default())
1461            .unwrap()
1462            .finish();
1463        assert!(console.render_export(&none).contains("no values"));
1464    }
1465
1466    #[test]
1467    fn unknown_columns_are_named() {
1468        let options = ProfileOptions {
1469            columns: Some(vec!["b".into(), "z".into()]),
1470            ..Default::default()
1471        };
1472        let error = Profiler::new(&["a".into(), "b".into()], options).unwrap_err();
1473        assert_eq!(error.to_string(), "no column \"z\"; the columns are a, b");
1474    }
1475
1476    #[test]
1477    fn the_model_round_trips_through_json() {
1478        let p = profile(
1479            &["k", "v"],
1480            &[&["a", "1"], &["b", "2.5"], &["a", ""]],
1481            ProfileOptions::default(),
1482        )
1483        .with_name("x.csv");
1484        let json = serde_json::to_string(&p).unwrap();
1485        let back: Profile = serde_json::from_str(&json).unwrap();
1486        assert_eq!(back, p);
1487        assert_eq!(p.columns()[1].data_type, "float");
1488        match &p.columns()[0].distribution {
1489            Distribution::Top { values, other } => {
1490                assert_eq!(values[0].value, "a");
1491                assert_eq!((values[0].count, *other), (2, 0));
1492            }
1493            other => panic!("{other:?}"),
1494        }
1495    }
1496}