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.sdf_fields", Count, Value("4096"), "Most fields (data items by name) read from an SDF file, each a column."),
352    s("limits.vcd_signals", Count, Value("1048576"), "Most signals read from a VCD file, each a column."),
353    s("limits.fix_tags", Count, Value("4096"), "Most tags read from a FIX file, each a column."),
354    s("limits.fix_fields", Count, Value("4096"), "Most fields read from one FIX message."),
355    s("limits.gpx_fields", Count, Value("256"), "Most extension fields read from a GPX file, each a column."),
356    s("limits.npy_header_bytes", Size, Value("\"4MiB\""), "Largest NumPy header read; a file with a larger one is refused."),
357    s("log.file", Path, Unset("\"~/datui.log\""), "Where the log is written. Unset, datui.log in the cache directory.").flag("log-file"),
358    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"),
359    // [theme]
360    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."),
361    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."),
362    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."),
363    color("theme.colors.chip_key", "#7dcfff", "#2e7de9", "Keys named in the footer, dialogs, the breadcrumb and the correlation matrix."),
364    color("theme.colors.chip_label", "#a9b1d6", "#3760bf", "Labels beside keys in the footer, and the footer's status."),
365    color("theme.colors.throbber", "#7dcfff", "#2e7de9", "The busy spinner."),
366    color("theme.colors.success", "#9ece6a", "#587539", "Success."),
367    color("theme.colors.error", "#f7768e", "#f52a65", "Errors."),
368    color("theme.colors.warning", "#e0af68", "#8c6c3e", "Warnings."),
369    color("theme.colors.dimmed", "#565f89", "#848cb5", "Dimmed text, nulls and axes."),
370    color("theme.colors.background", "default", "default", "Main background."),
371    color("theme.colors.surface", "default", "default", "Dialog background."),
372    color("theme.colors.controls_bg", "#262a3f", "#d0d5e3", "Count chips and dialogs' key chips."),
373    color("theme.colors.text_primary", "default", "default", "Text."),
374    color("theme.colors.text_secondary", "#737aa2", "#6172b0", "Secondary text."),
375    color("theme.colors.text_inverse", "#1a1b26", "#e1e2e7", "Text on a key chip."),
376    color("theme.colors.table_header", "#c0caf5", "#3760bf", "Header text."),
377    color("theme.colors.table_header_bg", "#2b3047", "#c4c8da", "Header fill."),
378    color("theme.colors.table_row_numbers", "#565f89", "#848cb5", "The row-number column."),
379    color("theme.colors.table_column_separator", "#3b4261", "#a8aecb", "The rule after frozen columns and beside section titles."),
380    color("theme.colors.table_selected", "#283457", "#b6bfe2", "Tint under the current row; reversed swaps text and background instead."),
381    color("theme.colors.table_column_cursor", "#292e42", "#cbd3f2", "Tint under the column cursor's cells."),
382    color("theme.colors.table_cell_cursor", "#3b4261", "#a0aef0", "The column cursor's header and the current cell."),
383    color("theme.colors.sidebar_border", "#565f89", "#6172b0", "Sidebar and dialog borders."),
384    color("theme.colors.modal_border_active", "#7dcfff", "#2e7de9", "The focused dialog's border."),
385    color("theme.colors.modal_border_error", "#f7768e", "#f52a65", "An error dialog's border."),
386    color("theme.colors.distribution_normal", "#9ece6a", "#587539", "Analysis: a normal distribution."),
387    color("theme.colors.distribution_skewed", "#e0af68", "#8c6c3e", "Analysis: a skewed distribution."),
388    color("theme.colors.distribution_other", "#c0caf5", "#3760bf", "Analysis: other distributions."),
389    color("theme.colors.outlier_marker", "#f7768e", "#f52a65", "Analysis: outliers."),
390    color("theme.colors.input_cursor", "default", "default", "The text caret; default reverses the text under it."),
391    color("theme.colors.input_cursor_text", "default", "default", "Text under the caret block; default picks black or white by contrast."),
392    color("theme.colors.table_alternate_row", "#1e2030", "#dcdfea", "Every other row; default turns the stripe off."),
393    color("theme.colors.type_str", "#9ece6a", "#587539", "String columns."),
394    color("theme.colors.type_int", "#7aa2f7", "#2e7de9", "Integer columns."),
395    color("theme.colors.type_float", "#2ac3de", "#007197", "Float columns."),
396    color("theme.colors.type_bool", "#e0af68", "#8c6c3e", "Boolean columns."),
397    color("theme.colors.type_temporal", "#bb9af7", "#9854f1", "Date, time and datetime columns."),
398    color("theme.colors.type_binary", "#565f89", "#848cb5", "Binary columns' placeholder."),
399    color("theme.colors.chart_1", "#7dcfff", "#2e7de9", "Chart series 1; also histogram bars, bar charts and Q-Q points."),
400    color("theme.colors.chart_2", "#bb9af7", "#9854f1", "Chart series 2."),
401    color("theme.colors.chart_3", "#9ece6a", "#587539", "Chart series 3."),
402    color("theme.colors.chart_4", "#e0af68", "#8c6c3e", "Chart series 4."),
403    color("theme.colors.chart_5", "#7aa2f7", "#007197", "Chart series 5."),
404    color("theme.colors.chart_6", "#f7768e", "#f52a65", "Chart series 6."),
405    color("theme.colors.chart_7", "#ff9e64", "#b15c00", "Chart series 7."),
406    color("theme.colors.chart_8", "#1abc9c", "#118c74", "Chart series 8."),
407    color("theme.colors.chart_9", "#ff5fd2", "#d1188c", "Chart series 9."),
408    color("theme.colors.chart_10", "#f4ef8a", "#24357a", "Chart series 10."),
409    color("theme.colors.chart_grid", "#3d4785", "#70aabf", "The chart grid, a shade dimmer than dimmed."),
410    color("theme.colors.accent", "#7dcfff", "#2e7de9", "Key chips, focused titles and the selection rail."),
411    color("theme.colors.accent_bright", "#a4daff", "#1a6cd0", "The section the cursor is in."),
412    color("theme.colors.gradient_start", "#7aa2f7", "#2e7de9", "The wordmark's first stop."),
413    color("theme.colors.gradient_end", "#bb9af7", "#9854f1", "The wordmark's last stop."),
414    color("theme.colors.find_match", "#e0af68", "#f0c35a", "Behind the cell a find landed on."),
415    color("theme.colors.hex_null", "#565f89", "#848cb5", "Hex view: the byte 0x00."),
416    color("theme.colors.hex_printable", "#7dcfff", "#007197", "Hex view: printable ASCII."),
417    color("theme.colors.hex_whitespace", "#9ece6a", "#587539", "Hex view: whitespace bytes."),
418    color("theme.colors.hex_control", "#bb9af7", "#9854f1", "Hex view: other control bytes."),
419    color("theme.colors.hex_high", "#e0af68", "#8c6c3e", "Hex view: 0x80 to 0xFE."),
420    color("theme.colors.hex_ff", "#f7768e", "#f52a65", "Hex view: the byte 0xFF."),
421    // [glyphs]
422    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."),
423];
424
425/// An option of one open, with no config key: it says how to read one file (#289),
426/// so it is a flag, a Python keyword and, for delimited text, a spec key.
427#[derive(Debug, Clone, Copy, PartialEq, Eq)]
428pub struct OpenOption {
429    /// The flag, without `--`.
430    pub flag: &'static str,
431    pub kwarg: &'static str,
432    pub kind: Kind,
433    /// The key a delimited format spec writes it as.
434    pub spec: Option<&'static str>,
435}
436
437const fn open(flag: &'static str, kwarg: &'static str, kind: Kind) -> OpenOption {
438    OpenOption {
439        flag,
440        kwarg,
441        kind,
442        spec: None,
443    }
444}
445
446const fn open_spec(
447    flag: &'static str,
448    kwarg: &'static str,
449    kind: Kind,
450    spec: &'static str,
451) -> OpenOption {
452    OpenOption {
453        flag,
454        kwarg,
455        kind,
456        spec: Some(spec),
457    }
458}
459
460/// The open's own options that Python takes as keywords, beside the config keys'.
461pub const OPEN: &[OpenOption] = &[
462    open("format", "format", Text),
463    open("table", "table", Text),
464    open("hive", "hive", Bool),
465    open(
466        "compression",
467        "compression",
468        Choice(&["gzip", "zstd", "bzip2", "xz"]),
469    ),
470    open("dict", "dict", List),
471    open("view", "view", Text),
472    open_spec("delimiter", "delimiter", Text, "delimiter"),
473    open("no-header", "no_header", Bool),
474    open_spec("header-rows", "header_rows", List, "header_rows"),
475    open("footer-rows", "footer_rows", Count),
476    open("skip-rows", "skip_rows", Count),
477    open_spec("skip-lines", "skip_lines", Count, "skip_lines"),
478];
479
480/// Who an environment variable belongs to, as the reference groups them.
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub enum EnvGroup {
483    /// datui's own.
484    Datui,
485    /// The terminal's: color, glyphs.
486    Terminal,
487    /// The programs datui hands a value to.
488    Programs,
489    /// The cloud logins, as each provider's own tools read them.
490    Cloud,
491}
492
493impl EnvGroup {
494    /// The group's heading in the reference.
495    pub fn title(self) -> &'static str {
496        match self {
497            Self::Datui => "datui",
498            Self::Terminal => "Terminal",
499            Self::Programs => "Programs datui starts",
500            Self::Cloud => "Cloud logins",
501        }
502    }
503}
504
505/// One environment variable datui reads.
506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507pub struct EnvVar {
508    /// The name, or names read as one (`AWS_REGION`, `AWS_DEFAULT_REGION`).
509    pub names: &'static [&'static str],
510    pub group: EnvGroup,
511    /// What it does, as markdown.
512    pub doc: &'static str,
513}
514
515const fn env(names: &'static [&'static str], group: EnvGroup, doc: &'static str) -> EnvVar {
516    EnvVar { names, group, doc }
517}
518
519/// The environment variables datui reads, for the reference and the manpage.
520pub const ENVIRONMENT: &[EnvVar] = &[
521    env(
522        &["DATUI_CONFIG_DIR"],
523        EnvGroup::Datui,
524        "The config directory, in place of the platform's (`~/.config/datui` on Linux). Saved views and format specs live there too",
525    ),
526    env(
527        &["DATUI_CACHE_DIR"],
528        EnvGroup::Datui,
529        "The cache directory, in place of the platform's (`~/.cache/datui` on Linux)",
530    ),
531    env(
532        &["DATUI_FORMATS_PATH"],
533        EnvGroup::Datui,
534        "Directories of format specs and dictionaries, separated as `PATH` is, searched before `[formats] path`",
535    ),
536    env(
537        &["DATUI_LOG"],
538        EnvGroup::Datui,
539        "The log level: `error`, `warn`, `info`, `debug`, `trace` or `off`. Overrides `log.level` in a config file; `-c` and `--log-level` override it",
540    ),
541    env(
542        &["DATUI_DEBUG"],
543        EnvGroup::Datui,
544        "`1` shows the debug overlay",
545    ),
546    env(
547        &["DATUI_GCP_PROJECT"],
548        EnvGroup::Datui,
549        "The Google Cloud project to list when projects cannot be searched. Like `GOOGLE_CLOUD_PROJECT`, but read first",
550    ),
551    env(
552        &["DATUI_TRACE_FIRST_ROWS"],
553        EnvGroup::Datui,
554        "A file to write the time (in Unix nanoseconds) to once the first rows are drawn. For benchmarks",
555    ),
556    env(
557        &["NO_COLOR"],
558        EnvGroup::Terminal,
559        "Set to anything to turn off colors: the terminal's own are used for everything",
560    ),
561    env(
562        &["COLORTERM", "TERM", "FORCE_COLOR"],
563        EnvGroup::Terminal,
564        "How many colors the terminal draws: 24-bit, 256 or 16. Theme colors are reduced to fit",
565    ),
566    env(
567        &["COLORFGBG"],
568        EnvGroup::Terminal,
569        "With `theme.mode = \"auto\"`, says whether the background is light or dark, for a terminal that does not answer when asked",
570    ),
571    env(
572        &["TERM_PROGRAM"],
573        EnvGroup::Terminal,
574        "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",
575    ),
576    env(
577        &["LC_ALL", "LC_CTYPE", "LANG"],
578        EnvGroup::Terminal,
579        "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",
580    ),
581    env(
582        &["VISUAL", "EDITOR", "PAGER"],
583        EnvGroup::Programs,
584        "The inspector's `o` opens text in the first one set, else `less` (on Windows, the system's opener)",
585    ),
586    env(
587        &["AWS_PROFILE"],
588        EnvGroup::Cloud,
589        "The AWS profile for `s3://`, else `default`",
590    ),
591    env(
592        &[
593            "AWS_ACCESS_KEY_ID",
594            "AWS_SECRET_ACCESS_KEY",
595            "AWS_SESSION_TOKEN",
596        ],
597        EnvGroup::Cloud,
598        "AWS keys, and the token of temporary ones",
599    ),
600    env(
601        &["AWS_REGION", "AWS_DEFAULT_REGION"],
602        EnvGroup::Cloud,
603        "The AWS region",
604    ),
605    env(
606        &["AWS_ENDPOINT_URL_S3", "AWS_ENDPOINT_URL", "AWS_ENDPOINT"],
607        EnvGroup::Cloud,
608        "An S3-compatible endpoint (MinIO, R2, Ceph); the first one set",
609    ),
610    env(
611        &["AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE"],
612        EnvGroup::Cloud,
613        "The AWS config and credentials files, in place of `~/.aws/config` and `~/.aws/credentials`",
614    ),
615    env(
616        &[
617            "GOOGLE_APPLICATION_CREDENTIALS",
618            "GOOGLE_SERVICE_ACCOUNT",
619            "GOOGLE_SERVICE_ACCOUNT_PATH",
620            "GOOGLE_SERVICE_ACCOUNT_KEY",
621        ],
622        EnvGroup::Cloud,
623        "A Google Cloud service account or credentials file for `gs://`",
624    ),
625    env(
626        &[
627            "GOOGLE_CLOUD_PROJECT",
628            "GCLOUD_PROJECT",
629            "CLOUDSDK_CORE_PROJECT",
630            "GCP_PROJECT",
631        ],
632        EnvGroup::Cloud,
633        "The Google Cloud project to list buckets in, after `DATUI_GCP_PROJECT`; the first one set",
634    ),
635    env(
636        &["CLOUDSDK_CONFIG"],
637        EnvGroup::Cloud,
638        "The `gcloud` configuration directory, in place of `~/.config/gcloud`",
639    ),
640    env(
641        &["AZURE_STORAGE_CONNECTION_STRING"],
642        EnvGroup::Cloud,
643        "An Azure storage connection string, with `AccountKey` or `SharedAccessSignature`",
644    ),
645    env(
646        &[
647            "AZURE_STORAGE_ACCOUNT_NAME",
648            "AZURE_STORAGE_ACCOUNT_KEY",
649            "AZURE_STORAGE_SAS_TOKEN",
650        ],
651        EnvGroup::Cloud,
652        "An Azure storage account and its key or SAS token",
653    ),
654    env(
655        &[
656            "AZURE_TENANT_ID",
657            "AZURE_CLIENT_ID",
658            "AZURE_CLIENT_SECRET",
659            "AZURE_FEDERATED_TOKEN_FILE",
660        ],
661        EnvGroup::Cloud,
662        "An Azure service principal, or AKS workload identity",
663    ),
664    env(
665        &["AZURE_CONFIG_DIR"],
666        EnvGroup::Cloud,
667        "The Azure CLI's directory, in place of `~/.azure`",
668    ),
669];
670
671/// `docs/reference/environment.md`: every variable in [`ENVIRONMENT`], by group.
672pub fn render_environment_markdown() -> String {
673    let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
674    let mut out = String::from(
675        "# Environment variables\n\n\
676         <!-- Generated from crates/datui-cli/src/settings.rs by `gen_docs`. Do not edit. -->\n\n\
677         The variables datui reads.\n",
678    );
679    for group in [
680        EnvGroup::Datui,
681        EnvGroup::Terminal,
682        EnvGroup::Programs,
683        EnvGroup::Cloud,
684    ] {
685        out.push_str(&format!("\n## {}\n\n", group.title()));
686        if group == EnvGroup::Cloud {
687            out.push_str(
688                "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",
689            );
690        }
691        out.push_str("| Variable | What it does |\n|---|---|\n");
692        for var in ENVIRONMENT.iter().filter(|v| v.group == group) {
693            let names: Vec<String> = var.names.iter().map(|n| format!("`{n}`")).collect();
694            out.push_str(&format!("| {} | {} |\n", names.join(", "), cell(var.doc)));
695        }
696    }
697    out
698}
699
700/// The setting `key` names, if any.
701pub fn find(key: &str) -> Option<&'static Setting> {
702    SETTINGS.iter().find(|s| s.matches(key))
703}
704
705/// A flag's help: its setting's doc and key. Panics on a flag no setting names, which
706/// `--help` and the tests reach.
707pub fn flag_help(flag: &str) -> String {
708    let setting = by_flag(flag).unwrap_or_else(|| panic!("--{flag} sets no registered key"));
709    format!("{} [config: {}]", setting.doc, setting.key)
710}
711
712/// The setting a Python keyword sets, if one does.
713pub fn by_kwarg(kwarg: &str) -> Option<&'static Setting> {
714    SETTINGS.iter().find(|s| s.kwarg == Some(kwarg))
715}
716
717/// The setting a flag sets, if one does.
718pub fn by_flag(flag: &str) -> Option<&'static Setting> {
719    SETTINGS.iter().find(|s| s.flag == Some(flag))
720}
721
722/// The settings of `section`, in table order.
723pub fn in_section(section: &str) -> impl Iterator<Item = &'static Setting> + '_ {
724    SETTINGS.iter().filter(move |s| s.section() == section)
725}
726
727/// How a value of each type is written, as Markdown: the settings reference and
728/// datui-config(5).
729pub const TYPES: &[(&str, &str)] = &[
730    (
731        "size",
732        "A number and a unit: `512MiB`, `2GiB`, `100KiB` (`MB`, `GB` are powers of 1000). `0` needs none",
733    ),
734    ("duration", "A number and a unit: `250ms`, `1.5s`, `2m`"),
735    (
736        "list",
737        "In a file, a TOML array; with `-c`, `a,b` or the array",
738    ),
739    (
740        "color",
741        "A name (`red`, `bright_blue`, `default`), `#rrggbb` or `indexed(0-255)`",
742    ),
743];
744
745/// `docs/reference/settings.md`: every key by section, from this table. Written by
746/// `gen_docs settings`; a test fails while the committed page differs.
747pub fn render_settings_markdown() -> String {
748    let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
749    let mut out = String::from(
750        "# Settings\n\n\
751         <!-- Generated from crates/datui-cli/src/settings.rs by `gen_docs settings`. Do not edit. -->\n\n\
752         Set these in `config.toml` (`datui config init` writes one with every key\n\
753         commented out), or for one run with `-c KEY=VALUE`:\n\n\
754         ```bash\n\
755         printf 'a,b\\n1,2\\n' | datui -c display.row_numbers=true\n\
756         ```\n\n\
757         A flag beats `-c`, which beats the config files, which beat the defaults.\n\
758         `datui config keys` lists every key with its value in effect and where it was\n\
759         set. See [Configure datui](../user-guide/configuration.md) for where the file\n\
760         lives, imports, the theme and troubleshooting.\n\n\
761         | Type | Written as |\n\
762         |---|---|\n",
763    );
764    for (kind, written) in TYPES {
765        out.push_str(&format!("| {kind} | {written} |\n"));
766    }
767    for section in SECTIONS {
768        let settings: Vec<&Setting> = in_section(section.name).collect();
769        if settings.is_empty() {
770            continue;
771        }
772        out.push_str(&format!("\n## {}\n\n", section.title));
773        if !section.name.is_empty() {
774            out.push_str(&format!("`[{}]`", section.name));
775            if !section.intro.is_empty() {
776                out.push_str(&format!(" {}", section.intro));
777            }
778            out.push_str("\n\n");
779        } else if !section.intro.is_empty() {
780            out.push_str(&format!("{}\n\n", section.intro));
781        }
782        let colors = settings
783            .iter()
784            .all(|s| matches!(s.default, DefaultValue::Color { .. }));
785        if colors {
786            out.push_str("| Key | Dark | Light | Description |\n|---|---|---|---|\n");
787        } else {
788            out.push_str("| Key | Type | Default | Flag | Description |\n|---|---|---|---|---|\n");
789        }
790        for setting in settings {
791            let key = format!("`{}`", setting.key);
792            match setting.default {
793                DefaultValue::Color { dark, light } => out.push_str(&format!(
794                    "| {key} | `{dark}` | `{light}` | {} |\n",
795                    cell(setting.doc)
796                )),
797                DefaultValue::Value(v) | DefaultValue::Unset(v) => {
798                    let default = match setting.default {
799                        DefaultValue::Value(_) => format!("`{}`", cell(v)),
800                        _ => "unset".to_string(),
801                    };
802                    let flag = setting.flag.map(|f| format!("`--{f}`")).unwrap_or_default();
803                    out.push_str(&format!(
804                        "| {key} | {} | {default} | {flag} | {} |\n",
805                        setting.kind.describe(),
806                        cell(setting.doc)
807                    ));
808                }
809            }
810        }
811    }
812    out.push_str(
813        "\nThe environment variables datui reads are in\n\
814         [Environment variables](environment.md).\n",
815    );
816    out
817}
818
819/// One `-c KEY=VALUE`: a key the registry knows and its value, read for the key's
820/// kind.
821#[derive(Debug, Clone, PartialEq)]
822pub struct Override {
823    pub key: String,
824    pub value: toml::Value,
825}
826
827impl std::str::FromStr for Override {
828    type Err = String;
829
830    /// `KEY=VALUE`, split at the first `=`. An unknown key names the nearest known
831    /// ones; a value the key cannot take says what it takes.
832    fn from_str(text: &str) -> Result<Self, String> {
833        let Some((key, value)) = text.split_once('=') else {
834            return Err(format!(
835                "\"{text}\" is not KEY=VALUE, as in -c display.row_numbers=true"
836            ));
837        };
838        let key = key.trim();
839        let setting = find(key).ok_or_else(|| unknown_key(key))?;
840        let value = parse_value(setting, value).map_err(|e| {
841            format!(
842                "{key}: {e} ({key} takes {})",
843                setting.kind.describe().replace("\\|", "|")
844            )
845        })?;
846        Ok(Self {
847            key: key.to_string(),
848            value,
849        })
850    }
851}
852
853/// `text` as the value of `setting`. Text needs no quotes, as with `git -c`.
854pub fn parse_value(setting: &Setting, text: &str) -> Result<toml::Value, String> {
855    let trimmed = text.trim();
856    Ok(match setting.kind {
857        Bool => toml::Value::Boolean(parse_bool(trimmed)?),
858        Count => {
859            let n: u64 = trimmed
860                .replace('_', "")
861                .parse()
862                .map_err(|_| format!("\"{trimmed}\" is not a whole number"))?;
863            toml::Value::Integer(i64::try_from(n).map_err(|_| format!("{n} is too large"))?)
864        }
865        Text | Path | Color => toml::Value::String(text.to_string()),
866        List => {
867            if trimmed.starts_with('[') {
868                toml_value(trimmed)
869                    .filter(toml::Value::is_array)
870                    .ok_or_else(|| format!("\"{trimmed}\" is not a list"))?
871            } else {
872                toml::Value::Array(
873                    trimmed
874                        .split(',')
875                        .map(str::trim)
876                        .filter(|item| !item.is_empty())
877                        .map(|item| toml::Value::String(item.to_string()))
878                        .collect(),
879                )
880            }
881        }
882        Choice(words) => {
883            let word = trimmed.to_ascii_lowercase();
884            if !words.contains(&word.as_str()) {
885                return Err(format!("\"{trimmed}\" is not one of {}", words.join(", ")));
886            }
887            toml::Value::String(word)
888        }
889        Size => {
890            crate::units::parse_size(trimmed)?;
891            toml::Value::String(trimmed.to_string())
892        }
893        Duration => {
894            crate::units::parse_duration(trimmed)?;
895            toml::Value::String(trimmed.to_string())
896        }
897        // A bool, number, list or table as TOML; anything else is a word.
898        Toml(_) => toml_value(trimmed).unwrap_or_else(|| toml::Value::String(trimmed.to_string())),
899        Tables => return Err("is a list of tables; write it in a config file".into()),
900    })
901}
902
903/// `true`, `false` and the words people use for them.
904pub fn parse_bool(text: &str) -> Result<bool, String> {
905    match text.to_ascii_lowercase().as_str() {
906        "true" | "yes" | "on" | "1" => Ok(true),
907        "false" | "no" | "off" | "0" => Ok(false),
908        _ => Err(format!("\"{text}\" is not true or false")),
909    }
910}
911
912fn toml_value(text: &str) -> Option<toml::Value> {
913    format!("v = {text}")
914        .parse::<toml::Table>()
915        .ok()
916        .and_then(|mut t| t.remove("v"))
917}
918
919/// The error for a key the registry does not know, with the nearest keys.
920fn unknown_key(key: &str) -> String {
921    let near = suggestions(key);
922    let mut message = format!("\"{key}\" is not a config key");
923    if !near.is_empty() {
924        message.push_str(&format!("; did you mean {}?", near.join(" or ")));
925    }
926    message.push_str(" `datui config keys` lists them");
927    message
928}
929
930/// Keys close to `key`: a few edits away, or a name in another section that one of
931/// them holds (`comment_char` and `comment`, so a renamed key finds its new name).
932pub fn suggestions(key: &str) -> Vec<&'static str> {
933    let name = key.rsplit_once('.').map_or(key, |(_, n)| n);
934    let mut scored: Vec<(usize, &'static str)> = SETTINGS
935        .iter()
936        .filter(|s| !s.key.ends_with(".*"))
937        .filter_map(|s| {
938            let distance = edit_distance(key, s.key);
939            let close = distance <= (key.len() / 4).max(2);
940            let other = s.name();
941            let alike = other == name
942                || (other.len() >= 4 && name.contains(other))
943                || (name.len() >= 4 && other.contains(name));
944            (close || alike).then_some((distance, s.key))
945        })
946        .collect();
947    scored.sort();
948    scored.into_iter().take(3).map(|(_, k)| k).collect()
949}
950
951/// Levenshtein distance, by characters.
952fn edit_distance(a: &str, b: &str) -> usize {
953    let b: Vec<char> = b.chars().collect();
954    let mut row: Vec<usize> = (0..=b.len()).collect();
955    for (i, ca) in a.chars().enumerate() {
956        let mut diagonal = row[0];
957        row[0] = i + 1;
958        for (j, cb) in b.iter().enumerate() {
959            let above = row[j + 1];
960            row[j + 1] = (diagonal + usize::from(ca != *cb))
961                .min(above + 1)
962                .min(row[j] + 1);
963            diagonal = above;
964        }
965    }
966    row[b.len()]
967}
968
969#[cfg(test)]
970mod tests {
971    use super::*;
972
973    #[test]
974    fn an_override_reads_its_value_for_the_key() {
975        let o: Override = "display.mouse=yes".parse().unwrap();
976        assert_eq!(o.value, toml::Value::Boolean(true));
977        let o: Override = "display.row_numbers=auto".parse().unwrap();
978        assert_eq!(o.value, toml::Value::String("auto".into()));
979        let o: Override = "display.row_numbers=false".parse().unwrap();
980        assert_eq!(o.value, toml::Value::Boolean(false));
981        let o: Override = "csv.comment=#".parse().unwrap();
982        assert_eq!(o.value, toml::Value::String("#".into()));
983        let o: Override = "cloud.env_files=a.env, b.env".parse().unwrap();
984        assert_eq!(o.value.as_array().map(Vec::len), Some(2));
985        let o: Override = "home.hide=[\"x\"]".parse().unwrap();
986        assert_eq!(o.value.as_array().map(Vec::len), Some(1));
987        let o: Override = "cloud.discover=s3,gcs".parse().unwrap();
988        assert_eq!(o.value, toml::Value::String("s3,gcs".into()));
989        let o: Override = "cloud.discover=false".parse().unwrap();
990        assert_eq!(o.value, toml::Value::Boolean(false));
991        let o: Override = "glyphs.spinner=[\"a\", \"b\"]".parse().unwrap();
992        assert!(o.value.is_array());
993        // Only the first `=` splits.
994        let o: Override = "csv.null_values=amount=".parse().unwrap();
995        assert_eq!(o.value.as_array().unwrap()[0].as_str(), Some("amount="));
996    }
997
998    #[test]
999    fn sizes_and_durations_take_their_unit() {
1000        let o: Override = "performance.max_buffered=1GiB".parse().unwrap();
1001        assert_eq!(o.value, toml::Value::String("1GiB".into()));
1002        let e = "performance.max_buffered=512"
1003            .parse::<Override>()
1004            .unwrap_err();
1005        assert!(e.contains("needs a unit"), "{e}");
1006        let o: Override = "read.follow_interval=1s".parse().unwrap();
1007        assert_eq!(o.value, toml::Value::String("1s".into()));
1008        let e = "read.follow_interval=fast".parse::<Override>().unwrap_err();
1009        assert!(e.contains("duration"), "{e}");
1010    }
1011
1012    #[test]
1013    fn an_override_refuses_with_the_way_out() {
1014        let e = "file_loading.comment_char=#"
1015            .parse::<Override>()
1016            .unwrap_err();
1017        assert!(e.contains("did you mean csv.comment"), "{e}");
1018        let e = "performance.polars_streaming=false"
1019            .parse::<Override>()
1020            .unwrap_err();
1021        assert!(e.contains("performance.streaming"), "{e}");
1022        let e = "display.row_number=true".parse::<Override>().unwrap_err();
1023        assert!(e.contains("did you mean display.row_numbers"), "{e}");
1024        let e = "row_numbers=true".parse::<Override>().unwrap_err();
1025        assert!(e.contains("display.row_numbers"), "{e}");
1026        let e = "display.row_numbers_start=one"
1027            .parse::<Override>()
1028            .unwrap_err();
1029        assert!(
1030            e.contains("not a whole number") && e.contains("integer"),
1031            "{e}"
1032        );
1033        let e = "display.mouse=maybe".parse::<Override>().unwrap_err();
1034        assert!(e.contains("not true or false"), "{e}");
1035        let e = "display.unicode=sometimes".parse::<Override>().unwrap_err();
1036        assert!(e.contains("auto, always, never"), "{e}");
1037        let e = "cloud.connections=x".parse::<Override>().unwrap_err();
1038        assert!(e.contains("config file"), "{e}");
1039        let e = "display.mouse".parse::<Override>().unwrap_err();
1040        assert!(e.contains("KEY=VALUE"), "{e}");
1041        let e = "nothing.like.this=1".parse::<Override>().unwrap_err();
1042        assert!(e.contains("config keys"), "{e}");
1043    }
1044
1045    #[test]
1046    fn every_key_is_listed_once_in_a_known_section() {
1047        for (i, setting) in SETTINGS.iter().enumerate() {
1048            assert!(
1049                SECTIONS.iter().any(|s| s.name == setting.section()),
1050                "{} has no section",
1051                setting.key
1052            );
1053            assert!(
1054                SETTINGS[..i].iter().all(|other| other.key != setting.key),
1055                "{} is listed twice",
1056                setting.key
1057            );
1058            assert!(!setting.doc.is_empty(), "{} has no doc", setting.key);
1059        }
1060    }
1061
1062    #[test]
1063    fn a_wildcard_key_matches_one_name_below_it() {
1064        let glyph = find("glyphs.spinner").expect("a glyph slot");
1065        assert_eq!(glyph.key, "glyphs.*");
1066        assert!(find("glyphs").is_none());
1067        assert!(find("glyphs.a.b").is_none());
1068        assert_eq!(find("display.mouse").map(|s| s.key), Some("display.mouse"));
1069    }
1070}