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: "log",
235        title: "Log",
236        intro: "",
237    },
238    Section {
239        name: "theme",
240        title: "Theme",
241        intro: "",
242    },
243    Section {
244        name: "theme.colors",
245        title: "Colors",
246        intro: "Each slot takes a name (`red`, `bright_blue`, `default`), `#rrggbb` or `indexed(0-255)`. They lie over the theme in use, `theme.dark` or `theme.light`, in either mode; a whole theme of your own goes in a file in `themes/`.",
247    },
248    Section {
249        name: "glyphs",
250        title: "Glyphs",
251        intro: "",
252    },
253];
254
255/// Every config key.
256pub const SETTINGS: &[Setting] = &[
257    // Top level
258    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."),
259    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."),
260    // [read]
261    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"),
262    s("read.parquet_schema", Choice(&["union", "first"]), Value("\"union\""), "A partitioned Parquet dataset's schema: union is every column any file has, from their footers; first lets Polars take one file's.").kwarg("parquet_schema"),
263    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"),
264    s("read.temp_dir", Path, Unset("\"/tmp\""), "Directory for decompression temp files. Unset: the system's.").flag("temp-dir").kwarg("temp_dir"),
265    s("read.follow_interval", Duration, Value("\"250ms\""), "With --follow, how often the file is checked for new rows, or on Linux the least time between two reads, 10ms to 1m. Appends within one interval are one refresh."),
266    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 it; 0 always counts."),
267    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."),
268    s("read.audio_float", Bool, Value("false"), "Show integer audio samples as float in [-1, 1].").kwarg("audio_float"),
269    // [csv]
270    s("csv.comment", Text, Unset("\"#\""), "Lines starting with this are comments, before the header and among the data.").flag("comment").kwarg("comment").spec("comment"),
271    s("csv.header_join", Text, Value("\" \""), "Joins a column's names when --header-rows names several lines.").kwarg("header_join").spec("header_join"),
272    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"),
273    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"),
274    s("csv.infer_rows", Count, Value("1000"), "Rows read to infer column types.").flag("infer-rows").kwarg("infer_rows"),
275    s("csv.ignore_errors", Bool, Value("false"), "Skip rows that do not parse instead of failing.").flag("ignore-errors").kwarg("ignore_errors"),
276    // [display]
277    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."),
278    s("display.row_numbers", Toml("\"auto\" \\| bool"), Value("\"auto\""), "Number rows on the left by their place in the source, kept through a sort or filter (# toggles). auto: for text and logs; true or false: for all of them.").flag("row-numbers").kwarg("row_numbers"),
279    s("display.row_numbers_start", Count, Value("1"), "The number of the source's first row.").kwarg("row_numbers_start"),
280    s("display.cell_padding", Toml("\"comfortable\" \\| \"compact\" \\| integer"), Value("\"comfortable\""), "Space between columns: comfortable (2 cells), compact (1) or a number of cells."),
281    s("display.column_colors", Bool, Value("true"), "Color cells by column type.").kwarg("column_colors"),
282    s("display.type_row", Bool, Value("true"), "A second header row naming each column's type (D toggles)."),
283    s("display.notes_accent", Bool, Value("true"), "Accent the i key when datui has noticed something about the data."),
284    s("display.mouse", Bool, Value("true"), "Take the mouse: the wheel scrolls, a click selects. false leaves it to the terminal.").flag("mouse"),
285    s("display.sidebar_width", Count, Unset("70"), "Width of every sidebar, in cells. Unset: each sidebar's own."),
286    s("display.right_align_numbers", Bool, Value("true"), "Right-align numeric columns and their headers.").kwarg("right_align_numbers"),
287    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"),
288    // [performance]
289    s("performance.pages_ahead", Count, Value("3"), "Pages of rows buffered ahead of the screen.").kwarg("pages_ahead"),
290    s("performance.pages_behind", Count, Value("3"), "Pages of rows buffered behind the screen.").kwarg("pages_behind"),
291    s("performance.max_buffered_rows", Count, Value("100000"), "Most rows the table buffers between reads; 0 for no limit.").kwarg("max_buffered_rows"),
292    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"),
293    s("performance.streaming", Bool, Value("true"), "Use the Polars streaming engine where it applies.").kwarg("streaming"),
294    // [analysis]
295    s("analysis.sample_rows", Count, Value("100000"), "Rows an analysis samples from a larger table, spread across all of it; 0 reads every row.").flag("sample-rows").kwarg("sample_rows"),
296    s("analysis.chart_rows", Count, Value("10000"), "Rows a chart reads; a larger table is sampled across all of it."),
297    s("analysis.chart_grid", Bool, Value("false"), "Start charts with a grid at the major ticks (g toggles)."),
298    s("analysis.quality_local_copy", Size, Value("\"2GiB\""), "Most a Data Quality full scan of a remote dataset copies into the cache to read once; 0 never copies."),
299    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."),
300    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."),
301    // [home]
302    s("home.desktop_recents", Bool, Value("true"), "Also list directories from the desktop's recently-used files; never the file names."),
303    s("home.show_unreadable", Bool, Value("false"), "List files datui cannot read, dimmed (Ctrl+A toggles)."),
304    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."),
305    s("home.preview_max", Size, Value("\"64MiB\""), "Largest local file whose first rows the home screen previews; 0 turns the preview off."),
306    s("home.search.enabled", Bool, Value("true"), "Search below the working directory as you type."),
307    s("home.search.max_depth", Count, Value("8"), "How many directories deep the search goes."),
308    s("home.search.max_results", Count, Value("1000"), "Matches listed; the rest are counted."),
309    s("home.search.time_budget", Duration, Value("\"1500ms\""), "How long the search walks before keeping what it found."),
310    s("home.search.cross_filesystems", Bool, Value("false"), "Descend into other filesystems, network mounts included."),
311    s("home.search.follow_gitignore", Bool, Value("false"), "Skip what .gitignore ignores."),
312    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."),
313    s("home.search.skip_extra", List, Value("[]"), "Directory names never searched, besides skip."),
314    s("home.search.extensions", List, Value("[]"), "Extensions searched for; empty means those of the formats datui reads."),
315    // [cloud]
316    s("cloud.connections", Tables, Unset("[]"), "Cloud stores to list on the home screen; see Cloud sources."),
317    s("cloud.hide", List, Value("[]"), "Cloud source IDs not shown on the home screen. Adds up across imports."),
318    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."),
319    s("cloud.env_files", List, Value("[]"), "Files to read cloud variables from, relative to the working directory, such as .env. Adds up across imports."),
320    s("cloud.instance_identity", Bool, Value("false"), "Use the identity of the cloud VM datui runs on (EC2, GCE, Azure)."),
321    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."),
322    s("cloud.list_on_start", Bool, Value("false"), "List every source's buckets when the home screen opens, not when one is entered."),
323    // [http]
324    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."),
325    // [query]
326    s("query.history_limit", Count, Value("1000"), "Queries remembered."),
327    s("query.history", Bool, Value("true"), "Remember queries."),
328    s("query.default_mode", Choice(&["sql", "q"]), Value("\"sql\""), "The language : starts in, until Ctrl+T picks another."),
329    // [views]
330    s("views.auto_apply", Bool, Value("false"), "Apply the best-matching view when a file opens."),
331    // [clipboard]
332    s("clipboard.backend", Choice(&["auto", "native", "osc52"]), Value("\"auto\""), "auto: the display server where one answers, osc52 elsewhere (SSH). osc52 is an escape sequence the terminal applies."),
333    s("clipboard.osc52_limit", Size, Value("\"100KiB\""), "Longest osc52 copy to attempt, as base64. Terminals cap what they accept."),
334    // [formats]
335    s("formats.path", List, Value("[]"), "Directories of format specs and dictionaries, searched after ~/.config/datui/formats and $DATUI_FORMATS_PATH. Adds up across imports."),
336    // [log]
337    s("log.file", Path, Unset("\"~/datui.log\""), "Where the log goes. Unset: datui.log in the cache directory.").flag("log-file"),
338    s("log.level", Choice(&["error", "warn", "info", "debug", "trace", "off"]), Unset("\"warn\""), "How much the log says (default warn). DATUI_LOG beats a config file's; -c and --log-level beat DATUI_LOG.").flag("log-level"),
339    // [theme]
340    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."),
341    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."),
342    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."),
343    color("theme.colors.chip_key", "#7dcfff", "#2e7de9", "Keys named in the footer, dialogs, the breadcrumb and the correlation matrix."),
344    color("theme.colors.chip_label", "#a9b1d6", "#3760bf", "Labels beside keys in the footer, and the footer's status."),
345    color("theme.colors.throbber", "#7dcfff", "#2e7de9", "The busy spinner."),
346    color("theme.colors.success", "#9ece6a", "#587539", "Success."),
347    color("theme.colors.error", "#f7768e", "#f52a65", "Errors."),
348    color("theme.colors.warning", "#e0af68", "#8c6c3e", "Warnings."),
349    color("theme.colors.dimmed", "#565f89", "#848cb5", "Dimmed text, nulls and axes."),
350    color("theme.colors.background", "default", "default", "Main background."),
351    color("theme.colors.surface", "default", "default", "Dialog background."),
352    color("theme.colors.controls_bg", "#262a3f", "#d0d5e3", "Count chips and dialogs' key chips."),
353    color("theme.colors.text_primary", "default", "default", "Text."),
354    color("theme.colors.text_secondary", "#737aa2", "#6172b0", "Secondary text."),
355    color("theme.colors.text_inverse", "#1a1b26", "#e1e2e7", "Text on a key chip."),
356    color("theme.colors.table_header", "#c0caf5", "#3760bf", "Header text."),
357    color("theme.colors.table_header_bg", "#2b3047", "#c4c8da", "Header fill."),
358    color("theme.colors.table_row_numbers", "#565f89", "#848cb5", "The row-number column."),
359    color("theme.colors.table_column_separator", "#3b4261", "#a8aecb", "The rule after frozen columns and beside section titles."),
360    color("theme.colors.table_selected", "#283457", "#b6bfe2", "Tint under the current row; reversed swaps text and background instead."),
361    color("theme.colors.table_column_cursor", "#292e42", "#cbd3f2", "Tint under the column cursor's cells."),
362    color("theme.colors.table_cell_cursor", "#3b4261", "#a0aef0", "The column cursor's header and the current cell."),
363    color("theme.colors.sidebar_border", "#565f89", "#6172b0", "Sidebar and dialog borders."),
364    color("theme.colors.modal_border_active", "#7dcfff", "#2e7de9", "The focused dialog's border."),
365    color("theme.colors.modal_border_error", "#f7768e", "#f52a65", "An error dialog's border."),
366    color("theme.colors.distribution_normal", "#9ece6a", "#587539", "Analysis: a normal distribution."),
367    color("theme.colors.distribution_skewed", "#e0af68", "#8c6c3e", "Analysis: a skewed distribution."),
368    color("theme.colors.distribution_other", "#c0caf5", "#3760bf", "Analysis: other distributions."),
369    color("theme.colors.outlier_marker", "#f7768e", "#f52a65", "Analysis: outliers."),
370    color("theme.colors.input_cursor", "default", "default", "The text caret; default reverses the text under it."),
371    color("theme.colors.input_cursor_text", "default", "default", "Text under the caret block; default picks black or white by contrast."),
372    color("theme.colors.table_alternate_row", "#1e2030", "#dcdfea", "Every other row; default turns the stripe off."),
373    color("theme.colors.type_str", "#9ece6a", "#587539", "String columns."),
374    color("theme.colors.type_int", "#7aa2f7", "#2e7de9", "Integer columns."),
375    color("theme.colors.type_float", "#2ac3de", "#007197", "Float columns."),
376    color("theme.colors.type_bool", "#e0af68", "#8c6c3e", "Boolean columns."),
377    color("theme.colors.type_temporal", "#bb9af7", "#9854f1", "Date, time and datetime columns."),
378    color("theme.colors.type_binary", "#565f89", "#848cb5", "Binary columns' placeholder."),
379    color("theme.colors.chart_1", "#7dcfff", "#2e7de9", "Chart series 1; also histogram bars, bar charts and Q-Q points."),
380    color("theme.colors.chart_2", "#bb9af7", "#9854f1", "Chart series 2."),
381    color("theme.colors.chart_3", "#9ece6a", "#587539", "Chart series 3."),
382    color("theme.colors.chart_4", "#e0af68", "#8c6c3e", "Chart series 4."),
383    color("theme.colors.chart_5", "#7aa2f7", "#007197", "Chart series 5."),
384    color("theme.colors.chart_6", "#f7768e", "#f52a65", "Chart series 6."),
385    color("theme.colors.chart_7", "#ff9e64", "#b15c00", "Chart series 7."),
386    color("theme.colors.chart_8", "#1abc9c", "#118c74", "Chart series 8."),
387    color("theme.colors.chart_9", "#ff5fd2", "#d1188c", "Chart series 9."),
388    color("theme.colors.chart_10", "#f4ef8a", "#24357a", "Chart series 10."),
389    color("theme.colors.chart_grid", "#3d4785", "#70aabf", "The chart grid, a shade dimmer than dimmed."),
390    color("theme.colors.accent", "#7dcfff", "#2e7de9", "Key chips, focused titles and the selection rail."),
391    color("theme.colors.accent_bright", "#a4daff", "#1a6cd0", "The section the cursor is in."),
392    color("theme.colors.gradient_start", "#7aa2f7", "#2e7de9", "The wordmark's first stop."),
393    color("theme.colors.gradient_end", "#bb9af7", "#9854f1", "The wordmark's last stop."),
394    color("theme.colors.find_match", "#e0af68", "#f0c35a", "Behind the cell a find landed on."),
395    color("theme.colors.hex_null", "#565f89", "#848cb5", "Hex view: the byte 0x00."),
396    color("theme.colors.hex_printable", "#7dcfff", "#007197", "Hex view: printable ASCII."),
397    color("theme.colors.hex_whitespace", "#9ece6a", "#587539", "Hex view: whitespace bytes."),
398    color("theme.colors.hex_control", "#bb9af7", "#9854f1", "Hex view: other control bytes."),
399    color("theme.colors.hex_high", "#e0af68", "#8c6c3e", "Hex view: 0x80 to 0xFE."),
400    color("theme.colors.hex_ff", "#f7768e", "#f52a65", "Hex view: the byte 0xFF."),
401    // [glyphs]
402    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."),
403];
404
405/// An option of one open, with no config key: it says how to read one file (#289),
406/// so it is a flag, a Python keyword and, for delimited text, a spec key.
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub struct OpenOption {
409    /// The flag, without `--`.
410    pub flag: &'static str,
411    pub kwarg: &'static str,
412    pub kind: Kind,
413    /// The key a delimited format spec writes it as.
414    pub spec: Option<&'static str>,
415}
416
417const fn open(flag: &'static str, kwarg: &'static str, kind: Kind) -> OpenOption {
418    OpenOption {
419        flag,
420        kwarg,
421        kind,
422        spec: None,
423    }
424}
425
426const fn open_spec(
427    flag: &'static str,
428    kwarg: &'static str,
429    kind: Kind,
430    spec: &'static str,
431) -> OpenOption {
432    OpenOption {
433        flag,
434        kwarg,
435        kind,
436        spec: Some(spec),
437    }
438}
439
440/// The open's own options that Python takes as keywords, beside the config keys'.
441pub const OPEN: &[OpenOption] = &[
442    open("format", "format", Text),
443    open("table", "table", Text),
444    open("hive", "hive", Bool),
445    open(
446        "compression",
447        "compression",
448        Choice(&["gzip", "zstd", "bzip2", "xz"]),
449    ),
450    open("dict", "dict", List),
451    open("view", "view", Text),
452    open_spec("delimiter", "delimiter", Text, "delimiter"),
453    open("no-header", "no_header", Bool),
454    open_spec("header-rows", "header_rows", List, "header_rows"),
455    open("footer-rows", "footer_rows", Count),
456    open("skip-rows", "skip_rows", Count),
457    open_spec("skip-lines", "skip_lines", Count, "skip_lines"),
458];
459
460/// Who an environment variable belongs to, as the reference groups them.
461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
462pub enum EnvGroup {
463    /// datui's own.
464    Datui,
465    /// The terminal's: color, glyphs.
466    Terminal,
467    /// The programs datui hands a value to.
468    Programs,
469    /// The cloud logins, as each provider's own tools read them.
470    Cloud,
471}
472
473impl EnvGroup {
474    /// The group's heading in the reference.
475    pub fn title(self) -> &'static str {
476        match self {
477            Self::Datui => "datui",
478            Self::Terminal => "Terminal",
479            Self::Programs => "Programs datui starts",
480            Self::Cloud => "Cloud logins",
481        }
482    }
483}
484
485/// One environment variable datui reads.
486#[derive(Debug, Clone, Copy, PartialEq, Eq)]
487pub struct EnvVar {
488    /// The name, or names read as one (`AWS_REGION`, `AWS_DEFAULT_REGION`).
489    pub names: &'static [&'static str],
490    pub group: EnvGroup,
491    /// What it does, as markdown.
492    pub doc: &'static str,
493}
494
495const fn env(names: &'static [&'static str], group: EnvGroup, doc: &'static str) -> EnvVar {
496    EnvVar { names, group, doc }
497}
498
499/// The environment variables datui reads, for the reference and the manpage.
500pub const ENVIRONMENT: &[EnvVar] = &[
501    env(
502        &["DATUI_CONFIG_DIR"],
503        EnvGroup::Datui,
504        "The config directory, in place of the platform's (`~/.config/datui` on Linux). Saved views and format specs live there too",
505    ),
506    env(
507        &["DATUI_CACHE_DIR"],
508        EnvGroup::Datui,
509        "The cache directory, in place of the platform's (`~/.cache/datui` on Linux)",
510    ),
511    env(
512        &["DATUI_FORMATS_PATH"],
513        EnvGroup::Datui,
514        "Directories of format specs and dictionaries, separated as `PATH` is, searched before `[formats] path`",
515    ),
516    env(
517        &["DATUI_LOG"],
518        EnvGroup::Datui,
519        "The log level: `error`, `warn`, `info`, `debug`, `trace` or `off`. Beats `log.level` in a file; `-c` and `--log-level` beat it",
520    ),
521    env(
522        &["DATUI_DEBUG"],
523        EnvGroup::Datui,
524        "`1` shows the debug overlay",
525    ),
526    env(
527        &["DATUI_GCP_PROJECT"],
528        EnvGroup::Datui,
529        "The Google Cloud project to list when projects cannot be searched, as `GOOGLE_CLOUD_PROJECT`",
530    ),
531    env(
532        &["DATUI_TRACE_FIRST_ROWS"],
533        EnvGroup::Datui,
534        "A file to write the time to, in Unix nanoseconds, once the first rows are drawn. For benchmarks",
535    ),
536    env(
537        &["NO_COLOR"],
538        EnvGroup::Terminal,
539        "Set to anything: no colors, the terminal's own for everything",
540    ),
541    env(
542        &["COLORTERM", "TERM", "FORCE_COLOR"],
543        EnvGroup::Terminal,
544        "How many colors the terminal draws: 24-bit, 256 or 16. Theme colors are brought down to fit",
545    ),
546    env(
547        &["COLORFGBG"],
548        EnvGroup::Terminal,
549        "With `theme.mode = \"auto\"`, says whether the background is light or dark, for a terminal that does not answer when asked",
550    ),
551    env(
552        &["TERM_PROGRAM"],
553        EnvGroup::Terminal,
554        "With `theme.mode = \"auto\"`, names the terminal whose last answer about its background picks the first frame's theme; `TERM` when unset",
555    ),
556    env(
557        &["LC_ALL", "LC_CTYPE", "LANG"],
558        EnvGroup::Terminal,
559        "With `display.unicode = \"auto\"`, the first one set says whether the terminal takes UTF-8; when it does not, glyphs are ASCII. With none set, Windows draws Unicode and other systems ASCII",
560    ),
561    env(
562        &["VISUAL", "EDITOR", "PAGER"],
563        EnvGroup::Programs,
564        "The inspector's `o` opens text in the first one set, else `less` (on Windows, the system's opener)",
565    ),
566    env(
567        &["AWS_PROFILE"],
568        EnvGroup::Cloud,
569        "The AWS profile for `s3://`, else `default`",
570    ),
571    env(
572        &[
573            "AWS_ACCESS_KEY_ID",
574            "AWS_SECRET_ACCESS_KEY",
575            "AWS_SESSION_TOKEN",
576        ],
577        EnvGroup::Cloud,
578        "AWS keys, and the token of temporary ones",
579    ),
580    env(
581        &["AWS_REGION", "AWS_DEFAULT_REGION"],
582        EnvGroup::Cloud,
583        "The AWS region",
584    ),
585    env(
586        &["AWS_ENDPOINT_URL_S3", "AWS_ENDPOINT_URL", "AWS_ENDPOINT"],
587        EnvGroup::Cloud,
588        "An S3-compatible endpoint (MinIO, R2, Ceph); the first one set",
589    ),
590    env(
591        &["AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE"],
592        EnvGroup::Cloud,
593        "The AWS config and credentials files, in place of `~/.aws/config` and `~/.aws/credentials`",
594    ),
595    env(
596        &[
597            "GOOGLE_APPLICATION_CREDENTIALS",
598            "GOOGLE_SERVICE_ACCOUNT",
599            "GOOGLE_SERVICE_ACCOUNT_PATH",
600            "GOOGLE_SERVICE_ACCOUNT_KEY",
601        ],
602        EnvGroup::Cloud,
603        "A Google Cloud service account or credentials file for `gs://`",
604    ),
605    env(
606        &[
607            "GOOGLE_CLOUD_PROJECT",
608            "GCLOUD_PROJECT",
609            "CLOUDSDK_CORE_PROJECT",
610            "GCP_PROJECT",
611        ],
612        EnvGroup::Cloud,
613        "The Google Cloud project to list buckets in, after `DATUI_GCP_PROJECT`; the first one set",
614    ),
615    env(
616        &["CLOUDSDK_CONFIG"],
617        EnvGroup::Cloud,
618        "The `gcloud` configuration directory, in place of `~/.config/gcloud`",
619    ),
620    env(
621        &["AZURE_STORAGE_CONNECTION_STRING"],
622        EnvGroup::Cloud,
623        "An Azure storage connection string, with `AccountKey` or `SharedAccessSignature`",
624    ),
625    env(
626        &[
627            "AZURE_STORAGE_ACCOUNT_NAME",
628            "AZURE_STORAGE_ACCOUNT_KEY",
629            "AZURE_STORAGE_SAS_TOKEN",
630        ],
631        EnvGroup::Cloud,
632        "An Azure storage account and its key or SAS token",
633    ),
634    env(
635        &[
636            "AZURE_TENANT_ID",
637            "AZURE_CLIENT_ID",
638            "AZURE_CLIENT_SECRET",
639            "AZURE_FEDERATED_TOKEN_FILE",
640        ],
641        EnvGroup::Cloud,
642        "An Azure service principal, or AKS workload identity",
643    ),
644    env(
645        &["AZURE_CONFIG_DIR"],
646        EnvGroup::Cloud,
647        "The Azure CLI's directory, in place of `~/.azure`",
648    ),
649];
650
651/// `docs/reference/environment.md`: every variable in [`ENVIRONMENT`], by group.
652pub fn render_environment_markdown() -> String {
653    let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
654    let mut out = String::from(
655        "# Environment variables\n\n\
656         <!-- Generated from crates/datui-cli/src/settings.rs by `gen_docs`. Do not edit. -->\n\n\
657         The variables datui reads.\n",
658    );
659    for group in [
660        EnvGroup::Datui,
661        EnvGroup::Terminal,
662        EnvGroup::Programs,
663        EnvGroup::Cloud,
664    ] {
665        out.push_str(&format!("\n## {}\n\n", group.title()));
666        if group == EnvGroup::Cloud {
667            out.push_str(
668                "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",
669            );
670        }
671        out.push_str("| Variable | What it does |\n|---|---|\n");
672        for var in ENVIRONMENT.iter().filter(|v| v.group == group) {
673            let names: Vec<String> = var.names.iter().map(|n| format!("`{n}`")).collect();
674            out.push_str(&format!("| {} | {} |\n", names.join(", "), cell(var.doc)));
675        }
676    }
677    out
678}
679
680/// The setting `key` names, if any.
681pub fn find(key: &str) -> Option<&'static Setting> {
682    SETTINGS.iter().find(|s| s.matches(key))
683}
684
685/// A flag's help: its setting's doc and key. Panics on a flag no setting names, which
686/// `--help` and the tests reach.
687pub fn flag_help(flag: &str) -> String {
688    let setting = by_flag(flag).unwrap_or_else(|| panic!("--{flag} sets no registered key"));
689    format!("{} [config: {}]", setting.doc, setting.key)
690}
691
692/// The setting a Python keyword sets, if one does.
693pub fn by_kwarg(kwarg: &str) -> Option<&'static Setting> {
694    SETTINGS.iter().find(|s| s.kwarg == Some(kwarg))
695}
696
697/// The setting a flag sets, if one does.
698pub fn by_flag(flag: &str) -> Option<&'static Setting> {
699    SETTINGS.iter().find(|s| s.flag == Some(flag))
700}
701
702/// The settings of `section`, in table order.
703pub fn in_section(section: &str) -> impl Iterator<Item = &'static Setting> + '_ {
704    SETTINGS.iter().filter(move |s| s.section() == section)
705}
706
707/// How a value of each type is written, as Markdown: the settings reference and
708/// datui-config(5).
709pub const TYPES: &[(&str, &str)] = &[
710    (
711        "size",
712        "A number and a unit: `512MiB`, `2GiB`, `100KiB` (`MB`, `GB` are powers of 1000). `0` needs none",
713    ),
714    ("duration", "A number and a unit: `250ms`, `1.5s`, `2m`"),
715    (
716        "list",
717        "In a file, a TOML array; with `-c`, `a,b` or the array",
718    ),
719    (
720        "color",
721        "A name (`red`, `bright_blue`, `default`), `#rrggbb` or `indexed(0-255)`",
722    ),
723];
724
725/// `docs/reference/settings.md`: every key by section, from this table. Written by
726/// `gen_docs settings`; a test fails while the committed page differs.
727pub fn render_settings_markdown() -> String {
728    let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
729    let mut out = String::from(
730        "# Settings\n\n\
731         <!-- Generated from crates/datui-cli/src/settings.rs by `gen_docs settings`. Do not edit. -->\n\n\
732         Set these in `config.toml` (`datui config init` writes one with every key\n\
733         commented out), or for one run with `-c KEY=VALUE`:\n\n\
734         ```bash\n\
735         printf 'a,b\\n1,2\\n' | datui -c display.row_numbers=true\n\
736         ```\n\n\
737         A flag beats `-c`, which beats the config files, which beat the defaults.\n\
738         `datui config keys` lists every key with its value in effect and where it was\n\
739         set. See [Configure datui](../user-guide/configuration.md) for where the file\n\
740         lives, imports, the theme and troubleshooting.\n\n\
741         | Type | Written as |\n\
742         |---|---|\n",
743    );
744    for (kind, written) in TYPES {
745        out.push_str(&format!("| {kind} | {written} |\n"));
746    }
747    for section in SECTIONS {
748        let settings: Vec<&Setting> = in_section(section.name).collect();
749        if settings.is_empty() {
750            continue;
751        }
752        out.push_str(&format!("\n## {}\n\n", section.title));
753        if !section.name.is_empty() {
754            out.push_str(&format!("`[{}]`", section.name));
755            if !section.intro.is_empty() {
756                out.push_str(&format!(" {}", section.intro));
757            }
758            out.push_str("\n\n");
759        } else if !section.intro.is_empty() {
760            out.push_str(&format!("{}\n\n", section.intro));
761        }
762        let colors = settings
763            .iter()
764            .all(|s| matches!(s.default, DefaultValue::Color { .. }));
765        if colors {
766            out.push_str("| Key | Dark | Light | Description |\n|---|---|---|---|\n");
767        } else {
768            out.push_str("| Key | Type | Default | Flag | Description |\n|---|---|---|---|---|\n");
769        }
770        for setting in settings {
771            let key = format!("`{}`", setting.key);
772            match setting.default {
773                DefaultValue::Color { dark, light } => out.push_str(&format!(
774                    "| {key} | `{dark}` | `{light}` | {} |\n",
775                    cell(setting.doc)
776                )),
777                DefaultValue::Value(v) | DefaultValue::Unset(v) => {
778                    let default = match setting.default {
779                        DefaultValue::Value(_) => format!("`{}`", cell(v)),
780                        _ => "unset".to_string(),
781                    };
782                    let flag = setting.flag.map(|f| format!("`--{f}`")).unwrap_or_default();
783                    out.push_str(&format!(
784                        "| {key} | {} | {default} | {flag} | {} |\n",
785                        setting.kind.describe(),
786                        cell(setting.doc)
787                    ));
788                }
789            }
790        }
791    }
792    out.push_str(
793        "\nThe environment variables datui reads are in\n\
794         [Environment variables](environment.md).\n",
795    );
796    out
797}
798
799/// One `-c KEY=VALUE`: a key the registry knows and its value, read for the key's
800/// kind.
801#[derive(Debug, Clone, PartialEq)]
802pub struct Override {
803    pub key: String,
804    pub value: toml::Value,
805}
806
807impl std::str::FromStr for Override {
808    type Err = String;
809
810    /// `KEY=VALUE`, split at the first `=`. An unknown key names the nearest known
811    /// ones; a value the key cannot take says what it takes.
812    fn from_str(text: &str) -> Result<Self, String> {
813        let Some((key, value)) = text.split_once('=') else {
814            return Err(format!(
815                "\"{text}\" is not KEY=VALUE, as in -c display.row_numbers=true"
816            ));
817        };
818        let key = key.trim();
819        let setting = find(key).ok_or_else(|| unknown_key(key))?;
820        let value = parse_value(setting, value).map_err(|e| {
821            format!(
822                "{key}: {e} ({key} takes {})",
823                setting.kind.describe().replace("\\|", "|")
824            )
825        })?;
826        Ok(Self {
827            key: key.to_string(),
828            value,
829        })
830    }
831}
832
833/// `text` as the value of `setting`. Text needs no quotes, as with `git -c`.
834pub fn parse_value(setting: &Setting, text: &str) -> Result<toml::Value, String> {
835    let trimmed = text.trim();
836    Ok(match setting.kind {
837        Bool => toml::Value::Boolean(parse_bool(trimmed)?),
838        Count => {
839            let n: u64 = trimmed
840                .replace('_', "")
841                .parse()
842                .map_err(|_| format!("\"{trimmed}\" is not a whole number"))?;
843            toml::Value::Integer(i64::try_from(n).map_err(|_| format!("{n} is too large"))?)
844        }
845        Text | Path | Color => toml::Value::String(text.to_string()),
846        List => {
847            if trimmed.starts_with('[') {
848                toml_value(trimmed)
849                    .filter(toml::Value::is_array)
850                    .ok_or_else(|| format!("\"{trimmed}\" is not a list"))?
851            } else {
852                toml::Value::Array(
853                    trimmed
854                        .split(',')
855                        .map(str::trim)
856                        .filter(|item| !item.is_empty())
857                        .map(|item| toml::Value::String(item.to_string()))
858                        .collect(),
859                )
860            }
861        }
862        Choice(words) => {
863            let word = trimmed.to_ascii_lowercase();
864            if !words.contains(&word.as_str()) {
865                return Err(format!("\"{trimmed}\" is not one of {}", words.join(", ")));
866            }
867            toml::Value::String(word)
868        }
869        Size => {
870            crate::units::parse_size(trimmed)?;
871            toml::Value::String(trimmed.to_string())
872        }
873        Duration => {
874            crate::units::parse_duration(trimmed)?;
875            toml::Value::String(trimmed.to_string())
876        }
877        // A bool, number, list or table as TOML; anything else is a word.
878        Toml(_) => toml_value(trimmed).unwrap_or_else(|| toml::Value::String(trimmed.to_string())),
879        Tables => return Err("is a list of tables; write it in a config file".into()),
880    })
881}
882
883/// `true`, `false` and the words people use for them.
884pub fn parse_bool(text: &str) -> Result<bool, String> {
885    match text.to_ascii_lowercase().as_str() {
886        "true" | "yes" | "on" | "1" => Ok(true),
887        "false" | "no" | "off" | "0" => Ok(false),
888        _ => Err(format!("\"{text}\" is not true or false")),
889    }
890}
891
892fn toml_value(text: &str) -> Option<toml::Value> {
893    format!("v = {text}")
894        .parse::<toml::Table>()
895        .ok()
896        .and_then(|mut t| t.remove("v"))
897}
898
899/// The error for a key the registry does not know, with the nearest keys.
900fn unknown_key(key: &str) -> String {
901    let near = suggestions(key);
902    let mut message = format!("\"{key}\" is not a config key");
903    if !near.is_empty() {
904        message.push_str(&format!("; did you mean {}?", near.join(" or ")));
905    }
906    message.push_str(" `datui config keys` lists them");
907    message
908}
909
910/// Keys close to `key`: a few edits away, or a name in another section that one of
911/// them holds (`comment_char` and `comment`, so a renamed key finds its new name).
912pub fn suggestions(key: &str) -> Vec<&'static str> {
913    let name = key.rsplit_once('.').map_or(key, |(_, n)| n);
914    let mut scored: Vec<(usize, &'static str)> = SETTINGS
915        .iter()
916        .filter(|s| !s.key.ends_with(".*"))
917        .filter_map(|s| {
918            let distance = edit_distance(key, s.key);
919            let close = distance <= (key.len() / 4).max(2);
920            let other = s.name();
921            let alike = other == name
922                || (other.len() >= 4 && name.contains(other))
923                || (name.len() >= 4 && other.contains(name));
924            (close || alike).then_some((distance, s.key))
925        })
926        .collect();
927    scored.sort();
928    scored.into_iter().take(3).map(|(_, k)| k).collect()
929}
930
931/// Levenshtein distance, by characters.
932fn edit_distance(a: &str, b: &str) -> usize {
933    let b: Vec<char> = b.chars().collect();
934    let mut row: Vec<usize> = (0..=b.len()).collect();
935    for (i, ca) in a.chars().enumerate() {
936        let mut diagonal = row[0];
937        row[0] = i + 1;
938        for (j, cb) in b.iter().enumerate() {
939            let above = row[j + 1];
940            row[j + 1] = (diagonal + usize::from(ca != *cb))
941                .min(above + 1)
942                .min(row[j] + 1);
943            diagonal = above;
944        }
945    }
946    row[b.len()]
947}
948
949#[cfg(test)]
950mod tests {
951    use super::*;
952
953    #[test]
954    fn an_override_reads_its_value_for_the_key() {
955        let o: Override = "display.mouse=yes".parse().unwrap();
956        assert_eq!(o.value, toml::Value::Boolean(true));
957        let o: Override = "display.row_numbers=auto".parse().unwrap();
958        assert_eq!(o.value, toml::Value::String("auto".into()));
959        let o: Override = "display.row_numbers=false".parse().unwrap();
960        assert_eq!(o.value, toml::Value::Boolean(false));
961        let o: Override = "csv.comment=#".parse().unwrap();
962        assert_eq!(o.value, toml::Value::String("#".into()));
963        let o: Override = "cloud.env_files=a.env, b.env".parse().unwrap();
964        assert_eq!(o.value.as_array().map(Vec::len), Some(2));
965        let o: Override = "home.hide=[\"x\"]".parse().unwrap();
966        assert_eq!(o.value.as_array().map(Vec::len), Some(1));
967        let o: Override = "cloud.discover=s3,gcs".parse().unwrap();
968        assert_eq!(o.value, toml::Value::String("s3,gcs".into()));
969        let o: Override = "cloud.discover=false".parse().unwrap();
970        assert_eq!(o.value, toml::Value::Boolean(false));
971        let o: Override = "glyphs.spinner=[\"a\", \"b\"]".parse().unwrap();
972        assert!(o.value.is_array());
973        // Only the first `=` splits.
974        let o: Override = "csv.null_values=amount=".parse().unwrap();
975        assert_eq!(o.value.as_array().unwrap()[0].as_str(), Some("amount="));
976    }
977
978    #[test]
979    fn sizes_and_durations_take_their_unit() {
980        let o: Override = "performance.max_buffered=1GiB".parse().unwrap();
981        assert_eq!(o.value, toml::Value::String("1GiB".into()));
982        let e = "performance.max_buffered=512"
983            .parse::<Override>()
984            .unwrap_err();
985        assert!(e.contains("needs a unit"), "{e}");
986        let o: Override = "read.follow_interval=1s".parse().unwrap();
987        assert_eq!(o.value, toml::Value::String("1s".into()));
988        let e = "read.follow_interval=fast".parse::<Override>().unwrap_err();
989        assert!(e.contains("duration"), "{e}");
990    }
991
992    #[test]
993    fn an_override_refuses_with_the_way_out() {
994        let e = "file_loading.comment_char=#"
995            .parse::<Override>()
996            .unwrap_err();
997        assert!(e.contains("did you mean csv.comment"), "{e}");
998        let e = "performance.polars_streaming=false"
999            .parse::<Override>()
1000            .unwrap_err();
1001        assert!(e.contains("performance.streaming"), "{e}");
1002        let e = "display.row_number=true".parse::<Override>().unwrap_err();
1003        assert!(e.contains("did you mean display.row_numbers"), "{e}");
1004        let e = "row_numbers=true".parse::<Override>().unwrap_err();
1005        assert!(e.contains("display.row_numbers"), "{e}");
1006        let e = "display.row_numbers_start=one"
1007            .parse::<Override>()
1008            .unwrap_err();
1009        assert!(
1010            e.contains("not a whole number") && e.contains("integer"),
1011            "{e}"
1012        );
1013        let e = "display.mouse=maybe".parse::<Override>().unwrap_err();
1014        assert!(e.contains("not true or false"), "{e}");
1015        let e = "display.unicode=sometimes".parse::<Override>().unwrap_err();
1016        assert!(e.contains("auto, always, never"), "{e}");
1017        let e = "cloud.connections=x".parse::<Override>().unwrap_err();
1018        assert!(e.contains("config file"), "{e}");
1019        let e = "display.mouse".parse::<Override>().unwrap_err();
1020        assert!(e.contains("KEY=VALUE"), "{e}");
1021        let e = "nothing.like.this=1".parse::<Override>().unwrap_err();
1022        assert!(e.contains("config keys"), "{e}");
1023    }
1024
1025    #[test]
1026    fn every_key_is_listed_once_in_a_known_section() {
1027        for (i, setting) in SETTINGS.iter().enumerate() {
1028            assert!(
1029                SECTIONS.iter().any(|s| s.name == setting.section()),
1030                "{} has no section",
1031                setting.key
1032            );
1033            assert!(
1034                SETTINGS[..i].iter().all(|other| other.key != setting.key),
1035                "{} is listed twice",
1036                setting.key
1037            );
1038            assert!(!setting.doc.is_empty(), "{} has no doc", setting.key);
1039        }
1040    }
1041
1042    #[test]
1043    fn a_wildcard_key_matches_one_name_below_it() {
1044        let glyph = find("glyphs.spinner").expect("a glyph slot");
1045        assert_eq!(glyph.key, "glyphs.*");
1046        assert!(find("glyphs").is_none());
1047        assert!(find("glyphs.a.b").is_none());
1048        assert_eq!(find("display.mouse").map(|s| s.key), Some("display.mouse"));
1049    }
1050}