Skip to main content

datui_cli/
settings.rs

1//! The option registry: every config key in one table, with its type, default, the
2//! line that documents it, and the flag that sets it for one run, if one does.
3//!
4//! What reads a setting is generated from or checked against this table: the `-c
5//! KEY=VALUE` parser, `datui config keys`, the commented file `datui config init`
6//! writes, `docs/reference/settings.md`, and the flags' help. Adding an option is
7//! adding an entry here and the field it fills in `datui-lib`'s config structs; a test
8//! there fails until the two agree.
9
10/// What a setting's value is. It decides how `-c` reads the text after `=`, and what
11/// the reference says the key takes.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Kind {
14    Bool,
15    /// A whole number, 0 or more.
16    Count,
17    Text,
18    /// A path; `~` and `$VAR` expand.
19    Path,
20    /// A list of strings. `-c` takes `a,b` or a TOML array.
21    List,
22    /// One of these words.
23    Choice(&'static [&'static str]),
24    /// Bytes with a unit: `512MiB`.
25    Size,
26    /// A duration with a unit: `250ms`.
27    Duration,
28    /// A color: a name, `#rrggbb` or `indexed(N)`.
29    Color,
30    /// A value of more than one shape, written as TOML; the text says which.
31    Toml(&'static str),
32    /// An array of tables, written in a file: `[[sources]]`. Not settable with `-c`.
33    Tables,
34}
35
36impl Kind {
37    /// What the reference says the key takes.
38    pub fn describe(&self) -> String {
39        match self {
40            Kind::Bool => "bool".into(),
41            Kind::Count => "integer".into(),
42            Kind::Text => "string".into(),
43            Kind::Path => "path".into(),
44            Kind::List => "list".into(),
45            Kind::Choice(words) => words.join(" \\| "),
46            Kind::Size => "size".into(),
47            Kind::Duration => "duration".into(),
48            Kind::Color => "color".into(),
49            Kind::Toml(shape) => (*shape).into(),
50            Kind::Tables => "tables".into(),
51        }
52    }
53}
54
55/// A setting's default, as TOML.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum DefaultValue {
58    Value(&'static str),
59    /// No value unless one is written; the generated config shows this example.
60    Unset(&'static str),
61    /// A theme color: one default per `theme.mode`.
62    Color {
63        dark: &'static str,
64        light: &'static str,
65    },
66}
67
68/// One config key.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct Setting {
71    /// The dotted key: `section.name`. A key ending `.*` stands for any name there.
72    pub key: &'static str,
73    pub kind: Kind,
74    pub default: DefaultValue,
75    /// One or two sentences: the generated config, the reference and the flag's help.
76    pub doc: &'static str,
77    /// The flag that sets it for one run, without `--`.
78    pub flag: Option<&'static str>,
79    /// The keyword argument of Python's `datui.view()` and `DatuiOptions`.
80    pub kwarg: Option<&'static str>,
81    /// The key a delimited format spec writes it as.
82    pub spec: Option<&'static str>,
83}
84
85const fn s(key: &'static str, kind: Kind, default: DefaultValue, doc: &'static str) -> Setting {
86    Setting {
87        key,
88        kind,
89        default,
90        doc,
91        flag: None,
92        kwarg: None,
93        spec: None,
94    }
95}
96
97impl Setting {
98    const fn flag(mut self, flag: &'static str) -> Self {
99        self.flag = Some(flag);
100        self
101    }
102
103    const fn kwarg(mut self, kwarg: &'static str) -> Self {
104        self.kwarg = Some(kwarg);
105        self
106    }
107
108    const fn spec(mut self, key: &'static str) -> Self {
109        self.spec = Some(key);
110        self
111    }
112
113    /// The section: everything before the last dot.
114    pub fn section(&self) -> &'static str {
115        self.key.rsplit_once('.').map_or("", |(s, _)| s)
116    }
117
118    /// The name inside the section.
119    pub fn name(&self) -> &'static str {
120        self.key.rsplit_once('.').map_or(self.key, |(_, n)| n)
121    }
122
123    /// Whether `key` is this setting: itself, or a name under a `.*` key.
124    pub fn matches(&self, key: &str) -> bool {
125        match self.key.strip_suffix(".*") {
126            Some(prefix) => key
127                .strip_prefix(prefix)
128                .and_then(|rest| rest.strip_prefix('.'))
129                .is_some_and(|name| !name.is_empty() && !name.contains('.')),
130            None => self.key == key,
131        }
132    }
133}
134
135use DefaultValue::{Unset, Value};
136use Kind::*;
137
138const fn color(
139    key: &'static str,
140    dark: &'static str,
141    light: &'static str,
142    doc: &'static str,
143) -> Setting {
144    s(key, Color, DefaultValue::Color { dark, light }, doc)
145}
146
147/// A config section: its title in the generated config and the reference.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct Section {
150    pub name: &'static str,
151    pub title: &'static str,
152    /// Markdown for the reference, under the title.
153    pub intro: &'static str,
154}
155
156/// The sections, in the order the generated config and the reference list them.
157pub const SECTIONS: &[Section] = &[
158    Section {
159        name: "",
160        title: "Top level",
161        intro: "",
162    },
163    Section {
164        name: "read",
165        title: "Read",
166        intro: "How files are read. A file's own layout (delimiter, header, rows to skip) is a flag for that file, not a setting.",
167    },
168    Section {
169        name: "csv",
170        title: "CSV",
171        intro: "CSV, TSV and PSV. A [delimited format spec](../formats/format-specs.md#delimited-text) takes these keys too.",
172    },
173    Section {
174        name: "display",
175        title: "Display",
176        intro: "",
177    },
178    Section {
179        name: "performance",
180        title: "Performance",
181        intro: "The rows the table buffers between reads, and the engine.",
182    },
183    Section {
184        name: "analysis",
185        title: "Analysis",
186        intro: "Analysis, Data Quality and charts.",
187    },
188    Section {
189        name: "chart",
190        title: "Chart",
191        intro: "Charts exported to a file (`e` in the chart view).",
192    },
193    Section {
194        name: "home",
195        title: "Home",
196        intro: "The home screen.",
197    },
198    Section {
199        name: "home.search",
200        title: "Home search",
201        intro: "Searching below the working directory as you type on the home screen.",
202    },
203    Section {
204        name: "cloud",
205        title: "Cloud",
206        intro: "See [Cloud sources](cloud-sources.md) for `[[cloud.connections]]`.",
207    },
208    Section {
209        name: "http",
210        title: "HTTP",
211        intro: "Every request datui makes: HTTP(S) files, cloud stores and their sign-ins.",
212    },
213    Section {
214        name: "query",
215        title: "Query",
216        intro: "",
217    },
218    Section {
219        name: "views",
220        title: "Views",
221        intro: "",
222    },
223    Section {
224        name: "clipboard",
225        title: "Clipboard",
226        intro: "How the copy dialog (`y`) reaches the system clipboard.",
227    },
228    Section {
229        name: "formats",
230        title: "Formats",
231        intro: "Where [format specs](../formats/format-specs.md) and dictionaries are found.",
232    },
233    Section {
234        name: "limits",
235        title: "Limits",
236        intro: "How much of a file some readers take in. Past a limit, the rest is left out or the file is refused, and the note or error names the key that raises the limit.",
237    },
238    Section {
239        name: "log",
240        title: "Log",
241        intro: "",
242    },
243    Section {
244        name: "theme",
245        title: "Theme",
246        intro: "",
247    },
248    Section {
249        name: "theme.colors",
250        title: "Colors",
251        intro: "Each slot takes a name (`red`, `bright_blue`, `default`), `#rrggbb` or `indexed(0-255)`. They override the theme in use, `theme.dark` or `theme.light`, in either mode; a whole theme of your own goes in a file in `themes/`.",
252    },
253    Section {
254        name: "glyphs",
255        title: "Glyphs",
256        intro: "",
257    },
258];
259
260/// Every config key.
261pub const SETTINGS: &[Setting] = &[
262    // Top level
263    s("import", List, Value("[]"), "Config files merged in before this one, in order; this file's own values win. Paths may be relative to this file, or use ~ and $VAR."),
264    s("catalogs", Toml("list of path \\| { path, id, label }"), Value("[]"), "Catalog files elsewhere, listed on the home screen after catalog.toml and the config directory's catalogs/*.toml, each a section; see Catalogs. Each is a path, or { path, id, label } to give it another id or label. Paths may be relative to this file. Adds up across imports."),
265    // [read]
266    s("read.infer_types", Toml("bool \\| list of columns"), Value("true"), "Read string columns as dates, times, durations or numbers where every value parses, after trimming: true for all, false for none, or a list of columns. CSV, and dates in JSON. A column with a leading zero (02134) stays text; a later value that does not parse is null, and the Notes tab counts them.").flag("infer-types").kwarg("infer_types"),
267    s("read.parquet_schema", Choice(&["union", "first"]), Value("\"union\""), "The schema of a partitioned Parquet dataset: union takes every column any file has, from their footers; first lets Polars take one file's schema.").kwarg("parquet_schema"),
268    s("read.decompress_in_memory", Bool, Value("false"), "Decompress a compressed CSV, TSV or PSV into memory instead of to a temp file.").kwarg("decompress_in_memory"),
269    s("read.temp_dir", Path, Unset("\"/tmp\""), "Directory for decompression temp files. Unset, the system's temp directory.").flag("temp-dir").kwarg("temp_dir"),
270    s("read.follow_interval", Duration, Value("\"250ms\""), "With --follow, how often the file is checked for new rows (on Linux, the shortest time between two reads), from 10ms to 1m. Rows appended within one interval arrive in one refresh."),
271    s("read.exact_count_files", Count, Value("50000"), "A dataset of more files than this shows a row count estimated from a sample of its footers, until c in the Info panel counts exactly; 0 always counts exactly."),
272    s("read.memory_warning", Size, Value("\"1GiB\""), "Ask before reading more than this of a file whole into memory (JSON, Avro, ORC, Excel and the other formats read in memory); 0 never asks."),
273    s("read.audio_float", Bool, Value("false"), "Show integer audio samples as float in [-1, 1].").kwarg("audio_float"),
274    // [csv]
275    s("csv.comment", Text, Unset("\"#\""), "Lines starting with this are comments, before the header and among the data.").flag("comment").kwarg("comment").spec("comment"),
276    s("csv.header_join", Text, Value("\" \""), "Joins a column's names when --header-rows names several lines.").kwarg("header_join").spec("header_join"),
277    s("csv.skip_initial_space", Bool, Value("false"), "Ignore the spaces after a delimiter, so padded numbers are numbers and a cell of spaces is null.").flag("skip-initial-space").kwarg("skip_initial_space").spec("skip_initial_space"),
278    s("csv.null_values", List, Value("[]"), "Values read as null: VAL in every column, COL=VAL in column COL only. --null is repeatable and replaces this list.").flag("null").kwarg("null_values").spec("null_values"),
279    s("csv.infer_rows", Count, Value("1000"), "Rows read to infer column types.").flag("infer-rows").kwarg("infer_rows"),
280    s("csv.ignore_errors", Bool, Value("false"), "Skip rows that do not parse instead of failing.").flag("ignore-errors").kwarg("ignore_errors"),
281    // [display]
282    s("display.unicode", Choice(&["auto", "always", "never"]), Value("\"auto\""), "Box-drawing and arrow glyphs, or plain ASCII. auto uses them when the locale is UTF-8, or on Windows when no locale is set."),
283    s("display.row_numbers", Toml("\"auto\" \\| bool"), Value("\"auto\""), "Number rows on the left by their place in the source; a row keeps its number through a sort or filter (# toggles). auto numbers text and logs; true or false turns them on or off for every table.").flag("row-numbers").kwarg("row_numbers"),
284    s("display.row_numbers_start", Count, Value("1"), "The number of the source's first row.").kwarg("row_numbers_start"),
285    s("display.cell_padding", Toml("\"comfortable\" \\| \"compact\" \\| integer"), Value("\"comfortable\""), "Space between columns: comfortable (2 cells), compact (1) or a number of cells."),
286    s("display.column_colors", Bool, Value("true"), "Color cells by column type.").kwarg("column_colors"),
287    s("display.type_row", Bool, Value("true"), "A second header row naming each column's type (D toggles)."),
288    s("display.notes_accent", Bool, Value("true"), "Accent the i key when datui has noticed something about the data."),
289    s("display.mouse", Bool, Value("true"), "Use the mouse: the wheel scrolls and a click selects. false leaves the mouse to the terminal.").flag("mouse"),
290    s("display.scroll_region", Bool, Value("true"), "Scroll by moving lines within the terminal, so a scroll writes only the new lines, not the whole page. false redraws every line that moved, for a terminal that mishandles the move."),
291    s("display.sidebar_width", Count, Unset("70"), "Width of every sidebar, in cells. Unset, each sidebar uses its own width."),
292    s("display.right_align_numbers", Bool, Value("true"), "Right-align numeric columns and their headers.").kwarg("right_align_numbers"),
293    s("display.number_format", Toml("preset \\| table"), Value("\"none\""), "Digit grouping: none, thousands, european, si, swiss, indian, underscore or system, or a [display.number_format] table (, toggles).").flag("number-format").kwarg("number_format"),
294    // [performance]
295    s("performance.pages_ahead", Count, Value("3"), "Pages of rows buffered ahead of the screen.").kwarg("pages_ahead"),
296    s("performance.pages_behind", Count, Value("3"), "Pages of rows buffered behind the screen.").kwarg("pages_behind"),
297    s("performance.max_buffered_rows", Count, Value("100000"), "Most rows the table buffers between reads; 0 for no limit.").kwarg("max_buffered_rows"),
298    s("performance.max_buffered", Size, Value("\"512MiB\""), "Most memory the buffered rows may take, estimated from the schema; 0 for no limit. Rounded up to whole MiB.").kwarg("max_buffered"),
299    s("performance.streaming", Bool, Value("true"), "Use the Polars streaming engine where it applies.").kwarg("streaming"),
300    s("performance.threads", Count, Value("0"), "Most threads Polars computes with; 0 for every core. It limits speed, not memory. POLARS_MAX_THREADS, when set, wins. Applies to the datui command only: the Python module runs its own Polars, sized by POLARS_MAX_THREADS when it first computes."),
301    // [analysis]
302    s("analysis.sample_rows", Count, Value("100000"), "Rows an analysis samples from a larger table, spread across the whole table; 0 reads every row.").flag("sample-rows").kwarg("sample_rows"),
303    s("analysis.chart_rows", Count, Value("10000"), "Rows a chart reads; a larger table is sampled across its whole length."),
304    s("analysis.chart_grid", Bool, Value("false"), "Start charts with a grid at the major ticks (g toggles)."),
305    s("analysis.quality_local_copy", Size, Value("\"2GiB\""), "Largest remote dataset a Data Quality full scan copies into the cache, so it is fetched once and every pass reads the copy; 0 never copies."),
306    s("analysis.sample_memory_limit", Size, Unset("\"8GiB\""), "Most memory a view's sample may take. Unset, the memory available now decides; 0 never warns or stops."),
307    s("chart.export_recipe", Bool, Value("true"), "Embed how an exported chart was made (source path, query, chart, sample) in its PNG, SVG or PDF. The export dialog's Recipe row starts from it."),
308    // [home]
309    s("home.desktop_recents", Bool, Value("true"), "Also list directories from the desktop's recently-used files; never the file names."),
310    s("home.wordmark", Bool, Value("true"), "Show the datui wordmark at the top of the home screen; false shows a one-line title bar instead."),
311    s("home.show_unreadable", Bool, Value("false"), "List files datui cannot read, dimmed (Ctrl+A toggles)."),
312    s("home.hide", List, Value("[]"), "Catalogs not shown, by id: mine (catalog.toml), examples, or a listed file's name; one entry as catalog/id, such as examples/nyc-taxis. Adds up across imports."),
313    s("home.preview_max", Size, Value("\"64MiB\""), "Largest local file whose first rows the home screen previews; 0 turns the preview off."),
314    s("home.search.enabled", Bool, Value("true"), "Search below the working directory as you type."),
315    s("home.search.max_depth", Count, Value("8"), "How many directories deep the search goes."),
316    s("home.search.max_results", Count, Value("1000"), "Matches listed; the rest are counted."),
317    s("home.search.time_budget", Duration, Value("\"1500ms\""), "How long the search walks before keeping what it found."),
318    s("home.search.cross_filesystems", Bool, Value("false"), "Descend into other filesystems, network mounts included."),
319    s("home.search.follow_gitignore", Bool, Value("false"), "Skip what .gitignore ignores."),
320    s("home.search.skip", List, Value("[\"node_modules\", \"target\", \"build\", \"dist\", \"vendor\", \"site-packages\", \"__pycache__\", \"venv\", \"env\"]"), "Directory names never searched. Replaces the defaults; skip_extra adds to them."),
321    s("home.search.skip_extra", List, Value("[]"), "Directory names never searched, besides skip."),
322    s("home.search.extensions", List, Value("[]"), "Extensions searched for; empty means those of the formats datui reads."),
323    // [cloud]
324    s("cloud.connections", Tables, Unset("[]"), "Cloud stores to list on the home screen; see Cloud sources."),
325    s("cloud.hide", List, Value("[]"), "Cloud source IDs not shown on the home screen. Adds up across imports."),
326    s("cloud.use_azure_account_keys", Bool, Value("true"), "Read an Azure account with its access keys when a sign-in has no data role, as the Portal does."),
327    s("cloud.env_files", List, Value("[]"), "Files to read cloud variables from, relative to the working directory, such as .env. Adds up across imports."),
328    s("cloud.instance_identity", Bool, Value("false"), "Use the identity of the cloud VM datui runs on (EC2, GCE, Azure)."),
329    s("cloud.discover", Toml("bool \\| \"all\" \\| \"none\" \\| list"), Unset("true"), "Logins found on this machine that become home-screen sources: all (unset), none, or kinds from s3, gcs, azure."),
330    s("cloud.list_on_start", Bool, Value("false"), "List every source's buckets when the home screen opens, not when one is entered."),
331    // [http]
332    s("http.user_agent", Text, Value("\"\""), "The User-Agent header on every request. Empty sends datui/VERSION (+https://github.com/derekwisong/datui), which names datui and its version and nothing about you."),
333    // [query]
334    s("query.history_limit", Count, Value("1000"), "Queries remembered."),
335    s("query.history", Bool, Value("true"), "Remember queries."),
336    s("query.default_mode", Choice(&["sql", "q"]), Value("\"sql\""), "The language the : command line starts in, until Ctrl+T picks another."),
337    // [views]
338    s("views.auto_apply", Bool, Value("false"), "Apply the best-matching view when a file opens."),
339    // [clipboard]
340    s("clipboard.backend", Choice(&["auto", "native", "osc52"]), Value("\"auto\""), "auto uses the display server where one answers, and osc52 elsewhere (SSH). osc52 is an escape sequence that asks the terminal to copy."),
341    s("clipboard.osc52_limit", Size, Value("\"100KiB\""), "Longest osc52 copy to attempt, as base64. Terminals cap what they accept."),
342    // [formats]
343    s("formats.path", List, Value("[]"), "Directories of format specs and dictionaries, searched after ~/.config/datui/formats and $DATUI_FORMATS_PATH. Adds up across imports."),
344    // [log]
345    s("limits.indexed_records", Count, Value("67108864"), "Most records one pass indexes: a flight log's messages, a candump's frames, a text file's lines, a format spec's tagged records."),
346    s("limits.elf_symbols", Count, Value("10000000"), "Most symbols read from an ELF file."),
347    s("limits.midi_bytes", Size, Value("\"64MiB\""), "Largest MIDI file read; a larger one is refused."),
348    s("limits.midi_events", Count, Value("10000000"), "Most events read from MIDI files, all files of one open together."),
349    s("limits.journal_bytes", Size, Value("\"1GiB\""), "Most journal JSON read into memory, all files of one open together; the records past it are left out, and the Notes tab says how much."),
350    s("limits.detail_rows", Count, Value("10000"), "Most rows of a list on an Info panel tab (symbols, sections, metadata); one more row says how many were left out."),
351    s("limits.sql_depth", Count, Value("24"), "Deepest a SQL statement may nest, each parenthesis, call, subquery or part of a dotted name a level; a deeper statement is refused. Each level past 24 about doubles the time a broken statement can take to reject: half a second at 28, seconds past 30. At most 50, the parser's own limit, which 0 means too."),
352    s("limits.sdf_fields", Count, Value("4096"), "Most fields (data items by name) read from an SDF file, each a column."),
353    s("limits.vcd_signals", Count, Value("1048576"), "Most signals read from a VCD file, each a column."),
354    s("limits.fix_tags", Count, Value("4096"), "Most tags read from a FIX file, each a column."),
355    s("limits.fix_fields", Count, Value("4096"), "Most fields read from one FIX message."),
356    s("limits.gpx_fields", Count, Value("256"), "Most extension fields read from a GPX file, each a column."),
357    s("limits.npy_header_bytes", Size, Value("\"4MiB\""), "Largest NumPy header read; a file with a larger one is refused."),
358    s("log.file", Path, Unset("\"~/datui.log\""), "Where the log is written. Unset, datui.log in the cache directory.").flag("log-file"),
359    s("log.level", Choice(&["error", "warn", "info", "debug", "trace", "off"]), Unset("\"warn\""), "How much the log records (default warn). DATUI_LOG overrides the config file; -c and --log-level override DATUI_LOG.").flag("log-level"),
360    // [theme]
361    s("theme.mode", Choice(&["auto", "dark", "light"]), Unset("\"auto\""), "Which mode's theme to use: theme.dark or theme.light. auto follows the terminal's answer about its background, else its last answer, then COLORFGBG, then dark; it asks again when the terminal regains focus."),
362    s("theme.dark", Text, Value("\"night-market\""), "The theme used when the terminal is dark: night-market, day-market, or a file's name in the config directory's themes/. A name that cannot be used falls back to night-market, with a warning when dark is in use."),
363    s("theme.light", Text, Value("\"day-market\""), "The theme used when the terminal is light: night-market, day-market, or a file's name in the config directory's themes/. A name that cannot be used falls back to day-market, with a warning when light is in use."),
364    color("theme.colors.chip_key", "#7dcfff", "#2e7de9", "Keys named in the footer, dialogs, the breadcrumb and the correlation matrix."),
365    color("theme.colors.chip_label", "#a9b1d6", "#3760bf", "Labels beside keys in the footer, and the footer's status."),
366    color("theme.colors.throbber", "#7dcfff", "#2e7de9", "The busy spinner."),
367    color("theme.colors.success", "#9ece6a", "#587539", "Success."),
368    color("theme.colors.error", "#f7768e", "#f52a65", "Errors."),
369    color("theme.colors.warning", "#e0af68", "#8c6c3e", "Warnings."),
370    color("theme.colors.dimmed", "#565f89", "#848cb5", "Dimmed text, nulls and axes."),
371    color("theme.colors.background", "default", "default", "Main background."),
372    color("theme.colors.surface", "default", "default", "Dialog background."),
373    color("theme.colors.controls_bg", "#262a3f", "#d0d5e3", "Count chips and dialogs' key chips."),
374    color("theme.colors.text_primary", "default", "default", "Text."),
375    color("theme.colors.text_secondary", "#737aa2", "#6172b0", "Secondary text."),
376    color("theme.colors.text_inverse", "#1a1b26", "#e1e2e7", "Text on a key chip."),
377    color("theme.colors.table_header", "#c0caf5", "#3760bf", "Header text."),
378    color("theme.colors.table_header_bg", "#2b3047", "#c4c8da", "Header fill."),
379    color("theme.colors.table_row_numbers", "#565f89", "#848cb5", "The row-number column."),
380    color("theme.colors.table_column_separator", "#3b4261", "#a8aecb", "The rule after frozen columns and beside section titles."),
381    color("theme.colors.table_selected", "#283457", "#b6bfe2", "Tint under the current row; reversed swaps text and background instead."),
382    color("theme.colors.table_column_cursor", "#292e42", "#cbd3f2", "Tint under the column cursor's cells."),
383    color("theme.colors.table_cell_cursor", "#3b4261", "#a0aef0", "The column cursor's header and the current cell."),
384    color("theme.colors.sidebar_border", "#565f89", "#6172b0", "Sidebar and dialog borders."),
385    color("theme.colors.modal_border_active", "#7dcfff", "#2e7de9", "The focused dialog's border."),
386    color("theme.colors.modal_border_error", "#f7768e", "#f52a65", "An error dialog's border."),
387    color("theme.colors.distribution_normal", "#9ece6a", "#587539", "Analysis: a normal distribution."),
388    color("theme.colors.distribution_skewed", "#e0af68", "#8c6c3e", "Analysis: a skewed distribution."),
389    color("theme.colors.distribution_other", "#c0caf5", "#3760bf", "Analysis: other distributions."),
390    color("theme.colors.outlier_marker", "#f7768e", "#f52a65", "Analysis: outliers."),
391    color("theme.colors.input_cursor", "default", "default", "The text caret; default reverses the text under it."),
392    color("theme.colors.input_cursor_text", "default", "default", "Text under the caret block; default picks black or white by contrast."),
393    color("theme.colors.table_alternate_row", "#1e2030", "#dcdfea", "Every other row; default turns the stripe off."),
394    color("theme.colors.type_str", "#9ece6a", "#587539", "String columns."),
395    color("theme.colors.type_int", "#7aa2f7", "#2e7de9", "Integer columns."),
396    color("theme.colors.type_float", "#2ac3de", "#007197", "Float columns."),
397    color("theme.colors.type_bool", "#e0af68", "#8c6c3e", "Boolean columns."),
398    color("theme.colors.type_temporal", "#bb9af7", "#9854f1", "Date, time and datetime columns."),
399    color("theme.colors.type_binary", "#565f89", "#848cb5", "Binary columns' placeholder."),
400    color("theme.colors.chart_1", "#7dcfff", "#2e7de9", "Chart series 1; also histogram bars, bar charts and Q-Q points."),
401    color("theme.colors.chart_2", "#bb9af7", "#9854f1", "Chart series 2."),
402    color("theme.colors.chart_3", "#9ece6a", "#587539", "Chart series 3."),
403    color("theme.colors.chart_4", "#e0af68", "#8c6c3e", "Chart series 4."),
404    color("theme.colors.chart_5", "#7aa2f7", "#007197", "Chart series 5."),
405    color("theme.colors.chart_6", "#f7768e", "#f52a65", "Chart series 6."),
406    color("theme.colors.chart_7", "#ff9e64", "#b15c00", "Chart series 7."),
407    color("theme.colors.chart_8", "#1abc9c", "#118c74", "Chart series 8."),
408    color("theme.colors.chart_9", "#ff5fd2", "#d1188c", "Chart series 9."),
409    color("theme.colors.chart_10", "#f4ef8a", "#24357a", "Chart series 10."),
410    color("theme.colors.chart_grid", "#3d4785", "#70aabf", "The chart grid, a shade dimmer than dimmed."),
411    color("theme.colors.accent", "#7dcfff", "#2e7de9", "Key chips, focused titles and the selection rail."),
412    color("theme.colors.accent_bright", "#a4daff", "#1a6cd0", "The section the cursor is in."),
413    color("theme.colors.gradient_start", "#7aa2f7", "#2e7de9", "The wordmark's first stop."),
414    color("theme.colors.gradient_end", "#bb9af7", "#9854f1", "The wordmark's last stop."),
415    color("theme.colors.find_match", "#e0af68", "#f0c35a", "Behind the cell a find landed on."),
416    color("theme.colors.hex_null", "#565f89", "#848cb5", "Hex view: the byte 0x00."),
417    color("theme.colors.hex_printable", "#7dcfff", "#007197", "Hex view: printable ASCII."),
418    color("theme.colors.hex_whitespace", "#9ece6a", "#587539", "Hex view: whitespace bytes."),
419    color("theme.colors.hex_control", "#bb9af7", "#9854f1", "Hex view: other control bytes."),
420    color("theme.colors.hex_high", "#e0af68", "#8c6c3e", "Hex view: 0x80 to 0xFE."),
421    color("theme.colors.hex_ff", "#f7768e", "#f52a65", "Hex view: the byte 0xFF."),
422    // [glyphs]
423    s("glyphs.*", Toml("string \\| list"), Unset("in_object_store = \"☁\""), "A glyph slot from glyphs.rs, replaced when the Unicode set is active. Keeps the width of the glyph it replaces."),
424];
425
426/// An option of one open, with no config key: it says how to read one file (#289),
427/// so it is a flag, a Python keyword and, for delimited text, a spec key.
428#[derive(Debug, Clone, Copy, PartialEq, Eq)]
429pub struct OpenOption {
430    /// The flag, without `--`.
431    pub flag: &'static str,
432    pub kwarg: &'static str,
433    pub kind: Kind,
434    /// The key a delimited format spec writes it as.
435    pub spec: Option<&'static str>,
436}
437
438const fn open(flag: &'static str, kwarg: &'static str, kind: Kind) -> OpenOption {
439    OpenOption {
440        flag,
441        kwarg,
442        kind,
443        spec: None,
444    }
445}
446
447const fn open_spec(
448    flag: &'static str,
449    kwarg: &'static str,
450    kind: Kind,
451    spec: &'static str,
452) -> OpenOption {
453    OpenOption {
454        flag,
455        kwarg,
456        kind,
457        spec: Some(spec),
458    }
459}
460
461/// The open's own options that Python takes as keywords, beside the config keys'.
462pub const OPEN: &[OpenOption] = &[
463    open("format", "format", Text),
464    open("table", "table", Text),
465    open("hive", "hive", Bool),
466    open(
467        "compression",
468        "compression",
469        Choice(&["gzip", "zstd", "bzip2", "xz"]),
470    ),
471    open("dict", "dict", List),
472    open("view", "view", Text),
473    open_spec("delimiter", "delimiter", Text, "delimiter"),
474    open("no-header", "no_header", Bool),
475    open_spec("header-rows", "header_rows", List, "header_rows"),
476    open("footer-rows", "footer_rows", Count),
477    open("skip-rows", "skip_rows", Count),
478    open_spec("skip-lines", "skip_lines", Count, "skip_lines"),
479];
480
481/// Who an environment variable belongs to, as the reference groups them.
482#[derive(Debug, Clone, Copy, PartialEq, Eq)]
483pub enum EnvGroup {
484    /// datui's own.
485    Datui,
486    /// The terminal's: color, glyphs.
487    Terminal,
488    /// The programs datui hands a value to.
489    Programs,
490    /// The cloud logins, as each provider's own tools read them.
491    Cloud,
492}
493
494impl EnvGroup {
495    /// The group's heading in the reference.
496    pub fn title(self) -> &'static str {
497        match self {
498            Self::Datui => "datui",
499            Self::Terminal => "Terminal",
500            Self::Programs => "Programs datui starts",
501            Self::Cloud => "Cloud logins",
502        }
503    }
504}
505
506/// One environment variable datui reads.
507#[derive(Debug, Clone, Copy, PartialEq, Eq)]
508pub struct EnvVar {
509    /// The name, or names read as one (`AWS_REGION`, `AWS_DEFAULT_REGION`).
510    pub names: &'static [&'static str],
511    pub group: EnvGroup,
512    /// What it does, as markdown.
513    pub doc: &'static str,
514}
515
516const fn env(names: &'static [&'static str], group: EnvGroup, doc: &'static str) -> EnvVar {
517    EnvVar { names, group, doc }
518}
519
520/// The environment variables datui reads, for the reference and the manpage.
521pub const ENVIRONMENT: &[EnvVar] = &[
522    env(
523        &["DATUI_CONFIG_DIR"],
524        EnvGroup::Datui,
525        "The config directory, in place of the platform's (`~/.config/datui` on Linux). Saved views and format specs live there too",
526    ),
527    env(
528        &["DATUI_CACHE_DIR"],
529        EnvGroup::Datui,
530        "The cache directory, in place of the platform's (`~/.cache/datui` on Linux)",
531    ),
532    env(
533        &["DATUI_FORMATS_PATH"],
534        EnvGroup::Datui,
535        "Directories of format specs and dictionaries, separated as `PATH` is, searched before `[formats] path`",
536    ),
537    env(
538        &["DATUI_LOG"],
539        EnvGroup::Datui,
540        "The log level: `error`, `warn`, `info`, `debug`, `trace` or `off`. Overrides `log.level` in a config file; `-c` and `--log-level` override it",
541    ),
542    env(
543        &["DATUI_DEBUG"],
544        EnvGroup::Datui,
545        "`1` shows the debug overlay",
546    ),
547    env(
548        &["DATUI_GCP_PROJECT"],
549        EnvGroup::Datui,
550        "The Google Cloud project to list when projects cannot be searched. Like `GOOGLE_CLOUD_PROJECT`, but read first",
551    ),
552    env(
553        &["DATUI_TRACE_FIRST_ROWS"],
554        EnvGroup::Datui,
555        "A file to write the time (in Unix nanoseconds) to once the first rows are drawn. For benchmarks",
556    ),
557    env(
558        &["NO_COLOR"],
559        EnvGroup::Terminal,
560        "Set to anything to turn off colors: the terminal's own are used for everything",
561    ),
562    env(
563        &["COLORTERM", "TERM", "FORCE_COLOR"],
564        EnvGroup::Terminal,
565        "How many colors the terminal draws: 24-bit, 256 or 16. Theme colors are reduced to fit",
566    ),
567    env(
568        &["COLORFGBG"],
569        EnvGroup::Terminal,
570        "With `theme.mode = \"auto\"`, says whether the background is light or dark, for a terminal that does not answer when asked",
571    ),
572    env(
573        &["TERM_PROGRAM"],
574        EnvGroup::Terminal,
575        "With `theme.mode = \"auto\"`, identifies the terminal, so its last answer about its background picks the first frame's theme; `TERM` is used when it is unset",
576    ),
577    env(
578        &["LC_ALL", "LC_CTYPE", "LANG"],
579        EnvGroup::Terminal,
580        "With `display.unicode = \"auto\"`, the first one set says whether the terminal takes UTF-8; if it does not, glyphs are ASCII. With none set, Windows gets Unicode and other systems ASCII",
581    ),
582    env(
583        &["VISUAL", "EDITOR", "PAGER"],
584        EnvGroup::Programs,
585        "The inspector's `o` opens text in the first one set, else `less` (on Windows, the system's opener)",
586    ),
587    env(
588        &["AWS_PROFILE"],
589        EnvGroup::Cloud,
590        "The AWS profile for `s3://`, else `default`",
591    ),
592    env(
593        &[
594            "AWS_ACCESS_KEY_ID",
595            "AWS_SECRET_ACCESS_KEY",
596            "AWS_SESSION_TOKEN",
597        ],
598        EnvGroup::Cloud,
599        "AWS keys, and the token of temporary ones",
600    ),
601    env(
602        &["AWS_REGION", "AWS_DEFAULT_REGION"],
603        EnvGroup::Cloud,
604        "The AWS region",
605    ),
606    env(
607        &["AWS_ENDPOINT_URL_S3", "AWS_ENDPOINT_URL", "AWS_ENDPOINT"],
608        EnvGroup::Cloud,
609        "An S3-compatible endpoint (MinIO, R2, Ceph); the first one set",
610    ),
611    env(
612        &["AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE"],
613        EnvGroup::Cloud,
614        "The AWS config and credentials files, in place of `~/.aws/config` and `~/.aws/credentials`",
615    ),
616    env(
617        &[
618            "GOOGLE_APPLICATION_CREDENTIALS",
619            "GOOGLE_SERVICE_ACCOUNT",
620            "GOOGLE_SERVICE_ACCOUNT_PATH",
621            "GOOGLE_SERVICE_ACCOUNT_KEY",
622        ],
623        EnvGroup::Cloud,
624        "A Google Cloud service account or credentials file for `gs://`",
625    ),
626    env(
627        &[
628            "GOOGLE_CLOUD_PROJECT",
629            "GCLOUD_PROJECT",
630            "CLOUDSDK_CORE_PROJECT",
631            "GCP_PROJECT",
632        ],
633        EnvGroup::Cloud,
634        "The Google Cloud project to list buckets in, after `DATUI_GCP_PROJECT`; the first one set",
635    ),
636    env(
637        &["CLOUDSDK_CONFIG"],
638        EnvGroup::Cloud,
639        "The `gcloud` configuration directory, in place of `~/.config/gcloud`",
640    ),
641    env(
642        &["AZURE_STORAGE_CONNECTION_STRING"],
643        EnvGroup::Cloud,
644        "An Azure storage connection string, with `AccountKey` or `SharedAccessSignature`",
645    ),
646    env(
647        &[
648            "AZURE_STORAGE_ACCOUNT_NAME",
649            "AZURE_STORAGE_ACCOUNT_KEY",
650            "AZURE_STORAGE_SAS_TOKEN",
651        ],
652        EnvGroup::Cloud,
653        "An Azure storage account and its key or SAS token",
654    ),
655    env(
656        &[
657            "AZURE_TENANT_ID",
658            "AZURE_CLIENT_ID",
659            "AZURE_CLIENT_SECRET",
660            "AZURE_FEDERATED_TOKEN_FILE",
661        ],
662        EnvGroup::Cloud,
663        "An Azure service principal, or AKS workload identity",
664    ),
665    env(
666        &["AZURE_CONFIG_DIR"],
667        EnvGroup::Cloud,
668        "The Azure CLI's directory, in place of `~/.azure`",
669    ),
670];
671
672/// `docs/reference/environment.md`: every variable in [`ENVIRONMENT`], by group.
673pub fn render_environment_markdown() -> String {
674    let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
675    let mut out = String::from(
676        "# Environment variables\n\n\
677         <!-- Generated from crates/datui-cli/src/settings.rs by `gen_docs`. Do not edit. -->\n\n\
678         The variables datui reads.\n",
679    );
680    for group in [
681        EnvGroup::Datui,
682        EnvGroup::Terminal,
683        EnvGroup::Programs,
684        EnvGroup::Cloud,
685    ] {
686        out.push_str(&format!("\n## {}\n\n", group.title()));
687        if group == EnvGroup::Cloud {
688            out.push_str(
689                "Read as each provider's own tools read them; a variable set but empty counts as unset. [Connect to cloud storage](../user-guide/remote-data.md) says which login wins, and `[cloud] env_files` can read them from `.env` files.\n\n",
690            );
691        }
692        out.push_str("| Variable | What it does |\n|---|---|\n");
693        for var in ENVIRONMENT.iter().filter(|v| v.group == group) {
694            let names: Vec<String> = var.names.iter().map(|n| format!("`{n}`")).collect();
695            out.push_str(&format!("| {} | {} |\n", names.join(", "), cell(var.doc)));
696        }
697    }
698    out
699}
700
701/// The setting `key` names, if any.
702pub fn find(key: &str) -> Option<&'static Setting> {
703    SETTINGS.iter().find(|s| s.matches(key))
704}
705
706/// A flag's help: its setting's doc and key. Panics on a flag no setting names, which
707/// `--help` and the tests reach.
708pub fn flag_help(flag: &str) -> String {
709    let setting = by_flag(flag).unwrap_or_else(|| panic!("--{flag} sets no registered key"));
710    format!("{} [config: {}]", setting.doc, setting.key)
711}
712
713/// The setting a Python keyword sets, if one does.
714pub fn by_kwarg(kwarg: &str) -> Option<&'static Setting> {
715    SETTINGS.iter().find(|s| s.kwarg == Some(kwarg))
716}
717
718/// The setting a flag sets, if one does.
719pub fn by_flag(flag: &str) -> Option<&'static Setting> {
720    SETTINGS.iter().find(|s| s.flag == Some(flag))
721}
722
723/// The settings of `section`, in table order.
724pub fn in_section(section: &str) -> impl Iterator<Item = &'static Setting> + '_ {
725    SETTINGS.iter().filter(move |s| s.section() == section)
726}
727
728/// How a value of each type is written, as Markdown: the settings reference and
729/// datui-config(5).
730pub const TYPES: &[(&str, &str)] = &[
731    (
732        "size",
733        "A number and a unit: `512MiB`, `2GiB`, `100KiB` (`MB`, `GB` are powers of 1000). `0` needs none",
734    ),
735    ("duration", "A number and a unit: `250ms`, `1.5s`, `2m`"),
736    (
737        "list",
738        "In a file, a TOML array; with `-c`, `a,b` or the array",
739    ),
740    (
741        "color",
742        "A name (`red`, `bright_blue`, `default`), `#rrggbb` or `indexed(0-255)`",
743    ),
744];
745
746/// `docs/reference/settings.md`: every key by section, from this table. Written by
747/// `gen_docs settings`; a test fails while the committed page differs.
748pub fn render_settings_markdown() -> String {
749    let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
750    let mut out = String::from(
751        "# Settings\n\n\
752         <!-- Generated from crates/datui-cli/src/settings.rs by `gen_docs settings`. Do not edit. -->\n\n\
753         Set these in `config.toml` (`datui config init` writes one with every key\n\
754         commented out), or for one run with `-c KEY=VALUE`:\n\n\
755         ```bash\n\
756         printf 'a,b\\n1,2\\n' | datui -c display.row_numbers=true\n\
757         ```\n\n\
758         A flag beats `-c`, which beats the config files, which beat the defaults.\n\
759         `datui config keys` lists every key with its value in effect and where it was\n\
760         set. See [Configure datui](../user-guide/configuration.md) for where the file\n\
761         lives, imports, the theme and troubleshooting.\n\n\
762         | Type | Written as |\n\
763         |---|---|\n",
764    );
765    for (kind, written) in TYPES {
766        out.push_str(&format!("| {kind} | {written} |\n"));
767    }
768    for section in SECTIONS {
769        let settings: Vec<&Setting> = in_section(section.name).collect();
770        if settings.is_empty() {
771            continue;
772        }
773        out.push_str(&format!("\n## {}\n\n", section.title));
774        if !section.name.is_empty() {
775            out.push_str(&format!("`[{}]`", section.name));
776            if !section.intro.is_empty() {
777                out.push_str(&format!(" {}", section.intro));
778            }
779            out.push_str("\n\n");
780        } else if !section.intro.is_empty() {
781            out.push_str(&format!("{}\n\n", section.intro));
782        }
783        let colors = settings
784            .iter()
785            .all(|s| matches!(s.default, DefaultValue::Color { .. }));
786        if colors {
787            out.push_str("| Key | Dark | Light | Description |\n|---|---|---|---|\n");
788        } else {
789            out.push_str("| Key | Type | Default | Flag | Description |\n|---|---|---|---|---|\n");
790        }
791        for setting in settings {
792            let key = format!("`{}`", setting.key);
793            match setting.default {
794                DefaultValue::Color { dark, light } => out.push_str(&format!(
795                    "| {key} | `{dark}` | `{light}` | {} |\n",
796                    cell(setting.doc)
797                )),
798                DefaultValue::Value(v) | DefaultValue::Unset(v) => {
799                    let default = match setting.default {
800                        DefaultValue::Value(_) => format!("`{}`", cell(v)),
801                        _ => "unset".to_string(),
802                    };
803                    let flag = setting.flag.map(|f| format!("`--{f}`")).unwrap_or_default();
804                    out.push_str(&format!(
805                        "| {key} | {} | {default} | {flag} | {} |\n",
806                        setting.kind.describe(),
807                        cell(setting.doc)
808                    ));
809                }
810            }
811        }
812    }
813    out.push_str(
814        "\nThe environment variables datui reads are in\n\
815         [Environment variables](environment.md).\n",
816    );
817    out
818}
819
820/// One `-c KEY=VALUE`: a key the registry knows and its value, read for the key's
821/// kind.
822#[derive(Debug, Clone, PartialEq)]
823pub struct Override {
824    pub key: String,
825    pub value: toml::Value,
826}
827
828impl std::str::FromStr for Override {
829    type Err = String;
830
831    /// `KEY=VALUE`, split at the first `=`. An unknown key names the nearest known
832    /// ones; a value the key cannot take says what it takes.
833    fn from_str(text: &str) -> Result<Self, String> {
834        let Some((key, value)) = text.split_once('=') else {
835            return Err(format!(
836                "\"{text}\" is not KEY=VALUE, as in -c display.row_numbers=true"
837            ));
838        };
839        let key = key.trim();
840        let setting = find(key).ok_or_else(|| unknown_key(key))?;
841        let value = parse_value(setting, value).map_err(|e| {
842            format!(
843                "{key}: {e} ({key} takes {})",
844                setting.kind.describe().replace("\\|", "|")
845            )
846        })?;
847        Ok(Self {
848            key: key.to_string(),
849            value,
850        })
851    }
852}
853
854/// `text` as the value of `setting`. Text needs no quotes, as with `git -c`.
855pub fn parse_value(setting: &Setting, text: &str) -> Result<toml::Value, String> {
856    let trimmed = text.trim();
857    Ok(match setting.kind {
858        Bool => toml::Value::Boolean(parse_bool(trimmed)?),
859        Count => {
860            let n: u64 = trimmed
861                .replace('_', "")
862                .parse()
863                .map_err(|_| format!("\"{trimmed}\" is not a whole number"))?;
864            toml::Value::Integer(i64::try_from(n).map_err(|_| format!("{n} is too large"))?)
865        }
866        Text | Path | Color => toml::Value::String(text.to_string()),
867        List => {
868            if trimmed.starts_with('[') {
869                toml_value(trimmed)
870                    .filter(toml::Value::is_array)
871                    .ok_or_else(|| format!("\"{trimmed}\" is not a list"))?
872            } else {
873                toml::Value::Array(
874                    trimmed
875                        .split(',')
876                        .map(str::trim)
877                        .filter(|item| !item.is_empty())
878                        .map(|item| toml::Value::String(item.to_string()))
879                        .collect(),
880                )
881            }
882        }
883        Choice(words) => {
884            let word = trimmed.to_ascii_lowercase();
885            if !words.contains(&word.as_str()) {
886                return Err(format!("\"{trimmed}\" is not one of {}", words.join(", ")));
887            }
888            toml::Value::String(word)
889        }
890        Size => {
891            crate::units::parse_size(trimmed)?;
892            toml::Value::String(trimmed.to_string())
893        }
894        Duration => {
895            crate::units::parse_duration(trimmed)?;
896            toml::Value::String(trimmed.to_string())
897        }
898        // A bool, number, list or table as TOML; anything else is a word.
899        Toml(_) => toml_value(trimmed).unwrap_or_else(|| toml::Value::String(trimmed.to_string())),
900        Tables => return Err("is a list of tables; write it in a config file".into()),
901    })
902}
903
904/// `true`, `false` and the words people use for them.
905pub fn parse_bool(text: &str) -> Result<bool, String> {
906    match text.to_ascii_lowercase().as_str() {
907        "true" | "yes" | "on" | "1" => Ok(true),
908        "false" | "no" | "off" | "0" => Ok(false),
909        _ => Err(format!("\"{text}\" is not true or false")),
910    }
911}
912
913fn toml_value(text: &str) -> Option<toml::Value> {
914    format!("v = {text}")
915        .parse::<toml::Table>()
916        .ok()
917        .and_then(|mut t| t.remove("v"))
918}
919
920/// The error for a key the registry does not know, with the nearest keys.
921fn unknown_key(key: &str) -> String {
922    let near = suggestions(key);
923    let mut message = format!("\"{key}\" is not a config key");
924    if !near.is_empty() {
925        message.push_str(&format!("; did you mean {}?", near.join(" or ")));
926    }
927    message.push_str(" `datui config keys` lists them");
928    message
929}
930
931/// Keys close to `key`: a few edits away, or a name in another section that one of
932/// them holds (`comment_char` and `comment`, so a renamed key finds its new name).
933pub fn suggestions(key: &str) -> Vec<&'static str> {
934    let name = key.rsplit_once('.').map_or(key, |(_, n)| n);
935    let mut scored: Vec<(usize, &'static str)> = SETTINGS
936        .iter()
937        .filter(|s| !s.key.ends_with(".*"))
938        .filter_map(|s| {
939            let distance = edit_distance(key, s.key);
940            let close = distance <= (key.len() / 4).max(2);
941            let other = s.name();
942            let alike = other == name
943                || (other.len() >= 4 && name.contains(other))
944                || (name.len() >= 4 && other.contains(name));
945            (close || alike).then_some((distance, s.key))
946        })
947        .collect();
948    scored.sort();
949    scored.into_iter().take(3).map(|(_, k)| k).collect()
950}
951
952/// Levenshtein distance, by characters.
953fn edit_distance(a: &str, b: &str) -> usize {
954    let b: Vec<char> = b.chars().collect();
955    let mut row: Vec<usize> = (0..=b.len()).collect();
956    for (i, ca) in a.chars().enumerate() {
957        let mut diagonal = row[0];
958        row[0] = i + 1;
959        for (j, cb) in b.iter().enumerate() {
960            let above = row[j + 1];
961            row[j + 1] = (diagonal + usize::from(ca != *cb))
962                .min(above + 1)
963                .min(row[j] + 1);
964            diagonal = above;
965        }
966    }
967    row[b.len()]
968}
969
970#[cfg(test)]
971mod tests {
972    use super::*;
973
974    #[test]
975    fn an_override_reads_its_value_for_the_key() {
976        let o: Override = "display.mouse=yes".parse().unwrap();
977        assert_eq!(o.value, toml::Value::Boolean(true));
978        let o: Override = "display.row_numbers=auto".parse().unwrap();
979        assert_eq!(o.value, toml::Value::String("auto".into()));
980        let o: Override = "display.row_numbers=false".parse().unwrap();
981        assert_eq!(o.value, toml::Value::Boolean(false));
982        let o: Override = "csv.comment=#".parse().unwrap();
983        assert_eq!(o.value, toml::Value::String("#".into()));
984        let o: Override = "cloud.env_files=a.env, b.env".parse().unwrap();
985        assert_eq!(o.value.as_array().map(Vec::len), Some(2));
986        let o: Override = "home.hide=[\"x\"]".parse().unwrap();
987        assert_eq!(o.value.as_array().map(Vec::len), Some(1));
988        let o: Override = "cloud.discover=s3,gcs".parse().unwrap();
989        assert_eq!(o.value, toml::Value::String("s3,gcs".into()));
990        let o: Override = "cloud.discover=false".parse().unwrap();
991        assert_eq!(o.value, toml::Value::Boolean(false));
992        let o: Override = "glyphs.spinner=[\"a\", \"b\"]".parse().unwrap();
993        assert!(o.value.is_array());
994        // Only the first `=` splits.
995        let o: Override = "csv.null_values=amount=".parse().unwrap();
996        assert_eq!(o.value.as_array().unwrap()[0].as_str(), Some("amount="));
997    }
998
999    #[test]
1000    fn sizes_and_durations_take_their_unit() {
1001        let o: Override = "performance.max_buffered=1GiB".parse().unwrap();
1002        assert_eq!(o.value, toml::Value::String("1GiB".into()));
1003        let e = "performance.max_buffered=512"
1004            .parse::<Override>()
1005            .unwrap_err();
1006        assert!(e.contains("needs a unit"), "{e}");
1007        let o: Override = "read.follow_interval=1s".parse().unwrap();
1008        assert_eq!(o.value, toml::Value::String("1s".into()));
1009        let e = "read.follow_interval=fast".parse::<Override>().unwrap_err();
1010        assert!(e.contains("duration"), "{e}");
1011    }
1012
1013    #[test]
1014    fn an_override_refuses_with_the_way_out() {
1015        let e = "file_loading.comment_char=#"
1016            .parse::<Override>()
1017            .unwrap_err();
1018        assert!(e.contains("did you mean csv.comment"), "{e}");
1019        let e = "performance.polars_streaming=false"
1020            .parse::<Override>()
1021            .unwrap_err();
1022        assert!(e.contains("performance.streaming"), "{e}");
1023        let e = "display.row_number=true".parse::<Override>().unwrap_err();
1024        assert!(e.contains("did you mean display.row_numbers"), "{e}");
1025        let e = "row_numbers=true".parse::<Override>().unwrap_err();
1026        assert!(e.contains("display.row_numbers"), "{e}");
1027        let e = "display.row_numbers_start=one"
1028            .parse::<Override>()
1029            .unwrap_err();
1030        assert!(
1031            e.contains("not a whole number") && e.contains("integer"),
1032            "{e}"
1033        );
1034        let e = "display.mouse=maybe".parse::<Override>().unwrap_err();
1035        assert!(e.contains("not true or false"), "{e}");
1036        let e = "display.unicode=sometimes".parse::<Override>().unwrap_err();
1037        assert!(e.contains("auto, always, never"), "{e}");
1038        let e = "cloud.connections=x".parse::<Override>().unwrap_err();
1039        assert!(e.contains("config file"), "{e}");
1040        let e = "display.mouse".parse::<Override>().unwrap_err();
1041        assert!(e.contains("KEY=VALUE"), "{e}");
1042        let e = "nothing.like.this=1".parse::<Override>().unwrap_err();
1043        assert!(e.contains("config keys"), "{e}");
1044    }
1045
1046    #[test]
1047    fn every_key_is_listed_once_in_a_known_section() {
1048        for (i, setting) in SETTINGS.iter().enumerate() {
1049            assert!(
1050                SECTIONS.iter().any(|s| s.name == setting.section()),
1051                "{} has no section",
1052                setting.key
1053            );
1054            assert!(
1055                SETTINGS[..i].iter().all(|other| other.key != setting.key),
1056                "{} is listed twice",
1057                setting.key
1058            );
1059            assert!(!setting.doc.is_empty(), "{} has no doc", setting.key);
1060        }
1061    }
1062
1063    #[test]
1064    fn a_wildcard_key_matches_one_name_below_it() {
1065        let glyph = find("glyphs.spinner").expect("a glyph slot");
1066        assert_eq!(glyph.key, "glyphs.*");
1067        assert!(find("glyphs").is_none());
1068        assert!(find("glyphs.a.b").is_none());
1069        assert_eq!(find("display.mouse").map(|s| s.key), Some("display.mouse"));
1070    }
1071}