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::{parse_float, Inferrer, DEFAULT_NULL_TOKENS};
76use crate::stats::{number, ColumnStats, Stats, StatsOptions};
77use crate::{DataError, Row, RowSource, Rows, Value};
78
79pub mod nested;
80
81pub use nested::{
82    path_key, JsonProfiler, PathSummary, TypeCounts, DEFAULT_DEPTH, DEFAULT_MAX_PATHS,
83    MAX_MALFORMED_LINES,
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    pub fn with_name(mut self, name: impl Into<String>) -> Self {
360        self.name = Some(name.into());
361        self
362    }
363
364    /// Add a note shown under the heading (rows cut to fit, keys seen late).
365    pub fn note(&mut self, note: impl Into<String>) {
366        self.notes.push(note.into());
367    }
368
369    /// Every row read.
370    pub fn rows(&self) -> u64 {
371        self.rows
372    }
373
374    /// The rows the types, statistics and distributions describe.
375    pub fn sample_size(&self) -> usize {
376        self.sample
377    }
378
379    /// Whether the sample is fewer rows than were read; for a nested
380    /// profile, whether any path kept fewer of its values than it had (then
381    /// [`sample_size`](Self::sample_size) is the most values a path keeps).
382    pub fn sampled(&self) -> bool {
383        match &self.paths {
384            Some(paths) => paths.sampled,
385            None => (self.sample as u64) < self.rows,
386        }
387    }
388
389    /// One entry per column, in order.
390    pub fn columns(&self) -> &[ColumnProfile] {
391        &self.columns
392    }
393
394    /// The column called `name`.
395    pub fn column(&self, name: &str) -> Option<&ColumnProfile> {
396        self.columns.iter().find(|c| c.name == name)
397    }
398
399    /// Where the nulls are.
400    pub fn missing(&self) -> &MissingMap {
401        &self.missing
402    }
403
404    /// The notes, in order.
405    pub fn notes(&self) -> &[String] {
406        &self.notes
407    }
408
409    /// Whether this is a nested profile of JSON paths ([`JsonProfiler`]):
410    /// its columns are paths, each with its presence and type mix.
411    pub fn is_nested(&self) -> bool {
412        self.paths.is_some()
413    }
414
415    /// A nested profile's limits, and the paths past its cap; `None` for a
416    /// profile of rows.
417    pub fn paths(&self) -> Option<&PathSummary> {
418        self.paths.as_ref()
419    }
420
421    /// Lines skipped because they were not JSON (a nested profile only).
422    pub fn malformed(&self) -> u64 {
423        self.malformed
424    }
425
426    /// The first of them by line number (at most
427    /// [`MAX_MALFORMED_LINES`]).
428    pub fn malformed_lines(&self) -> &[usize] {
429        &self.malformed_lines
430    }
431
432    /// Style the table of columns by value (`rich_ext`'s
433    /// [`table::rules`](rich_ext::table::rules)). A rule names a heading
434    /// ([`table_headers`](Self::table_headers)) and compares the model's
435    /// value, not the cell's text: `nulls`, `absent` and `distinct` are
436    /// counts, `mean` a number, the rest text.
437    ///
438    /// ```
439    /// use rich::Console;
440    /// use rich_data::profile::Profile;
441    /// use rich_data::Rows;
442    /// use rich_ext::table::{StyleRule, StyleRules};
443    ///
444    /// let mut rows = Rows::new(["a", "b"]);
445    /// rows.push(["1".into(), "".into()]);
446    /// let rules = StyleRules::new().rule(StyleRule::parse("nulls>0:row:bold red").unwrap());
447    /// let profile = Profile::of(&rows).style_rules(rules);
448    /// let console = Console::builder().width(60).force_terminal(true).build();
449    /// let out = console.render_export(&profile.to_table());
450    /// assert!(out.contains("\x1b[1;31mb"), "{out:?}");
451    /// ```
452    pub fn style_rules(mut self, rules: StyleRules) -> Self {
453        self.rules = Rules(rules);
454        self
455    }
456
457    /// The style rules set with [`style_rules`](Self::style_rules).
458    pub fn rules(&self) -> &StyleRules {
459        &self.rules.0
460    }
461
462    /// The headings of the table of columns, which style rules name:
463    /// `column`, `type`, `nulls`, `distinct`, `min`, `max` and `mean`; for
464    /// a nested profile `path`, `types` (the mix, `null` among them),
465    /// `absent`, `distinct`, `min`, `max` and `mean`.
466    pub fn table_headers(&self) -> &'static [&'static str] {
467        if self.is_nested() {
468            &["path", "types", "absent", "distinct", "min", "max", "mean"]
469        } else {
470            &["column", "type", "nulls", "distinct", "min", "max", "mean"]
471        }
472    }
473
474    /// One row of the table as style rules compare it, in
475    /// [`table_headers`](Self::table_headers) order.
476    fn rule_values(&self, c: &ColumnProfile) -> Vec<Value> {
477        let text = |v: &Option<String>| v.clone().map_or(Value::Null, Value::Str);
478        let count = |n: u64| Value::Int(i64::try_from(n).unwrap_or(i64::MAX));
479        let mut values = vec![Value::Str(c.name.clone())];
480        if self.is_nested() {
481            values.push(Value::Str(c.types.map(|t| t.mix()).unwrap_or_default()));
482            values.push(count(c.absent.unwrap_or(0)));
483        } else {
484            values.push(Value::Str(c.data_type.clone()));
485            values.push(count(c.nulls));
486        }
487        values.extend([
488            count(c.distinct as u64),
489            text(&c.min),
490            text(&c.max),
491            c.mean.map_or(Value::Null, Value::Float),
492        ]);
493        values
494    }
495
496    /// The profile as JSON: `rows`, `sample`, `sampled`, `columns`,
497    /// `missing`, and `name` and `notes` when set. A nested profile adds
498    /// `paths` (its `depth`, `max_paths`, whether it was `capped` and the
499    /// paths `dropped`), `malformed` and `malformed_lines` when lines were
500    /// skipped, and per column `present`, `absent`, `values`, `types` and
501    /// `mixed`.
502    pub fn to_json(&self) -> serde_json::Value {
503        let mut value = serde_json::to_value(self).unwrap_or_default();
504        if let Some(object) = value.as_object_mut() {
505            object.insert("sampled".into(), self.sampled().into());
506        }
507        value
508    }
509
510    /// The heading: the name, the rows (and the sample, when smaller) and
511    /// the columns.
512    pub fn heading(&self) -> String {
513        let count = self.columns.len() as u64;
514        let body = if self.is_nested() {
515            // Records and paths; each path is sampled on its own.
516            let mut body = format!(
517                "{} {}, {} {}",
518                grouped(self.rows),
519                plural(self.rows, "record", "records"),
520                grouped(count),
521                plural(count, "path", "paths")
522            );
523            if self.sampled() {
524                body.push_str(&format!(
525                    ", sampled to {} values a path",
526                    grouped(self.sample as u64)
527                ));
528            }
529            body
530        } else {
531            let rows = if self.sampled() {
532                format!(
533                    "sampled {} of {} rows",
534                    grouped(self.sample as u64),
535                    grouped(self.rows)
536                )
537            } else {
538                format!(
539                    "{} {}",
540                    grouped(self.rows),
541                    plural(self.rows, "row", "rows")
542                )
543            };
544            format!("{rows}, {count} {}", plural(count, "column", "columns"))
545        };
546        match &self.name {
547            Some(name) => format!("{}: {body}", sanitize_single_line(name)),
548            None => body,
549        }
550    }
551
552    /// The table of columns: type, nulls, distinct, min, max and mean (the
553    /// median and quartiles are in the model and under each numeric
554    /// column's heading).
555    ///
556    /// A nested profile's table has each path's type mix (`str 90% · null
557    /// 10%`, with `!` before the mix of a path holding more than one type
558    /// besides `null`) and the records that lack it (`absent`), in place of
559    /// the inferred type and the nulls (the mix has their share).
560    pub fn to_table(&self) -> Table {
561        self.table(None, Hold::NamesAndMixes)
562    }
563
564    /// [`to_table`](Self::to_table), its columns held as `hold` says, and
565    /// mixed type mixes styled from `console`'s theme when there is one.
566    fn table(&self, console: Option<&Console>, hold: Hold) -> Table {
567        let names_first = hold != Hold::Nothing;
568        let mut table = Table::new();
569        let headers = self.table_headers();
570        // Names give way last.
571        let widest = self
572            .columns
573            .iter()
574            .map(|c| c.name.chars().count())
575            .max()
576            .unwrap_or(0);
577        // A nested profile's paths are held whole (up to 24) and not
578        // narrowed before the other columns: paths differ at their ends.
579        let nested_names = names_first && self.is_nested();
580        table.add_column_with(
581            Text::new(headers[0]),
582            ColumnOptions {
583                min_width: names_first.then(|| widest.clamp(6, if nested_names { 24 } else { 16 })),
584                no_wrap: nested_names,
585                ..ColumnOptions::default()
586            },
587        );
588        // A nested profile's type mixes give way after the names.
589        let mixes = hold == Hold::NamesAndMixes && self.is_nested();
590        table.add_column_with(
591            Text::new(headers[1]),
592            ColumnOptions {
593                no_wrap: mixes,
594                ..ColumnOptions::default()
595            },
596        );
597        for heading in &headers[2..] {
598            table.add_column_justify(*heading, Justify::Right);
599        }
600        let mixed = console.map(|console| style(console, "profile.mixed", STYLES));
601        let rules = &self.rules.0;
602        let values: Vec<Vec<Value>> = if rules.is_empty() {
603            Vec::new()
604        } else {
605            self.columns.iter().map(|c| self.rule_values(c)).collect()
606        };
607        let resolved = (!rules.is_empty()).then(|| rules.resolve(headers, &values));
608        for (index, c) in self.columns.iter().enumerate() {
609            let optional = |v: Option<f64>| v.map(number).unwrap_or_default();
610            let text = |v: &Option<String>| v.as_deref().map(label).unwrap_or_default();
611            let mut cells = vec![Text::new(sanitize_single_line(&c.name))];
612            if self.is_nested() {
613                let mix = c.types.unwrap_or_default().mix();
614                cells.push(match (c.mixed == Some(true), &mixed) {
615                    (true, Some(style)) => Text::styled(format!("! {mix}"), style.clone()),
616                    (true, None) => Text::new(format!("! {mix}")),
617                    (false, _) => Text::new(mix),
618                });
619                cells.push(Text::new(nulls(
620                    c.absent.unwrap_or(0),
621                    c.absent_rate(self.rows).unwrap_or(0.0),
622                )));
623            } else {
624                cells.push(Text::new(c.data_type.clone()));
625                // A nested path's nulls are in its type mix.
626                cells.push(Text::new(nulls(c.nulls, c.null_rate)));
627            }
628            // An object or array walked into has no values of its own.
629            let types = c.types.unwrap_or_default();
630            let container = c.count == 0 && types.object + types.array > 0;
631            cells.extend(
632                [
633                    if container {
634                        String::new()
635                    } else {
636                        c.distinct.to_string()
637                    },
638                    text(&c.min),
639                    text(&c.max),
640                    optional(c.mean),
641                ]
642                .into_iter()
643                .map(Text::new),
644            );
645            let (Some(resolved), Some(row)) = (&resolved, values.get(index)) else {
646                table.add_row_text(cells);
647                continue;
648            };
649            let cells = cells
650                .into_iter()
651                .enumerate()
652                .map(|(column, mut text)| {
653                    if let Some(style) = resolved.cell_style(row, column) {
654                        let len = text.plain().len();
655                        text.stylize(style, 0, len);
656                    }
657                    Cell::Text(text)
658                })
659                .collect();
660            table.add_row_with(cells, resolved.row_style(row).map(Into::into), false);
661        }
662        // A note row when a nested profile met its cap.
663        if let Some(paths) = self.paths.as_ref().filter(|p| p.capped) {
664            let more = paths.dropped.len() as u64;
665            let count = if paths.more_dropped {
666                format!("{}+", grouped(more))
667            } else {
668                grouped(more)
669            };
670            let note = format!(
671                "{count} more {} not profiled (max_paths {})",
672                plural(more, "path", "paths"),
673                grouped(paths.max_paths as u64)
674            );
675            let dim = console.map(|console| style(console, "profile.note", STYLES));
676            let cell = |text: String| match &dim {
677                Some(style) => Text::styled(text, style.clone()),
678                None => Text::new(text),
679            };
680            table.add_row_text(vec![cell("…".into()), cell(note)]);
681        }
682        table
683    }
684
685    /// The missing-value map as a heatmap: a row per bucket, a column per
686    /// column, each cell the percentage of the bucket's cells that are null.
687    /// `None` when nothing is null.
688    ///
689    /// A nested profile counts a record as missing a path when it lacks the
690    /// path or holds only `null` there, and maps only the paths something is
691    /// missing from.
692    pub fn missing_heatmap(&self) -> Option<Heatmap> {
693        if !self.is_nested() && self.columns.iter().all(|c| c.nulls == 0) {
694            return None;
695        }
696        let missing = |i: usize| {
697            self.missing
698                .buckets
699                .iter()
700                .any(|b| b.nulls.get(i).is_some_and(|&n| n > 0))
701        };
702        // Every column, or for a nested profile those with anything missing.
703        let shown: Vec<usize> = (0..self.columns.len())
704            .filter(|&i| !self.is_nested() || missing(i))
705            .collect();
706        if shown.is_empty() {
707            return None;
708        }
709        let widest = shown
710            .iter()
711            .map(|&i| self.columns[i].name.chars().count())
712            .max()
713            .unwrap_or(1);
714        let mut map = Heatmap::new()
715            .columns(
716                shown
717                    .iter()
718                    .map(|&i| sanitize_single_line(&self.columns[i].name)),
719            )
720            .range(0.0, 100.0)
721            .cell_width((widest + 1).clamp(2, 12));
722        for bucket in &self.missing.buckets {
723            let last = bucket.first + bucket.rows.saturating_sub(1);
724            let label = if bucket.rows <= 1 {
725                bucket.first.to_string()
726            } else {
727                format!("{}-{last}", bucket.first)
728            };
729            let rows = bucket.rows.max(1) as f64;
730            let share =
731                |&i: &usize| bucket.nulls.get(i).copied().unwrap_or(0) as f64 * 100.0 / rows;
732            map = map.row(label, shown.iter().map(share));
733        }
734        Some(map)
735    }
736}
737
738/// Which columns of the table of columns keep their width when it is tight.
739#[derive(Clone, Copy, PartialEq, Eq)]
740enum Hold {
741    /// The names (up to 16) and a nested profile's type mixes (up to 20).
742    NamesAndMixes,
743    /// The names alone.
744    Names,
745    /// Neither.
746    Nothing,
747}
748
749/// A column's distribution as bars: the histogram's bins, or the most
750/// common values.
751fn distribution_chart(distribution: &Distribution) -> Option<BarChart> {
752    let bars: Vec<(String, f64)> = match distribution {
753        Distribution::Histogram { bins } => bins
754            .iter()
755            .map(|b| (b.label.clone(), b.count as f64))
756            .collect(),
757        Distribution::Top { values, .. } => values
758            .iter()
759            .map(|v| (label(&v.value), v.count as f64))
760            .collect(),
761        Distribution::Empty => return None,
762    };
763    Some(BarChart::from_pairs(bars).bar_width(BAR_WIDTH))
764}
765
766impl Renderable for Profile {
767    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
768        let width = options.max_width;
769        if width == 0 {
770            return Vec::new();
771        }
772        let options = options.reset_height();
773        let note = style(console, "profile.note", STYLES);
774        let mut lines: Vec<Vec<Segment>> = Vec::new();
775        let text = |text: Text, indent: usize, lines: &mut Vec<Vec<Segment>>| {
776            block(console, &options, &text, indent, lines);
777        };
778
779        text(
780            Text::styled(self.heading(), style(console, "profile.title", STYLES)),
781            0,
782            &mut lines,
783        );
784        if self.sampled() {
785            let says = if self.is_nested() {
786                "types, distinct values, statistics and distributions are of a uniform sample \
787                 of each path's values; presence, type mixes, nulls and the missing-value map \
788                 count every record"
789            } else {
790                "types, distinct values, statistics and distributions are of a uniform sample; \
791                 nulls and the missing-value map count every row"
792            };
793            text(Text::styled(says, note.clone()), 0, &mut lines);
794        }
795        for line in &self.notes {
796            text(
797                Text::styled(sanitize_single_line(line), note.clone()),
798                0,
799                &mut lines,
800            );
801        }
802        if self.columns.is_empty() {
803            return join(lines);
804        }
805        lines.push(Vec::new());
806        // A name column (and a nested profile's type mixes) held wide can
807        // push the table past a narrow width; then the mixes give way, then
808        // the names too. A nested profile's columns also give way rather
809        // than cut another column's heading.
810        let fits = |table: &Table| {
811            let drawn = console.render(table, Some(&options));
812            let lines = Segment::split_lines(&drawn);
813            let within = lines.iter().all(|line| {
814                line.iter().map(Segment::cell_length).sum::<usize>() <= options.max_width
815            });
816            let headings = || {
817                let heading: String = lines
818                    .get(1)
819                    .map(|line| line.iter().map(|s| s.text.to_string()).collect())
820                    .unwrap_or_default();
821                self.table_headers().iter().all(|h| heading.contains(h))
822            };
823            within && (!self.is_nested() || headings())
824        };
825        let mut table = self.table(Some(console), Hold::NamesAndMixes);
826        let mut fit = fits(&table);
827        if self.is_nested() && !fit {
828            // Whole paths with the mixes wrapped, while the six other
829            // columns keep about eight cells each.
830            let names = self
831                .columns
832                .iter()
833                .map(|c| c.name.chars().count().min(24))
834                .max()
835                .unwrap_or(0);
836            if options.max_width >= names + 3 + 48 {
837                table = self.table(Some(console), Hold::Names);
838                let drawn = console.render(&table, Some(&options));
839                fit = Segment::split_lines(&drawn).iter().all(|line| {
840                    line.iter().map(Segment::cell_length).sum::<usize>() <= options.max_width
841                });
842            }
843        }
844        if !fit {
845            table = self.table(Some(console), Hold::Nothing);
846        }
847        block(console, &options, &table, 0, &mut lines);
848
849        for c in &self.columns {
850            lines.push(Vec::new());
851            let mut title = Text::styled(
852                sanitize_single_line(&c.name),
853                style(console, "profile.column", STYLES),
854            );
855            title.append(" · ", None);
856            title.append(
857                &c.data_type,
858                Some(style(console, "profile.type", STYLES).into()),
859            );
860            // A nested path: its type mix when it holds more than one type
861            // besides null, and the records that lack it.
862            let types = c.types.unwrap_or_default();
863            if c.mixed == Some(true) {
864                title.append(" · ", None);
865                title.append(
866                    &format!("mixed types: {}", types.mix()),
867                    Some(style(console, "profile.mixed", STYLES).into()),
868                );
869            }
870            if let Some(absent) = c.absent.filter(|&n| n > 0) {
871                title.append(" · ", None);
872                title.append(
873                    &format!(
874                        "{} absent ({})",
875                        grouped(absent),
876                        percent(c.absent_rate(self.rows).unwrap_or(0.0))
877                    ),
878                    Some(style(console, "profile.null", STYLES).into()),
879                );
880            }
881            if c.nulls > 0 {
882                title.append(" · ", None);
883                title.append(
884                    &format!(
885                        "{} {} ({})",
886                        grouped(c.nulls),
887                        plural(c.nulls, "null", "nulls"),
888                        percent(c.null_rate)
889                    ),
890                    Some(style(console, "profile.null", STYLES).into()),
891                );
892            }
893            // A nested object or array whose own paths are profiled has no
894            // values of its own to show.
895            let container = c.count == 0 && types.object + types.array > 0;
896            if matches!(c.distribution, Distribution::Histogram { .. }) {
897                let quartiles = [c.p25, c.median, c.p75].map(|q| q.map(number));
898                if let [Some(p25), Some(median), Some(p75)] = quartiles {
899                    title.append(&format!(" · median {median} (p25 {p25}, p75 {p75})"), None);
900                }
901            } else if !container {
902                title.append(&format!(" · {} distinct", grouped(c.distinct as u64)), None);
903            }
904            text(title, 0, &mut lines);
905            match distribution_chart(&c.distribution) {
906                Some(chart) => block(console, &options, &chart, 2, &mut lines),
907                None if container => {}
908                None => text(Text::styled("no values", note.clone()), 2, &mut lines),
909            }
910            if let Distribution::Top { values, other } = &c.distribution {
911                if *other > 0 {
912                    let more = c.distinct.saturating_sub(values.len()) as u64;
913                    text(
914                        Text::styled(
915                            format!(
916                                "+ {} other {} ({} {})",
917                                grouped(more),
918                                plural(more, "value", "values"),
919                                grouped(*other as u64),
920                                plural(*other as u64, "cell", "cells"),
921                            ),
922                            note.clone(),
923                        ),
924                        2,
925                        &mut lines,
926                    );
927                }
928            }
929        }
930
931        lines.push(Vec::new());
932        match self.missing_heatmap() {
933            None => text(
934                Text::styled("no missing values", note.clone()),
935                0,
936                &mut lines,
937            ),
938            Some(map) => {
939                let rows = self.missing.bucket_rows;
940                let (what, row, rows_name) = if self.is_nested() {
941                    ("% of records absent or null", "a record", "records")
942                } else {
943                    ("% null", "a row", "rows")
944                };
945                let per = if rows <= 1 {
946                    format!("{row} each")
947                } else {
948                    format!("{} {rows_name} each", grouped(rows))
949                };
950                text(
951                    Text::styled(
952                        format!("missing values ({what}; {rows_name} top to bottom, {per})"),
953                        style(console, "profile.column", STYLES),
954                    ),
955                    0,
956                    &mut lines,
957                );
958                block(console, &options, &map, 0, &mut lines);
959            }
960        }
961        join(lines)
962    }
963
964    /// As wide as its widest line at the width given.
965    fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
966        let widest = Segment::split_lines(&self.rich_render(console, options))
967            .iter()
968            .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
969            .max()
970            .unwrap_or(0);
971        rich::measure::Measurement::new(widest, widest)
972    }
973}
974
975/// Render `renderable` `indent` cells in, adding its lines to `lines`.
976pub(crate) fn block(
977    console: &Console,
978    options: &ConsoleOptions,
979    renderable: &dyn Renderable,
980    indent: usize,
981    lines: &mut Vec<Vec<Segment>>,
982) {
983    let width = options.max_width.saturating_sub(indent).max(1);
984    let rendered = console.render(renderable, Some(&options.update_width(width)));
985    for line in Segment::split_lines(&rendered) {
986        let mut row = Vec::with_capacity(line.len() + 1);
987        if indent > 0 {
988            row.push(Segment::new(" ".repeat(indent), None));
989        }
990        row.extend(line);
991        lines.push(row);
992    }
993}
994
995/// Lines separated by newlines.
996pub(crate) fn join(lines: Vec<Vec<Segment>>) -> Vec<Segment> {
997    let mut out = Vec::new();
998    for (i, line) in lines.into_iter().enumerate() {
999        if i > 0 {
1000            out.push(Segment::line());
1001        }
1002        out.extend(line);
1003    }
1004    out
1005}
1006
1007/// Profiles rows one at a time in bounded memory. See the
1008/// [module docs](self).
1009///
1010/// ```
1011/// use rich_data::profile::{ProfileOptions, Profiler};
1012/// use rich_data::Value;
1013///
1014/// let options = ProfileOptions { sample: 100, ..Default::default() };
1015/// let mut profiler = Profiler::new(&["n".to_string()], options).unwrap();
1016/// for n in 0..10_000 {
1017///     profiler.push(vec![Value::Int(n)]);
1018/// }
1019/// let profile = profiler.finish();
1020/// assert_eq!((profile.rows(), profile.sample_size()), (10_000, 100));
1021/// assert!(profile.sampled());
1022/// assert!(profile.heading().starts_with("sampled 100 of 10,000 rows"));
1023/// ```
1024#[derive(Clone, Debug)]
1025pub struct Profiler {
1026    options: ProfileOptions,
1027    names: Vec<String>,
1028    /// The source column of each profiled column.
1029    indices: Vec<usize>,
1030    rows: u64,
1031    reservoir: Vec<Row>,
1032    rng: u64,
1033    nulls: Vec<u64>,
1034    bucket_rows: u64,
1035    buckets: Vec<(u64, Vec<u64>)>,
1036}
1037
1038impl Profiler {
1039    /// A profiler for rows with `columns`. Fails when
1040    /// [`ProfileOptions::columns`] names a column there is not.
1041    pub fn new(columns: &[String], options: ProfileOptions) -> Result<Self, DataError> {
1042        let indices: Vec<usize> = match &options.columns {
1043            None => (0..columns.len()).collect(),
1044            Some(wanted) => wanted
1045                .iter()
1046                .map(|name| {
1047                    columns.iter().position(|c| c == name).ok_or_else(|| {
1048                        DataError::new(format!(
1049                            "no column {:?}; the columns are {}",
1050                            sanitize_single_line(name),
1051                            columns
1052                                .iter()
1053                                .map(|c| sanitize_single_line(c))
1054                                .collect::<Vec<_>>()
1055                                .join(", ")
1056                        ))
1057                    })
1058                })
1059                .collect::<Result<_, _>>()?,
1060        };
1061        Ok(Profiler {
1062            names: indices.iter().map(|&i| columns[i].clone()).collect(),
1063            nulls: vec![0; indices.len()],
1064            indices,
1065            rows: 0,
1066            reservoir: Vec::new(),
1067            rng: 0x9E37_79B9_7F4A_7C15,
1068            bucket_rows: 1,
1069            buckets: Vec::new(),
1070            options,
1071        })
1072    }
1073
1074    /// Add a row (missing cells are null; extra cells are ignored).
1075    pub fn push(&mut self, row: Row) {
1076        let mut cells: Row = self
1077            .indices
1078            .iter()
1079            .map(|&i| row.get(i).cloned().unwrap_or(Value::Null))
1080            .collect();
1081        // The missing-value map: a new bucket when the last is full, and
1082        // neighbours merged when there would be too many.
1083        if self
1084            .buckets
1085            .last()
1086            .is_none_or(|(rows, _)| *rows >= self.bucket_rows)
1087        {
1088            if self.buckets.len() >= self.options.buckets.max(1) {
1089                self.merge_buckets();
1090            }
1091            if self
1092                .buckets
1093                .last()
1094                .is_none_or(|(rows, _)| *rows >= self.bucket_rows)
1095            {
1096                self.buckets.push((0, vec![0; self.indices.len()]));
1097            }
1098        }
1099        let bucket = self.buckets.last_mut().expect("a bucket was just added");
1100        bucket.0 += 1;
1101        // Null tokens become `Null` in the sample too, so a text column does
1102        // not count `NA` as a value.
1103        for (column, cell) in cells.iter_mut().enumerate() {
1104            if is_null(&self.options.nulls, cell) {
1105                self.nulls[column] += 1;
1106                bucket.1[column] += 1;
1107                *cell = Value::Null;
1108            }
1109        }
1110        // Algorithm R: keep the first `sample` rows, then replace a kept row
1111        // with probability sample / rows seen.
1112        let sample = self.options.sample.max(1);
1113        self.rows += 1;
1114        if self.reservoir.len() < sample {
1115            self.reservoir.push(cells);
1116        } else {
1117            let slot = self.next_random() % self.rows;
1118            if let Some(kept) = self.reservoir.get_mut(slot as usize) {
1119                *kept = cells;
1120            }
1121        }
1122    }
1123
1124    /// The profile of every row pushed.
1125    pub fn finish(self) -> Profile {
1126        let mut sample = Rows::new(self.names.clone());
1127        let size = self.reservoir.len();
1128        for row in self.reservoir {
1129            sample.push(row);
1130        }
1131        let inference = Inferrer::new()
1132            .null_tokens(self.options.nulls.iter().cloned())
1133            .infer(&sample);
1134        inference.apply(&mut sample);
1135        let stats = Stats::with_options(
1136            &sample,
1137            &StatsOptions {
1138                quantiles: vec![0.25, 0.75],
1139                top: self.options.top,
1140            },
1141        );
1142        let rows = self.rows;
1143        let columns = stats
1144            .columns()
1145            .iter()
1146            .zip(inference.columns())
1147            .enumerate()
1148            .map(|(index, (s, inferred))| {
1149                let mut column = describe(
1150                    &sample,
1151                    index,
1152                    s,
1153                    inferred.data_type().name(),
1154                    self.options.bins,
1155                );
1156                column.nulls = self.nulls[index];
1157                column.null_rate = if rows == 0 {
1158                    0.0
1159                } else {
1160                    self.nulls[index] as f64 / rows as f64
1161                };
1162                column
1163            })
1164            .collect();
1165        let mut first = 1;
1166        let buckets = self
1167            .buckets
1168            .into_iter()
1169            .map(|(rows, nulls)| {
1170                let bucket = MissingBucket { first, rows, nulls };
1171                first += rows;
1172                bucket
1173            })
1174            .collect();
1175        Profile {
1176            name: None,
1177            rows,
1178            sample: size,
1179            columns,
1180            missing: MissingMap {
1181                bucket_rows: self.bucket_rows,
1182                buckets,
1183            },
1184            notes: Vec::new(),
1185            paths: None,
1186            malformed: 0,
1187            malformed_lines: Vec::new(),
1188            rules: Rules::default(),
1189        }
1190    }
1191
1192    /// Merge neighbouring buckets, doubling the rows per bucket. Every
1193    /// bucket is full, so each new one covers twice the rows, in order; an
1194    /// odd last one carries on filling.
1195    fn merge_buckets(&mut self) {
1196        let merged = self
1197            .buckets
1198            .chunks(2)
1199            .map(|pair| {
1200                let mut rows = 0;
1201                let mut nulls = vec![0; self.indices.len()];
1202                for (r, n) in pair {
1203                    rows += r;
1204                    for (total, n) in nulls.iter_mut().zip(n) {
1205                        *total += n;
1206                    }
1207                }
1208                (rows, nulls)
1209            })
1210            .collect();
1211        self.buckets = merged;
1212        self.bucket_rows *= 2;
1213    }
1214
1215    /// SplitMix64: a fixed sequence, so a profile is reproducible.
1216    fn next_random(&mut self) -> u64 {
1217        self.rng = self.rng.wrapping_add(0x9E37_79B9_7F4A_7C15);
1218        let mut z = self.rng;
1219        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1220        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1221        z ^ (z >> 31)
1222    }
1223}
1224
1225/// Column `index` of `sample` described from its statistics `s`: its type,
1226/// statistics and distribution, with no nulls counted yet (the caller
1227/// counts them over every row, not the sample).
1228fn describe(
1229    sample: &Rows,
1230    index: usize,
1231    s: &ColumnStats,
1232    data_type: &str,
1233    bins: usize,
1234) -> ColumnProfile {
1235    let distribution = if s.count == 0 {
1236        Distribution::Empty
1237    } else if s.numeric {
1238        let values = sample.column(index).filter_map(|v| {
1239            v.as_f64()
1240                .or_else(|| parse_float(v.plain().trim()))
1241                .filter(|n| n.is_finite())
1242        });
1243        histogram(Histogram::new(values).bins(bins.max(1)))
1244    } else {
1245        let shown: usize = s.top.iter().map(|(_, n)| n).sum();
1246        Distribution::Top {
1247            values: s
1248                .top
1249                .iter()
1250                .map(|(value, count)| TopValue {
1251                    value: value.clone(),
1252                    count: *count,
1253                })
1254                .collect(),
1255            other: s.count - shown,
1256        }
1257    };
1258    let quantile = |q: f64| s.quantiles.iter().find(|(at, _)| *at == q).map(|v| v.1);
1259    ColumnProfile {
1260        name: s.name.clone(),
1261        data_type: data_type.to_string(),
1262        nulls: 0,
1263        null_rate: 0.0,
1264        count: s.count,
1265        distinct: s.distinct,
1266        min: s.min.clone(),
1267        max: s.max.clone(),
1268        mean: s.mean,
1269        median: s.median,
1270        p25: quantile(0.25),
1271        p75: quantile(0.75),
1272        distribution,
1273        present: None,
1274        absent: None,
1275        values: None,
1276        types: None,
1277        mixed: None,
1278    }
1279}
1280
1281/// Whether `value` is null: [`Value::Null`] or one of the `tokens`.
1282fn is_null(tokens: &[String], value: &Value) -> bool {
1283    match value {
1284        Value::Null => true,
1285        Value::Int(_) | Value::Float(_) => false,
1286        Value::Str(text) => tokens.iter().any(|t| t == text.trim()),
1287        Value::Text(text) => {
1288            let plain = text.plain();
1289            tokens.iter().any(|t| t == plain.trim())
1290        }
1291    }
1292}
1293
1294/// A histogram's bins, labelled as its chart labels them.
1295fn histogram(histogram: Histogram) -> Distribution {
1296    let edges = histogram.edges();
1297    let counts = histogram.counts();
1298    let chart = histogram.to_bar_chart();
1299    let bins = chart
1300        .bars()
1301        .iter()
1302        .zip(counts)
1303        .enumerate()
1304        .map(|(i, (bar, count))| Bin {
1305            label: bar.label.clone(),
1306            low: edges[i],
1307            high: edges[i + 1],
1308            count,
1309        })
1310        .collect();
1311    Distribution::Histogram { bins }
1312}
1313
1314/// A value as a label: one line, at most [`LABEL_CHARS`] characters.
1315fn label(value: &str) -> String {
1316    let line = sanitize_single_line(value);
1317    if line.chars().count() <= LABEL_CHARS {
1318        return line;
1319    }
1320    let mut cut: String = line.chars().take(LABEL_CHARS - 1).collect();
1321    cut.push('…');
1322    cut
1323}
1324
1325/// `3 (25%)`, or `0`.
1326fn nulls(count: u64, rate: f64) -> String {
1327    if count == 0 {
1328        "0".to_string()
1329    } else {
1330        format!("{} ({})", grouped(count), percent(rate))
1331    }
1332}
1333
1334/// A rate as a percentage: whole numbers from 1%, one decimal below, and
1335/// never `0%` or `100%` unless exactly so.
1336fn percent(rate: f64) -> String {
1337    let p = rate * 100.0;
1338    if (99.5..100.0).contains(&p) {
1339        return ">99%".to_string();
1340    }
1341    if p > 0.0 && p < 0.1 {
1342        return "<0.1%".to_string();
1343    }
1344    if p < 1.0 {
1345        format!("{}%", number((p * 10.0).round() / 10.0))
1346    } else {
1347        format!("{}%", p.round())
1348    }
1349}
1350
1351/// `1234567` as `1,234,567`.
1352fn grouped(n: u64) -> String {
1353    let digits = n.to_string();
1354    let mut out = String::new();
1355    for (i, c) in digits.chars().enumerate() {
1356        if i > 0 && (digits.len() - i).is_multiple_of(3) {
1357            out.push(',');
1358        }
1359        out.push(c);
1360    }
1361    out
1362}
1363
1364fn plural(n: u64, one: &'static str, many: &'static str) -> &'static str {
1365    if n == 1 {
1366        one
1367    } else {
1368        many
1369    }
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374    use super::*;
1375
1376    fn profile(columns: &[&str], rows: &[&[&str]], options: ProfileOptions) -> Profile {
1377        let names: Vec<String> = columns.iter().map(|c| c.to_string()).collect();
1378        let mut profiler = Profiler::new(&names, options).unwrap();
1379        for row in rows {
1380            profiler.push(row.iter().map(|c| Value::from(*c)).collect());
1381        }
1382        profiler.finish()
1383    }
1384
1385    #[test]
1386    fn numbers_group_and_rates_round() {
1387        assert_eq!(grouped(0), "0");
1388        assert_eq!(grouped(999), "999");
1389        assert_eq!(grouped(1_000), "1,000");
1390        assert_eq!(grouped(1_234_567), "1,234,567");
1391        assert_eq!(percent(0.25), "25%");
1392        assert_eq!(percent(0.004), "0.4%");
1393        assert_eq!(percent(0.000_01), "<0.1%");
1394        assert_eq!(percent(0.999), ">99%");
1395        assert_eq!(percent(1.0), "100%");
1396    }
1397
1398    #[test]
1399    fn the_map_merges_buckets_in_order() {
1400        let options = ProfileOptions {
1401            buckets: 4,
1402            ..Default::default()
1403        };
1404        let rows: Vec<Vec<&str>> = (0..10)
1405            .map(|i| vec![if i % 3 == 0 { "" } else { "x" }])
1406            .collect();
1407        let rows: Vec<&[&str]> = rows.iter().map(Vec::as_slice).collect();
1408        let p = profile(&["a"], &rows, options);
1409        let map = p.missing();
1410        assert_eq!(map.bucket_rows, 4);
1411        let shape: Vec<(u64, u64, Vec<u64>)> = map
1412            .buckets
1413            .iter()
1414            .map(|b| (b.first, b.rows, b.nulls.clone()))
1415            .collect();
1416        // Nulls at rows 1, 4, 7 and 10.
1417        assert_eq!(shape, [(1, 4, vec![2]), (5, 4, vec![1]), (9, 2, vec![1])]);
1418        assert_eq!(p.columns()[0].nulls, 4);
1419    }
1420
1421    #[test]
1422    fn a_large_input_is_sampled_with_exact_nulls() {
1423        let options = ProfileOptions {
1424            sample: 50,
1425            ..Default::default()
1426        };
1427        let names = vec!["n".to_string(), "s".to_string()];
1428        let mut profiler = Profiler::new(&names, options).unwrap();
1429        for i in 0..5_000i64 {
1430            let s = if i % 10 == 0 {
1431                Value::Null
1432            } else {
1433                Value::from("x")
1434            };
1435            profiler.push(vec![Value::Int(i), s]);
1436        }
1437        let p = profiler.finish();
1438        assert_eq!((p.rows(), p.sample_size(), p.sampled()), (5_000, 50, true));
1439        assert_eq!(p.columns()[1].nulls, 500);
1440        assert_eq!(p.columns()[1].null_rate, 0.1);
1441        // The sample spreads over the input, not just its start.
1442        let max: f64 = p.columns()[0].max.as_deref().unwrap().parse().unwrap();
1443        assert!(max > 2_500.0, "{max}");
1444        assert_eq!(p.to_json()["sampled"], true);
1445        assert_eq!(p.to_json()["sample"], 50);
1446    }
1447
1448    #[test]
1449    fn ragged_and_empty_input_never_panic() {
1450        let names = vec!["a".to_string(), "b".to_string()];
1451        let mut profiler = Profiler::new(&names, ProfileOptions::default()).unwrap();
1452        profiler.push(vec![]);
1453        profiler.push(vec![Value::from("1"), Value::from("2"), Value::from("3")]);
1454        let p = profiler.finish();
1455        assert_eq!(p.columns()[0].nulls, 1);
1456        let console = Console::builder().width(30).color_system(None).build();
1457        console.render_export(&p);
1458        let empty = Profiler::new(&[], ProfileOptions::default())
1459            .unwrap()
1460            .finish();
1461        assert_eq!(console.render_export(&empty), "0 rows, 0 columns\n");
1462        let none = Profiler::new(&names, ProfileOptions::default())
1463            .unwrap()
1464            .finish();
1465        assert!(console.render_export(&none).contains("no values"));
1466    }
1467
1468    #[test]
1469    fn unknown_columns_are_named() {
1470        let options = ProfileOptions {
1471            columns: Some(vec!["b".into(), "z".into()]),
1472            ..Default::default()
1473        };
1474        let error = Profiler::new(&["a".into(), "b".into()], options).unwrap_err();
1475        assert_eq!(error.to_string(), "no column \"z\"; the columns are a, b");
1476    }
1477
1478    #[test]
1479    fn the_model_round_trips_through_json() {
1480        let p = profile(
1481            &["k", "v"],
1482            &[&["a", "1"], &["b", "2.5"], &["a", ""]],
1483            ProfileOptions::default(),
1484        )
1485        .with_name("x.csv");
1486        let json = serde_json::to_string(&p).unwrap();
1487        let back: Profile = serde_json::from_str(&json).unwrap();
1488        assert_eq!(back, p);
1489        assert_eq!(p.columns()[1].data_type, "float");
1490        match &p.columns()[0].distribution {
1491            Distribution::Top { values, other } => {
1492                assert_eq!(values[0].value, "a");
1493                assert_eq!((values[0].count, *other), (2, 0));
1494            }
1495            other => panic!("{other:?}"),
1496        }
1497    }
1498}