1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Kind {
14 Bool,
15 Count,
17 Text,
18 Path,
20 List,
22 Choice(&'static [&'static str]),
24 Size,
26 Duration,
28 Color,
30 Toml(&'static str),
32 Tables,
34}
35
36impl Kind {
37 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum DefaultValue {
58 Value(&'static str),
59 Unset(&'static str),
61 Color {
63 dark: &'static str,
64 light: &'static str,
65 },
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct Setting {
71 pub key: &'static str,
73 pub kind: Kind,
74 pub default: DefaultValue,
75 pub doc: &'static str,
77 pub flag: Option<&'static str>,
79 pub kwarg: Option<&'static str>,
81 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 pub fn section(&self) -> &'static str {
115 self.key.rsplit_once('.').map_or("", |(s, _)| s)
116 }
117
118 pub fn name(&self) -> &'static str {
120 self.key.rsplit_once('.').map_or(self.key, |(_, n)| n)
121 }
122
123 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct Section {
150 pub name: &'static str,
151 pub title: &'static str,
152 pub intro: &'static str,
154}
155
156pub 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: "The most of a file some readers take in. Past one, the rest is left out or the file refused, and the note or error names the key that raises it.",
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 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/`.",
252 },
253 Section {
254 name: "glyphs",
255 title: "Glyphs",
256 intro: "",
257 },
258];
259
260pub const SETTINGS: &[Setting] = &[
262 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 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\""), "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"),
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.").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, or on Linux the least time between two reads, 10ms to 1m. Appends within one interval are 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 it; 0 always counts."),
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 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 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, 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"),
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"), "Take the mouse: the wheel scrolls, a click selects. false leaves it to the terminal.").flag("mouse"),
290 s("display.scroll_region", Bool, Value("true"), "Move lines within the terminal when the screen scrolls, so a scroll writes the new lines, not the 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's own."),
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 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 caps speed, not memory; POLARS_MAX_THREADS, when set, wins. The datui command only: the Python module runs its own Polars, which POLARS_MAX_THREADS sizes when it first computes."),
301 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"),
303 s("analysis.chart_rows", Count, Value("10000"), "Rows a chart reads; a larger table is sampled across all of it."),
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\""), "Most a Data Quality full scan of a remote dataset copies into the cache to read once; 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 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; off, the one-line title bar."),
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 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 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 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 : starts in, until Ctrl+T picks another."),
337 s("views.auto_apply", Bool, Value("false"), "Apply the best-matching view when a file opens."),
339 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."),
341 s("clipboard.osc52_limit", Size, Value("\"100KiB\""), "Longest osc52 copy to attempt, as base64. Terminals cap what they accept."),
342 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 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 goes. 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 says (default warn). DATUI_LOG beats a config file's; -c and --log-level beat DATUI_LOG.").flag("log-level"),
359 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
428pub struct OpenOption {
429 pub flag: &'static str,
431 pub kwarg: &'static str,
432 pub kind: Kind,
433 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
460pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub enum EnvGroup {
483 Datui,
485 Terminal,
487 Programs,
489 Cloud,
491}
492
493impl EnvGroup {
494 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507pub struct EnvVar {
508 pub names: &'static [&'static str],
510 pub group: EnvGroup,
511 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
519pub 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`. Beats `log.level` in a file; `-c` and `--log-level` beat 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, as `GOOGLE_CLOUD_PROJECT`",
550 ),
551 env(
552 &["DATUI_TRACE_FIRST_ROWS"],
553 EnvGroup::Datui,
554 "A file to write the time to, in Unix nanoseconds, once the first rows are drawn. For benchmarks",
555 ),
556 env(
557 &["NO_COLOR"],
558 EnvGroup::Terminal,
559 "Set to anything: no colors, the terminal's own 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 brought down 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\"`, names the terminal whose last answer about its background picks the first frame's theme; `TERM` when 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; when it does not, glyphs are ASCII. With none set, Windows draws 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
671pub 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
700pub fn find(key: &str) -> Option<&'static Setting> {
702 SETTINGS.iter().find(|s| s.matches(key))
703}
704
705pub 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
712pub fn by_kwarg(kwarg: &str) -> Option<&'static Setting> {
714 SETTINGS.iter().find(|s| s.kwarg == Some(kwarg))
715}
716
717pub fn by_flag(flag: &str) -> Option<&'static Setting> {
719 SETTINGS.iter().find(|s| s.flag == Some(flag))
720}
721
722pub fn in_section(section: &str) -> impl Iterator<Item = &'static Setting> + '_ {
724 SETTINGS.iter().filter(move |s| s.section() == section)
725}
726
727pub 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
745pub 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#[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 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
853pub 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 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
903pub 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
919fn 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
930pub 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
951fn 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 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}