#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Bool,
Count,
Text,
Path,
List,
Choice(&'static [&'static str]),
Size,
Duration,
Color,
Toml(&'static str),
Tables,
}
impl Kind {
pub fn describe(&self) -> String {
match self {
Kind::Bool => "bool".into(),
Kind::Count => "integer".into(),
Kind::Text => "string".into(),
Kind::Path => "path".into(),
Kind::List => "list".into(),
Kind::Choice(words) => words.join(" \\| "),
Kind::Size => "size".into(),
Kind::Duration => "duration".into(),
Kind::Color => "color".into(),
Kind::Toml(shape) => (*shape).into(),
Kind::Tables => "tables".into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DefaultValue {
Value(&'static str),
Unset(&'static str),
Color {
dark: &'static str,
light: &'static str,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Setting {
pub key: &'static str,
pub kind: Kind,
pub default: DefaultValue,
pub doc: &'static str,
pub flag: Option<&'static str>,
pub kwarg: Option<&'static str>,
pub spec: Option<&'static str>,
}
const fn s(key: &'static str, kind: Kind, default: DefaultValue, doc: &'static str) -> Setting {
Setting {
key,
kind,
default,
doc,
flag: None,
kwarg: None,
spec: None,
}
}
impl Setting {
const fn flag(mut self, flag: &'static str) -> Self {
self.flag = Some(flag);
self
}
const fn kwarg(mut self, kwarg: &'static str) -> Self {
self.kwarg = Some(kwarg);
self
}
const fn spec(mut self, key: &'static str) -> Self {
self.spec = Some(key);
self
}
pub fn section(&self) -> &'static str {
self.key.rsplit_once('.').map_or("", |(s, _)| s)
}
pub fn name(&self) -> &'static str {
self.key.rsplit_once('.').map_or(self.key, |(_, n)| n)
}
pub fn matches(&self, key: &str) -> bool {
match self.key.strip_suffix(".*") {
Some(prefix) => key
.strip_prefix(prefix)
.and_then(|rest| rest.strip_prefix('.'))
.is_some_and(|name| !name.is_empty() && !name.contains('.')),
None => self.key == key,
}
}
}
use DefaultValue::{Unset, Value};
use Kind::*;
const fn color(
key: &'static str,
dark: &'static str,
light: &'static str,
doc: &'static str,
) -> Setting {
s(key, Color, DefaultValue::Color { dark, light }, doc)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Section {
pub name: &'static str,
pub title: &'static str,
pub intro: &'static str,
}
pub const SECTIONS: &[Section] = &[
Section {
name: "",
title: "Top level",
intro: "",
},
Section {
name: "read",
title: "Read",
intro: "How files are read. A file's own layout (delimiter, header, rows to skip) is a flag for that file, not a setting.",
},
Section {
name: "csv",
title: "CSV",
intro: "CSV, TSV and PSV. A [delimited format spec](../formats/format-specs.md#delimited-text) takes these keys too.",
},
Section {
name: "display",
title: "Display",
intro: "",
},
Section {
name: "performance",
title: "Performance",
intro: "The rows the table buffers between reads, and the engine.",
},
Section {
name: "analysis",
title: "Analysis",
intro: "Analysis, Data Quality and charts.",
},
Section {
name: "chart",
title: "Chart",
intro: "Charts exported to a file (`e` in the chart view).",
},
Section {
name: "home",
title: "Home",
intro: "The home screen.",
},
Section {
name: "home.search",
title: "Home search",
intro: "Searching below the working directory as you type on the home screen.",
},
Section {
name: "cloud",
title: "Cloud",
intro: "See [Cloud sources](cloud-sources.md) for `[[cloud.connections]]`.",
},
Section {
name: "http",
title: "HTTP",
intro: "Every request datui makes: HTTP(S) files, cloud stores and their sign-ins.",
},
Section {
name: "query",
title: "Query",
intro: "",
},
Section {
name: "views",
title: "Views",
intro: "",
},
Section {
name: "clipboard",
title: "Clipboard",
intro: "How the copy dialog (`y`) reaches the system clipboard.",
},
Section {
name: "formats",
title: "Formats",
intro: "Where [format specs](../formats/format-specs.md) and dictionaries are found.",
},
Section {
name: "limits",
title: "Limits",
intro: "How much of a file some readers take in. Past a limit, the rest is left out or the file is refused, and the note or error names the key that raises the limit.",
},
Section {
name: "log",
title: "Log",
intro: "",
},
Section {
name: "theme",
title: "Theme",
intro: "",
},
Section {
name: "theme.colors",
title: "Colors",
intro: "Each slot takes a name (`red`, `bright_blue`, `default`), `#rrggbb` or `indexed(0-255)`. They override the theme in use, `theme.dark` or `theme.light`, in either mode; a whole theme of your own goes in a file in `themes/`.",
},
Section {
name: "glyphs",
title: "Glyphs",
intro: "",
},
];
pub const SETTINGS: &[Setting] = &[
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."),
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."),
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"),
s("read.parquet_schema", Choice(&["union", "first"]), Value("\"union\""), "The schema of a partitioned Parquet dataset: union takes every column any file has, from their footers; first lets Polars take one file's schema.").kwarg("parquet_schema"),
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"),
s("read.temp_dir", Path, Unset("\"/tmp\""), "Directory for decompression temp files. Unset, the system's temp directory.").flag("temp-dir").kwarg("temp_dir"),
s("read.follow_interval", Duration, Value("\"250ms\""), "With --follow, how often the file is checked for new rows (on Linux, the shortest time between two reads), from 10ms to 1m. Rows appended within one interval arrive in one refresh."),
s("read.exact_count_files", Count, Value("50000"), "A dataset of more files than this shows a row count estimated from a sample of its footers, until c in the Info panel counts exactly; 0 always counts exactly."),
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."),
s("read.audio_float", Bool, Value("false"), "Show integer audio samples as float in [-1, 1].").kwarg("audio_float"),
s("csv.comment", Text, Unset("\"#\""), "Lines starting with this are comments, before the header and among the data.").flag("comment").kwarg("comment").spec("comment"),
s("csv.header_join", Text, Value("\" \""), "Joins a column's names when --header-rows names several lines.").kwarg("header_join").spec("header_join"),
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"),
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"),
s("csv.infer_rows", Count, Value("1000"), "Rows read to infer column types.").flag("infer-rows").kwarg("infer_rows"),
s("csv.ignore_errors", Bool, Value("false"), "Skip rows that do not parse instead of failing.").flag("ignore-errors").kwarg("ignore_errors"),
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."),
s("display.row_numbers", Toml("\"auto\" \\| bool"), Value("\"auto\""), "Number rows on the left by their place in the source; a row keeps its number through a sort or filter (# toggles). auto numbers text and logs; true or false turns them on or off for every table.").flag("row-numbers").kwarg("row_numbers"),
s("display.row_numbers_start", Count, Value("1"), "The number of the source's first row.").kwarg("row_numbers_start"),
s("display.cell_padding", Toml("\"comfortable\" \\| \"compact\" \\| integer"), Value("\"comfortable\""), "Space between columns: comfortable (2 cells), compact (1) or a number of cells."),
s("display.column_colors", Bool, Value("true"), "Color cells by column type.").kwarg("column_colors"),
s("display.type_row", Bool, Value("true"), "A second header row naming each column's type (D toggles)."),
s("display.notes_accent", Bool, Value("true"), "Accent the i key when datui has noticed something about the data."),
s("display.mouse", Bool, Value("true"), "Use the mouse: the wheel scrolls and a click selects. false leaves the mouse to the terminal.").flag("mouse"),
s("display.scroll_region", Bool, Value("true"), "Scroll by moving lines within the terminal, so a scroll writes only the new lines, not the whole page. false redraws every line that moved, for a terminal that mishandles the move."),
s("display.sidebar_width", Count, Unset("70"), "Width of every sidebar, in cells. Unset, each sidebar uses its own width."),
s("display.right_align_numbers", Bool, Value("true"), "Right-align numeric columns and their headers.").kwarg("right_align_numbers"),
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"),
s("performance.pages_ahead", Count, Value("3"), "Pages of rows buffered ahead of the screen.").kwarg("pages_ahead"),
s("performance.pages_behind", Count, Value("3"), "Pages of rows buffered behind the screen.").kwarg("pages_behind"),
s("performance.max_buffered_rows", Count, Value("100000"), "Most rows the table buffers between reads; 0 for no limit.").kwarg("max_buffered_rows"),
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"),
s("performance.streaming", Bool, Value("true"), "Use the Polars streaming engine where it applies.").kwarg("streaming"),
s("performance.threads", Count, Value("0"), "Most threads Polars computes with; 0 for every core. It limits speed, not memory. POLARS_MAX_THREADS, when set, wins. Applies to the datui command only: the Python module runs its own Polars, sized by POLARS_MAX_THREADS when it first computes."),
s("analysis.sample_rows", Count, Value("100000"), "Rows an analysis samples from a larger table, spread across the whole table; 0 reads every row.").flag("sample-rows").kwarg("sample_rows"),
s("analysis.chart_rows", Count, Value("10000"), "Rows a chart reads; a larger table is sampled across its whole length."),
s("analysis.chart_grid", Bool, Value("false"), "Start charts with a grid at the major ticks (g toggles)."),
s("analysis.quality_local_copy", Size, Value("\"2GiB\""), "Largest remote dataset a Data Quality full scan copies into the cache, so it is fetched once and every pass reads the copy; 0 never copies."),
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."),
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."),
s("home.desktop_recents", Bool, Value("true"), "Also list directories from the desktop's recently-used files; never the file names."),
s("home.wordmark", Bool, Value("true"), "Show the datui wordmark at the top of the home screen; false shows a one-line title bar instead."),
s("home.show_unreadable", Bool, Value("false"), "List files datui cannot read, dimmed (Ctrl+A toggles)."),
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."),
s("home.preview_max", Size, Value("\"64MiB\""), "Largest local file whose first rows the home screen previews; 0 turns the preview off."),
s("home.search.enabled", Bool, Value("true"), "Search below the working directory as you type."),
s("home.search.max_depth", Count, Value("8"), "How many directories deep the search goes."),
s("home.search.max_results", Count, Value("1000"), "Matches listed; the rest are counted."),
s("home.search.time_budget", Duration, Value("\"1500ms\""), "How long the search walks before keeping what it found."),
s("home.search.cross_filesystems", Bool, Value("false"), "Descend into other filesystems, network mounts included."),
s("home.search.follow_gitignore", Bool, Value("false"), "Skip what .gitignore ignores."),
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."),
s("home.search.skip_extra", List, Value("[]"), "Directory names never searched, besides skip."),
s("home.search.extensions", List, Value("[]"), "Extensions searched for; empty means those of the formats datui reads."),
s("cloud.connections", Tables, Unset("[]"), "Cloud stores to list on the home screen; see Cloud sources."),
s("cloud.hide", List, Value("[]"), "Cloud source IDs not shown on the home screen. Adds up across imports."),
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."),
s("cloud.env_files", List, Value("[]"), "Files to read cloud variables from, relative to the working directory, such as .env. Adds up across imports."),
s("cloud.instance_identity", Bool, Value("false"), "Use the identity of the cloud VM datui runs on (EC2, GCE, Azure)."),
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."),
s("cloud.list_on_start", Bool, Value("false"), "List every source's buckets when the home screen opens, not when one is entered."),
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."),
s("query.history_limit", Count, Value("1000"), "Queries remembered."),
s("query.history", Bool, Value("true"), "Remember queries."),
s("query.default_mode", Choice(&["sql", "q"]), Value("\"sql\""), "The language the : command line starts in, until Ctrl+T picks another."),
s("views.auto_apply", Bool, Value("false"), "Apply the best-matching view when a file opens."),
s("clipboard.backend", Choice(&["auto", "native", "osc52"]), Value("\"auto\""), "auto uses the display server where one answers, and osc52 elsewhere (SSH). osc52 is an escape sequence that asks the terminal to copy."),
s("clipboard.osc52_limit", Size, Value("\"100KiB\""), "Longest osc52 copy to attempt, as base64. Terminals cap what they accept."),
s("formats.path", List, Value("[]"), "Directories of format specs and dictionaries, searched after ~/.config/datui/formats and $DATUI_FORMATS_PATH. Adds up across imports."),
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."),
s("limits.elf_symbols", Count, Value("10000000"), "Most symbols read from an ELF file."),
s("limits.midi_bytes", Size, Value("\"64MiB\""), "Largest MIDI file read; a larger one is refused."),
s("limits.midi_events", Count, Value("10000000"), "Most events read from MIDI files, all files of one open together."),
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."),
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."),
s("limits.sdf_fields", Count, Value("4096"), "Most fields (data items by name) read from an SDF file, each a column."),
s("limits.vcd_signals", Count, Value("1048576"), "Most signals read from a VCD file, each a column."),
s("limits.fix_tags", Count, Value("4096"), "Most tags read from a FIX file, each a column."),
s("limits.fix_fields", Count, Value("4096"), "Most fields read from one FIX message."),
s("limits.gpx_fields", Count, Value("256"), "Most extension fields read from a GPX file, each a column."),
s("limits.npy_header_bytes", Size, Value("\"4MiB\""), "Largest NumPy header read; a file with a larger one is refused."),
s("log.file", Path, Unset("\"~/datui.log\""), "Where the log is written. Unset, datui.log in the cache directory.").flag("log-file"),
s("log.level", Choice(&["error", "warn", "info", "debug", "trace", "off"]), Unset("\"warn\""), "How much the log records (default warn). DATUI_LOG overrides the config file; -c and --log-level override DATUI_LOG.").flag("log-level"),
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."),
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."),
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."),
color("theme.colors.chip_key", "#7dcfff", "#2e7de9", "Keys named in the footer, dialogs, the breadcrumb and the correlation matrix."),
color("theme.colors.chip_label", "#a9b1d6", "#3760bf", "Labels beside keys in the footer, and the footer's status."),
color("theme.colors.throbber", "#7dcfff", "#2e7de9", "The busy spinner."),
color("theme.colors.success", "#9ece6a", "#587539", "Success."),
color("theme.colors.error", "#f7768e", "#f52a65", "Errors."),
color("theme.colors.warning", "#e0af68", "#8c6c3e", "Warnings."),
color("theme.colors.dimmed", "#565f89", "#848cb5", "Dimmed text, nulls and axes."),
color("theme.colors.background", "default", "default", "Main background."),
color("theme.colors.surface", "default", "default", "Dialog background."),
color("theme.colors.controls_bg", "#262a3f", "#d0d5e3", "Count chips and dialogs' key chips."),
color("theme.colors.text_primary", "default", "default", "Text."),
color("theme.colors.text_secondary", "#737aa2", "#6172b0", "Secondary text."),
color("theme.colors.text_inverse", "#1a1b26", "#e1e2e7", "Text on a key chip."),
color("theme.colors.table_header", "#c0caf5", "#3760bf", "Header text."),
color("theme.colors.table_header_bg", "#2b3047", "#c4c8da", "Header fill."),
color("theme.colors.table_row_numbers", "#565f89", "#848cb5", "The row-number column."),
color("theme.colors.table_column_separator", "#3b4261", "#a8aecb", "The rule after frozen columns and beside section titles."),
color("theme.colors.table_selected", "#283457", "#b6bfe2", "Tint under the current row; reversed swaps text and background instead."),
color("theme.colors.table_column_cursor", "#292e42", "#cbd3f2", "Tint under the column cursor's cells."),
color("theme.colors.table_cell_cursor", "#3b4261", "#a0aef0", "The column cursor's header and the current cell."),
color("theme.colors.sidebar_border", "#565f89", "#6172b0", "Sidebar and dialog borders."),
color("theme.colors.modal_border_active", "#7dcfff", "#2e7de9", "The focused dialog's border."),
color("theme.colors.modal_border_error", "#f7768e", "#f52a65", "An error dialog's border."),
color("theme.colors.distribution_normal", "#9ece6a", "#587539", "Analysis: a normal distribution."),
color("theme.colors.distribution_skewed", "#e0af68", "#8c6c3e", "Analysis: a skewed distribution."),
color("theme.colors.distribution_other", "#c0caf5", "#3760bf", "Analysis: other distributions."),
color("theme.colors.outlier_marker", "#f7768e", "#f52a65", "Analysis: outliers."),
color("theme.colors.input_cursor", "default", "default", "The text caret; default reverses the text under it."),
color("theme.colors.input_cursor_text", "default", "default", "Text under the caret block; default picks black or white by contrast."),
color("theme.colors.table_alternate_row", "#1e2030", "#dcdfea", "Every other row; default turns the stripe off."),
color("theme.colors.type_str", "#9ece6a", "#587539", "String columns."),
color("theme.colors.type_int", "#7aa2f7", "#2e7de9", "Integer columns."),
color("theme.colors.type_float", "#2ac3de", "#007197", "Float columns."),
color("theme.colors.type_bool", "#e0af68", "#8c6c3e", "Boolean columns."),
color("theme.colors.type_temporal", "#bb9af7", "#9854f1", "Date, time and datetime columns."),
color("theme.colors.type_binary", "#565f89", "#848cb5", "Binary columns' placeholder."),
color("theme.colors.chart_1", "#7dcfff", "#2e7de9", "Chart series 1; also histogram bars, bar charts and Q-Q points."),
color("theme.colors.chart_2", "#bb9af7", "#9854f1", "Chart series 2."),
color("theme.colors.chart_3", "#9ece6a", "#587539", "Chart series 3."),
color("theme.colors.chart_4", "#e0af68", "#8c6c3e", "Chart series 4."),
color("theme.colors.chart_5", "#7aa2f7", "#007197", "Chart series 5."),
color("theme.colors.chart_6", "#f7768e", "#f52a65", "Chart series 6."),
color("theme.colors.chart_7", "#ff9e64", "#b15c00", "Chart series 7."),
color("theme.colors.chart_8", "#1abc9c", "#118c74", "Chart series 8."),
color("theme.colors.chart_9", "#ff5fd2", "#d1188c", "Chart series 9."),
color("theme.colors.chart_10", "#f4ef8a", "#24357a", "Chart series 10."),
color("theme.colors.chart_grid", "#3d4785", "#70aabf", "The chart grid, a shade dimmer than dimmed."),
color("theme.colors.accent", "#7dcfff", "#2e7de9", "Key chips, focused titles and the selection rail."),
color("theme.colors.accent_bright", "#a4daff", "#1a6cd0", "The section the cursor is in."),
color("theme.colors.gradient_start", "#7aa2f7", "#2e7de9", "The wordmark's first stop."),
color("theme.colors.gradient_end", "#bb9af7", "#9854f1", "The wordmark's last stop."),
color("theme.colors.find_match", "#e0af68", "#f0c35a", "Behind the cell a find landed on."),
color("theme.colors.hex_null", "#565f89", "#848cb5", "Hex view: the byte 0x00."),
color("theme.colors.hex_printable", "#7dcfff", "#007197", "Hex view: printable ASCII."),
color("theme.colors.hex_whitespace", "#9ece6a", "#587539", "Hex view: whitespace bytes."),
color("theme.colors.hex_control", "#bb9af7", "#9854f1", "Hex view: other control bytes."),
color("theme.colors.hex_high", "#e0af68", "#8c6c3e", "Hex view: 0x80 to 0xFE."),
color("theme.colors.hex_ff", "#f7768e", "#f52a65", "Hex view: the byte 0xFF."),
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."),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OpenOption {
pub flag: &'static str,
pub kwarg: &'static str,
pub kind: Kind,
pub spec: Option<&'static str>,
}
const fn open(flag: &'static str, kwarg: &'static str, kind: Kind) -> OpenOption {
OpenOption {
flag,
kwarg,
kind,
spec: None,
}
}
const fn open_spec(
flag: &'static str,
kwarg: &'static str,
kind: Kind,
spec: &'static str,
) -> OpenOption {
OpenOption {
flag,
kwarg,
kind,
spec: Some(spec),
}
}
pub const OPEN: &[OpenOption] = &[
open("format", "format", Text),
open("table", "table", Text),
open("hive", "hive", Bool),
open(
"compression",
"compression",
Choice(&["gzip", "zstd", "bzip2", "xz"]),
),
open("dict", "dict", List),
open("view", "view", Text),
open_spec("delimiter", "delimiter", Text, "delimiter"),
open("no-header", "no_header", Bool),
open_spec("header-rows", "header_rows", List, "header_rows"),
open("footer-rows", "footer_rows", Count),
open("skip-rows", "skip_rows", Count),
open_spec("skip-lines", "skip_lines", Count, "skip_lines"),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnvGroup {
Datui,
Terminal,
Programs,
Cloud,
}
impl EnvGroup {
pub fn title(self) -> &'static str {
match self {
Self::Datui => "datui",
Self::Terminal => "Terminal",
Self::Programs => "Programs datui starts",
Self::Cloud => "Cloud logins",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EnvVar {
pub names: &'static [&'static str],
pub group: EnvGroup,
pub doc: &'static str,
}
const fn env(names: &'static [&'static str], group: EnvGroup, doc: &'static str) -> EnvVar {
EnvVar { names, group, doc }
}
pub const ENVIRONMENT: &[EnvVar] = &[
env(
&["DATUI_CONFIG_DIR"],
EnvGroup::Datui,
"The config directory, in place of the platform's (`~/.config/datui` on Linux). Saved views and format specs live there too",
),
env(
&["DATUI_CACHE_DIR"],
EnvGroup::Datui,
"The cache directory, in place of the platform's (`~/.cache/datui` on Linux)",
),
env(
&["DATUI_FORMATS_PATH"],
EnvGroup::Datui,
"Directories of format specs and dictionaries, separated as `PATH` is, searched before `[formats] path`",
),
env(
&["DATUI_LOG"],
EnvGroup::Datui,
"The log level: `error`, `warn`, `info`, `debug`, `trace` or `off`. Overrides `log.level` in a config file; `-c` and `--log-level` override it",
),
env(
&["DATUI_DEBUG"],
EnvGroup::Datui,
"`1` shows the debug overlay",
),
env(
&["DATUI_GCP_PROJECT"],
EnvGroup::Datui,
"The Google Cloud project to list when projects cannot be searched. Like `GOOGLE_CLOUD_PROJECT`, but read first",
),
env(
&["DATUI_TRACE_FIRST_ROWS"],
EnvGroup::Datui,
"A file to write the time (in Unix nanoseconds) to once the first rows are drawn. For benchmarks",
),
env(
&["NO_COLOR"],
EnvGroup::Terminal,
"Set to anything to turn off colors: the terminal's own are used for everything",
),
env(
&["COLORTERM", "TERM", "FORCE_COLOR"],
EnvGroup::Terminal,
"How many colors the terminal draws: 24-bit, 256 or 16. Theme colors are reduced to fit",
),
env(
&["COLORFGBG"],
EnvGroup::Terminal,
"With `theme.mode = \"auto\"`, says whether the background is light or dark, for a terminal that does not answer when asked",
),
env(
&["TERM_PROGRAM"],
EnvGroup::Terminal,
"With `theme.mode = \"auto\"`, identifies the terminal, so its last answer about its background picks the first frame's theme; `TERM` is used when it is unset",
),
env(
&["LC_ALL", "LC_CTYPE", "LANG"],
EnvGroup::Terminal,
"With `display.unicode = \"auto\"`, the first one set says whether the terminal takes UTF-8; if it does not, glyphs are ASCII. With none set, Windows gets Unicode and other systems ASCII",
),
env(
&["VISUAL", "EDITOR", "PAGER"],
EnvGroup::Programs,
"The inspector's `o` opens text in the first one set, else `less` (on Windows, the system's opener)",
),
env(
&["AWS_PROFILE"],
EnvGroup::Cloud,
"The AWS profile for `s3://`, else `default`",
),
env(
&[
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
],
EnvGroup::Cloud,
"AWS keys, and the token of temporary ones",
),
env(
&["AWS_REGION", "AWS_DEFAULT_REGION"],
EnvGroup::Cloud,
"The AWS region",
),
env(
&["AWS_ENDPOINT_URL_S3", "AWS_ENDPOINT_URL", "AWS_ENDPOINT"],
EnvGroup::Cloud,
"An S3-compatible endpoint (MinIO, R2, Ceph); the first one set",
),
env(
&["AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE"],
EnvGroup::Cloud,
"The AWS config and credentials files, in place of `~/.aws/config` and `~/.aws/credentials`",
),
env(
&[
"GOOGLE_APPLICATION_CREDENTIALS",
"GOOGLE_SERVICE_ACCOUNT",
"GOOGLE_SERVICE_ACCOUNT_PATH",
"GOOGLE_SERVICE_ACCOUNT_KEY",
],
EnvGroup::Cloud,
"A Google Cloud service account or credentials file for `gs://`",
),
env(
&[
"GOOGLE_CLOUD_PROJECT",
"GCLOUD_PROJECT",
"CLOUDSDK_CORE_PROJECT",
"GCP_PROJECT",
],
EnvGroup::Cloud,
"The Google Cloud project to list buckets in, after `DATUI_GCP_PROJECT`; the first one set",
),
env(
&["CLOUDSDK_CONFIG"],
EnvGroup::Cloud,
"The `gcloud` configuration directory, in place of `~/.config/gcloud`",
),
env(
&["AZURE_STORAGE_CONNECTION_STRING"],
EnvGroup::Cloud,
"An Azure storage connection string, with `AccountKey` or `SharedAccessSignature`",
),
env(
&[
"AZURE_STORAGE_ACCOUNT_NAME",
"AZURE_STORAGE_ACCOUNT_KEY",
"AZURE_STORAGE_SAS_TOKEN",
],
EnvGroup::Cloud,
"An Azure storage account and its key or SAS token",
),
env(
&[
"AZURE_TENANT_ID",
"AZURE_CLIENT_ID",
"AZURE_CLIENT_SECRET",
"AZURE_FEDERATED_TOKEN_FILE",
],
EnvGroup::Cloud,
"An Azure service principal, or AKS workload identity",
),
env(
&["AZURE_CONFIG_DIR"],
EnvGroup::Cloud,
"The Azure CLI's directory, in place of `~/.azure`",
),
];
pub fn render_environment_markdown() -> String {
let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
let mut out = String::from(
"# Environment variables\n\n\
<!-- Generated from crates/datui-cli/src/settings.rs by `gen_docs`. Do not edit. -->\n\n\
The variables datui reads.\n",
);
for group in [
EnvGroup::Datui,
EnvGroup::Terminal,
EnvGroup::Programs,
EnvGroup::Cloud,
] {
out.push_str(&format!("\n## {}\n\n", group.title()));
if group == EnvGroup::Cloud {
out.push_str(
"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",
);
}
out.push_str("| Variable | What it does |\n|---|---|\n");
for var in ENVIRONMENT.iter().filter(|v| v.group == group) {
let names: Vec<String> = var.names.iter().map(|n| format!("`{n}`")).collect();
out.push_str(&format!("| {} | {} |\n", names.join(", "), cell(var.doc)));
}
}
out
}
pub fn find(key: &str) -> Option<&'static Setting> {
SETTINGS.iter().find(|s| s.matches(key))
}
pub fn flag_help(flag: &str) -> String {
let setting = by_flag(flag).unwrap_or_else(|| panic!("--{flag} sets no registered key"));
format!("{} [config: {}]", setting.doc, setting.key)
}
pub fn by_kwarg(kwarg: &str) -> Option<&'static Setting> {
SETTINGS.iter().find(|s| s.kwarg == Some(kwarg))
}
pub fn by_flag(flag: &str) -> Option<&'static Setting> {
SETTINGS.iter().find(|s| s.flag == Some(flag))
}
pub fn in_section(section: &str) -> impl Iterator<Item = &'static Setting> + '_ {
SETTINGS.iter().filter(move |s| s.section() == section)
}
pub const TYPES: &[(&str, &str)] = &[
(
"size",
"A number and a unit: `512MiB`, `2GiB`, `100KiB` (`MB`, `GB` are powers of 1000). `0` needs none",
),
("duration", "A number and a unit: `250ms`, `1.5s`, `2m`"),
(
"list",
"In a file, a TOML array; with `-c`, `a,b` or the array",
),
(
"color",
"A name (`red`, `bright_blue`, `default`), `#rrggbb` or `indexed(0-255)`",
),
];
pub fn render_settings_markdown() -> String {
let cell = |s: &str| s.replace('|', "\\|").replace('\n', " ");
let mut out = String::from(
"# Settings\n\n\
<!-- Generated from crates/datui-cli/src/settings.rs by `gen_docs settings`. Do not edit. -->\n\n\
Set these in `config.toml` (`datui config init` writes one with every key\n\
commented out), or for one run with `-c KEY=VALUE`:\n\n\
```bash\n\
printf 'a,b\\n1,2\\n' | datui -c display.row_numbers=true\n\
```\n\n\
A flag beats `-c`, which beats the config files, which beat the defaults.\n\
`datui config keys` lists every key with its value in effect and where it was\n\
set. See [Configure datui](../user-guide/configuration.md) for where the file\n\
lives, imports, the theme and troubleshooting.\n\n\
| Type | Written as |\n\
|---|---|\n",
);
for (kind, written) in TYPES {
out.push_str(&format!("| {kind} | {written} |\n"));
}
for section in SECTIONS {
let settings: Vec<&Setting> = in_section(section.name).collect();
if settings.is_empty() {
continue;
}
out.push_str(&format!("\n## {}\n\n", section.title));
if !section.name.is_empty() {
out.push_str(&format!("`[{}]`", section.name));
if !section.intro.is_empty() {
out.push_str(&format!(" {}", section.intro));
}
out.push_str("\n\n");
} else if !section.intro.is_empty() {
out.push_str(&format!("{}\n\n", section.intro));
}
let colors = settings
.iter()
.all(|s| matches!(s.default, DefaultValue::Color { .. }));
if colors {
out.push_str("| Key | Dark | Light | Description |\n|---|---|---|---|\n");
} else {
out.push_str("| Key | Type | Default | Flag | Description |\n|---|---|---|---|---|\n");
}
for setting in settings {
let key = format!("`{}`", setting.key);
match setting.default {
DefaultValue::Color { dark, light } => out.push_str(&format!(
"| {key} | `{dark}` | `{light}` | {} |\n",
cell(setting.doc)
)),
DefaultValue::Value(v) | DefaultValue::Unset(v) => {
let default = match setting.default {
DefaultValue::Value(_) => format!("`{}`", cell(v)),
_ => "unset".to_string(),
};
let flag = setting.flag.map(|f| format!("`--{f}`")).unwrap_or_default();
out.push_str(&format!(
"| {key} | {} | {default} | {flag} | {} |\n",
setting.kind.describe(),
cell(setting.doc)
));
}
}
}
}
out.push_str(
"\nThe environment variables datui reads are in\n\
[Environment variables](environment.md).\n",
);
out
}
#[derive(Debug, Clone, PartialEq)]
pub struct Override {
pub key: String,
pub value: toml::Value,
}
impl std::str::FromStr for Override {
type Err = String;
fn from_str(text: &str) -> Result<Self, String> {
let Some((key, value)) = text.split_once('=') else {
return Err(format!(
"\"{text}\" is not KEY=VALUE, as in -c display.row_numbers=true"
));
};
let key = key.trim();
let setting = find(key).ok_or_else(|| unknown_key(key))?;
let value = parse_value(setting, value).map_err(|e| {
format!(
"{key}: {e} ({key} takes {})",
setting.kind.describe().replace("\\|", "|")
)
})?;
Ok(Self {
key: key.to_string(),
value,
})
}
}
pub fn parse_value(setting: &Setting, text: &str) -> Result<toml::Value, String> {
let trimmed = text.trim();
Ok(match setting.kind {
Bool => toml::Value::Boolean(parse_bool(trimmed)?),
Count => {
let n: u64 = trimmed
.replace('_', "")
.parse()
.map_err(|_| format!("\"{trimmed}\" is not a whole number"))?;
toml::Value::Integer(i64::try_from(n).map_err(|_| format!("{n} is too large"))?)
}
Text | Path | Color => toml::Value::String(text.to_string()),
List => {
if trimmed.starts_with('[') {
toml_value(trimmed)
.filter(toml::Value::is_array)
.ok_or_else(|| format!("\"{trimmed}\" is not a list"))?
} else {
toml::Value::Array(
trimmed
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(|item| toml::Value::String(item.to_string()))
.collect(),
)
}
}
Choice(words) => {
let word = trimmed.to_ascii_lowercase();
if !words.contains(&word.as_str()) {
return Err(format!("\"{trimmed}\" is not one of {}", words.join(", ")));
}
toml::Value::String(word)
}
Size => {
crate::units::parse_size(trimmed)?;
toml::Value::String(trimmed.to_string())
}
Duration => {
crate::units::parse_duration(trimmed)?;
toml::Value::String(trimmed.to_string())
}
Toml(_) => toml_value(trimmed).unwrap_or_else(|| toml::Value::String(trimmed.to_string())),
Tables => return Err("is a list of tables; write it in a config file".into()),
})
}
pub fn parse_bool(text: &str) -> Result<bool, String> {
match text.to_ascii_lowercase().as_str() {
"true" | "yes" | "on" | "1" => Ok(true),
"false" | "no" | "off" | "0" => Ok(false),
_ => Err(format!("\"{text}\" is not true or false")),
}
}
fn toml_value(text: &str) -> Option<toml::Value> {
format!("v = {text}")
.parse::<toml::Table>()
.ok()
.and_then(|mut t| t.remove("v"))
}
fn unknown_key(key: &str) -> String {
let near = suggestions(key);
let mut message = format!("\"{key}\" is not a config key");
if !near.is_empty() {
message.push_str(&format!("; did you mean {}?", near.join(" or ")));
}
message.push_str(" `datui config keys` lists them");
message
}
pub fn suggestions(key: &str) -> Vec<&'static str> {
let name = key.rsplit_once('.').map_or(key, |(_, n)| n);
let mut scored: Vec<(usize, &'static str)> = SETTINGS
.iter()
.filter(|s| !s.key.ends_with(".*"))
.filter_map(|s| {
let distance = edit_distance(key, s.key);
let close = distance <= (key.len() / 4).max(2);
let other = s.name();
let alike = other == name
|| (other.len() >= 4 && name.contains(other))
|| (name.len() >= 4 && other.contains(name));
(close || alike).then_some((distance, s.key))
})
.collect();
scored.sort();
scored.into_iter().take(3).map(|(_, k)| k).collect()
}
fn edit_distance(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut row: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.chars().enumerate() {
let mut diagonal = row[0];
row[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let above = row[j + 1];
row[j + 1] = (diagonal + usize::from(ca != *cb))
.min(above + 1)
.min(row[j] + 1);
diagonal = above;
}
}
row[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_override_reads_its_value_for_the_key() {
let o: Override = "display.mouse=yes".parse().unwrap();
assert_eq!(o.value, toml::Value::Boolean(true));
let o: Override = "display.row_numbers=auto".parse().unwrap();
assert_eq!(o.value, toml::Value::String("auto".into()));
let o: Override = "display.row_numbers=false".parse().unwrap();
assert_eq!(o.value, toml::Value::Boolean(false));
let o: Override = "csv.comment=#".parse().unwrap();
assert_eq!(o.value, toml::Value::String("#".into()));
let o: Override = "cloud.env_files=a.env, b.env".parse().unwrap();
assert_eq!(o.value.as_array().map(Vec::len), Some(2));
let o: Override = "home.hide=[\"x\"]".parse().unwrap();
assert_eq!(o.value.as_array().map(Vec::len), Some(1));
let o: Override = "cloud.discover=s3,gcs".parse().unwrap();
assert_eq!(o.value, toml::Value::String("s3,gcs".into()));
let o: Override = "cloud.discover=false".parse().unwrap();
assert_eq!(o.value, toml::Value::Boolean(false));
let o: Override = "glyphs.spinner=[\"a\", \"b\"]".parse().unwrap();
assert!(o.value.is_array());
let o: Override = "csv.null_values=amount=".parse().unwrap();
assert_eq!(o.value.as_array().unwrap()[0].as_str(), Some("amount="));
}
#[test]
fn sizes_and_durations_take_their_unit() {
let o: Override = "performance.max_buffered=1GiB".parse().unwrap();
assert_eq!(o.value, toml::Value::String("1GiB".into()));
let e = "performance.max_buffered=512"
.parse::<Override>()
.unwrap_err();
assert!(e.contains("needs a unit"), "{e}");
let o: Override = "read.follow_interval=1s".parse().unwrap();
assert_eq!(o.value, toml::Value::String("1s".into()));
let e = "read.follow_interval=fast".parse::<Override>().unwrap_err();
assert!(e.contains("duration"), "{e}");
}
#[test]
fn an_override_refuses_with_the_way_out() {
let e = "file_loading.comment_char=#"
.parse::<Override>()
.unwrap_err();
assert!(e.contains("did you mean csv.comment"), "{e}");
let e = "performance.polars_streaming=false"
.parse::<Override>()
.unwrap_err();
assert!(e.contains("performance.streaming"), "{e}");
let e = "display.row_number=true".parse::<Override>().unwrap_err();
assert!(e.contains("did you mean display.row_numbers"), "{e}");
let e = "row_numbers=true".parse::<Override>().unwrap_err();
assert!(e.contains("display.row_numbers"), "{e}");
let e = "display.row_numbers_start=one"
.parse::<Override>()
.unwrap_err();
assert!(
e.contains("not a whole number") && e.contains("integer"),
"{e}"
);
let e = "display.mouse=maybe".parse::<Override>().unwrap_err();
assert!(e.contains("not true or false"), "{e}");
let e = "display.unicode=sometimes".parse::<Override>().unwrap_err();
assert!(e.contains("auto, always, never"), "{e}");
let e = "cloud.connections=x".parse::<Override>().unwrap_err();
assert!(e.contains("config file"), "{e}");
let e = "display.mouse".parse::<Override>().unwrap_err();
assert!(e.contains("KEY=VALUE"), "{e}");
let e = "nothing.like.this=1".parse::<Override>().unwrap_err();
assert!(e.contains("config keys"), "{e}");
}
#[test]
fn every_key_is_listed_once_in_a_known_section() {
for (i, setting) in SETTINGS.iter().enumerate() {
assert!(
SECTIONS.iter().any(|s| s.name == setting.section()),
"{} has no section",
setting.key
);
assert!(
SETTINGS[..i].iter().all(|other| other.key != setting.key),
"{} is listed twice",
setting.key
);
assert!(!setting.doc.is_empty(), "{} has no doc", setting.key);
}
}
#[test]
fn a_wildcard_key_matches_one_name_below_it() {
let glyph = find("glyphs.spinner").expect("a glyph slot");
assert_eq!(glyph.key, "glyphs.*");
assert!(find("glyphs").is_none());
assert!(find("glyphs.a.b").is_none());
assert_eq!(find("display.mouse").map(|s| s.key), Some("display.mouse"));
}
}