Skip to main content

datui_lib/
config.rs

1pub use crate::catalog::is_object_store_dataset;
2use crate::numfmt::{self, Glob, Grouping, NumberFormat, NumberFormatSettings};
3use color_eyre::Result;
4use color_eyre::eyre::eyre;
5pub use datui_cli::units::{ByteSize, Interval};
6use ratatui::style::Color;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use supports_color::Stream;
11
12/// Manages config directory and config file operations
13#[derive(Clone)]
14pub struct ConfigManager {
15    pub(crate) config_dir: PathBuf,
16}
17
18impl ConfigManager {
19    /// Create a ConfigManager with a custom config directory (primarily for testing)
20    pub fn with_dir(config_dir: PathBuf) -> Self {
21        Self { config_dir }
22    }
23
24    /// Create a new ConfigManager for the given app name.
25    ///
26    /// `DATUI_CONFIG_DIR` overrides the location. The test suite sets it: views
27    /// live under the config directory, so without the override every App-level test
28    /// that saved one wrote it into the developer's own view list — dozens of
29    /// "pivot then break" entries were found there. As with the cache, a test that
30    /// reaches the real directory refuses rather than writes.
31    pub fn new(app_name: &str) -> Result<Self> {
32        #[cfg(test)]
33        crate::cache::isolate_cache();
34        if let Some(dir) = std::env::var_os("DATUI_CONFIG_DIR") {
35            return Ok(Self {
36                config_dir: PathBuf::from(dir),
37            });
38        }
39        if crate::cache::running_as_a_cargo_test() {
40            panic!(
41                "DATUI_CONFIG_DIR is not set: a test would read and write the real \
42                 config (saved views included). Call common::isolate_cache() before \
43                 building an App or a ConfigManager."
44            );
45        }
46
47        let config_dir = dirs::config_dir()
48            .ok_or_else(|| eyre!("Could not determine config directory"))?
49            .join(app_name);
50
51        Ok(Self { config_dir })
52    }
53
54    /// Get the config directory path
55    pub fn config_dir(&self) -> &Path {
56        &self.config_dir
57    }
58
59    /// Get path to a specific config file or subdirectory
60    pub fn config_path(&self, path: &str) -> PathBuf {
61        self.config_dir.join(path)
62    }
63
64    /// Ensure the config directory exists
65    pub fn ensure_config_dir(&self) -> Result<()> {
66        if !self.config_dir.exists() {
67            std::fs::create_dir_all(&self.config_dir)?;
68        }
69        Ok(())
70    }
71
72    /// Ensure a subdirectory exists within the config directory
73    pub fn ensure_subdir(&self, subdir: &str) -> Result<PathBuf> {
74        let subdir_path = self.config_dir.join(subdir);
75        if !subdir_path.exists() {
76            std::fs::create_dir_all(&subdir_path)?;
77        }
78        Ok(subdir_path)
79    }
80
81    /// The commented file `datui config init` writes, from the option registry: every
82    /// key with its doc line and its default, commented out so the defaults keep
83    /// applying. Datasets are not here: they are in `catalog.toml`.
84    pub fn generate_default_config(&self) -> String {
85        use datui_cli::settings::{DefaultValue, Kind, SECTIONS, in_section};
86        let mut out = String::from(
87            "# datui configuration file (TOML: https://toml.io).\n\
88             # Every setting is commented out at its default; remove the # to change one.\n\
89             # `datui config keys` lists them with the values in effect.\n",
90        );
91        for section in SECTIONS {
92            let settings: Vec<_> = in_section(section.name)
93                .filter(|s| s.kind != Kind::Tables)
94                .collect();
95            if settings.is_empty() {
96                continue;
97            }
98            out.push('\n');
99            if !section.name.is_empty() {
100                out.push_str(&format!(
101                    "# {rule}\n# {}\n# {rule}\n# [{}]\n",
102                    section.title,
103                    section.name,
104                    rule = "=".repeat(76)
105                ));
106            }
107            for setting in settings {
108                for line in wrap(setting.doc, 86) {
109                    out.push_str(&format!("# {line}\n"));
110                }
111                let value = match setting.default {
112                    DefaultValue::Value(v) | DefaultValue::Unset(v) => v.to_string(),
113                    DefaultValue::Color { dark, .. } => format!("\"{dark}\""),
114                };
115                if setting.key.ends_with(".*") {
116                    out.push_str(&format!("# {value}\n"));
117                } else {
118                    out.push_str(&format!("# {} = {value}\n", setting.name()));
119                }
120            }
121        }
122        out
123    }
124
125    /// Write default configuration to config file
126    pub fn write_default_config(&self, force: bool) -> Result<PathBuf> {
127        let config_path = self.config_path("config.toml");
128
129        if config_path.exists() && !force {
130            return Err(eyre!(
131                "Config file already exists at {}. Use --force to overwrite.",
132                config_path.display()
133            ));
134        }
135
136        // Ensure config directory exists
137        self.ensure_config_dir()?;
138
139        // Generate and write default config
140        let template = self.generate_default_config();
141        write_private(&config_path, &template)?;
142
143        // The catalog is the user's own data, so it is written only when there is none,
144        // --force or not.
145        let catalog = self.config_path(crate::catalog::MINE_FILE);
146        if !catalog.exists() {
147            std::fs::write(&catalog, crate::catalog::MINE_TEMPLATE)?;
148        }
149        // Where more catalogs go: the header's `> catalogs/examples.toml` needs it there.
150        self.ensure_subdir(crate::catalog::FOLDER)?;
151        // Where theme files go: `datui theme show NAME` prints one to start from.
152        self.ensure_subdir(crate::themes::FOLDER)?;
153
154        Ok(config_path)
155    }
156}
157
158/// One entry of `catalogs`: a catalog file's path, or a table naming it with an id and
159/// a label of its own, for a file that cannot be renamed or edited.
160#[derive(Debug, Clone, PartialEq, Serialize)]
161#[serde(untagged)]
162pub enum CatalogRef {
163    Path(String),
164    Table {
165        path: String,
166        #[serde(skip_serializing_if = "Option::is_none")]
167        id: Option<String>,
168        #[serde(skip_serializing_if = "Option::is_none")]
169        label: Option<String>,
170    },
171}
172
173impl CatalogRef {
174    /// The file, as written.
175    pub fn path(&self) -> &str {
176        match self {
177            Self::Path(path) | Self::Table { path, .. } => path,
178        }
179    }
180
181    /// The id the entry gives, when it gives one: else the file's name is the id.
182    pub fn id(&self) -> Option<&str> {
183        match self {
184            Self::Table { id: Some(id), .. } => Some(id),
185            _ => None,
186        }
187    }
188
189    /// The label the entry gives, over the file's own.
190    pub fn label(&self) -> Option<&str> {
191        match self {
192            Self::Table {
193                label: Some(label), ..
194            } => Some(label),
195            _ => None,
196        }
197    }
198}
199
200impl<'de> Deserialize<'de> for CatalogRef {
201    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
202        use serde::de::Error;
203        match toml::Value::deserialize(deserializer)? {
204            toml::Value::String(path) => Ok(Self::Path(path)),
205            toml::Value::Table(table) => {
206                let text = |key: &str| -> Result<Option<String>, D::Error> {
207                    match table.get(key) {
208                        None => Ok(None),
209                        Some(toml::Value::String(s)) => Ok(Some(s.clone())),
210                        Some(_) => Err(D::Error::custom(format!(
211                            "catalogs: {key} must be a string"
212                        ))),
213                    }
214                };
215                if let Some(key) = table
216                    .keys()
217                    .find(|k| !matches!(k.as_str(), "path" | "id" | "label"))
218                {
219                    return Err(D::Error::custom(format!(
220                        "catalogs: unknown key '{key}'. Expected one of: path, id, label"
221                    )));
222                }
223                let path = text("path")?
224                    .ok_or_else(|| D::Error::custom("catalogs: a table needs path = \"...\""))?;
225                Ok(Self::Table {
226                    path,
227                    id: text("id")?,
228                    label: text("label")?,
229                })
230            }
231            _ => Err(D::Error::custom(
232                "catalogs: each entry is a path, or { path, id, label }",
233            )),
234        }
235    }
236}
237
238/// Complete application configuration
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(default)]
241pub struct AppConfig {
242    /// Additional config files merged in before this file's own values.
243    pub import: Vec<String>,
244    /// Catalog files elsewhere, listed on the home screen besides `catalog.toml` and
245    /// `catalogs/`: a path, or `{ path, id, label }`.
246    pub catalogs: Vec<CatalogRef>,
247    /// The catalogs read: `catalog.toml`, then each of `catalogs`. Not a key: read by
248    /// [`AppConfig::read_catalog_files`] once the layers are merged.
249    #[serde(skip)]
250    pub read_catalogs: Vec<crate::catalog::Catalog>,
251    /// The directory `catalog.toml` was looked for in: the config file's.
252    #[serde(skip)]
253    pub catalog_dir: Option<PathBuf>,
254    /// Catalog files left out for a mistake, each with what is wrong.
255    #[serde(skip)]
256    pub broken_catalogs: Vec<crate::catalog::Broken>,
257    pub read: ReadConfig,
258    pub csv: CsvConfig,
259    pub display: DisplayConfig,
260    pub performance: PerformanceConfig,
261    pub analysis: AnalysisConfig,
262    pub chart: ChartConfig,
263    pub home: HomeConfig,
264    pub cloud: CloudConfig,
265    pub http: HttpConfig,
266    pub query: QueryConfig,
267    pub views: ViewsConfig,
268    pub clipboard: ClipboardConfig,
269    pub formats: FormatsConfig,
270    pub log: LogConfig,
271    pub theme: ThemeConfig,
272    pub glyphs: GlyphsConfig,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
276#[serde(default)]
277pub struct CloudConfig {
278    /// The S3 endpoint, keys and region the environment gives (`AWS_*`): not keys of
279    /// the file, where a secret would sit in plain text. `[[cloud.connections]]` names
280    /// a store's variables instead.
281    #[serde(skip)]
282    pub s3_endpoint_url: Option<String>,
283    #[serde(skip)]
284    pub s3_access_key_id: Option<String>,
285    #[serde(skip)]
286    pub s3_secret_access_key: Option<String>,
287    #[serde(skip)]
288    pub s3_region: Option<String>,
289    /// Stores named in `[[cloud.connections]]`, beside the ones found on the machine.
290    #[serde(skip_serializing_if = "Vec::is_empty")]
291    pub connections: Vec<CloudConnectionConfig>,
292    /// Source IDs never shown on the home screen.
293    pub hide: Vec<String>,
294    /// Read an Azure account with its access keys when a sign-in has no data role, as
295    /// the Portal does.
296    pub use_azure_account_keys: bool,
297    /// Files to read cloud variables from, relative to the working directory: `.env`.
298    /// Only known cloud variable names are taken, and nothing is exported.
299    pub env_files: Vec<String>,
300    /// Use the identity of the cloud VM datui runs on (EC2, GCE, Azure). Finding it is a
301    /// request to a metadata service, so it is off unless the platform says so.
302    pub instance_identity: bool,
303    /// Which logins found on this machine become home-screen sources. Unset means all.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub discover: Option<CloudDiscover>,
306    /// List every source's buckets when the home screen opens. Off: a source is listed
307    /// when it is entered or on Ctrl+R, and its credential command runs only then.
308    pub list_on_start: bool,
309    /// How the object-store datasets of the catalogs are read. Not a key: derived from
310    /// the catalogs by [`AppConfig`], so resolving a URL needs only this section.
311    #[serde(skip)]
312    pub dataset_access: Vec<DatasetAccess>,
313}
314
315impl Default for CloudConfig {
316    fn default() -> Self {
317        Self {
318            s3_endpoint_url: None,
319            s3_access_key_id: None,
320            s3_secret_access_key: None,
321            s3_region: None,
322            connections: Vec::new(),
323            hide: Vec::new(),
324            use_azure_account_keys: true,
325            env_files: Vec::new(),
326            instance_identity: false,
327            discover: None,
328            list_on_start: false,
329            dataset_access: Vec::new(),
330        }
331    }
332}
333
334/// How a catalog dataset's URL in an object store is read, when it says.
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct DatasetAccess {
337    /// The dataset's URL: everything under it is read the same way.
338    pub url: String,
339    /// The catalog it is listed in.
340    pub catalog: String,
341    pub auth: DatasetAuth,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub enum DatasetAuth {
346    /// As any URL is read: the login found for it, unsigned when there is none.
347    Auto,
348    /// No credentials and no signature.
349    Anonymous,
350    /// Signed with the `[[cloud.connections]]` entry of this name.
351    Connection(String),
352}
353
354/// The kinds of source `[cloud] discover` can name.
355pub const CLOUD_DISCOVER_KINDS: [&str; 3] = ["s3", "gcs", "azure"];
356
357/// `[cloud] discover`: `true` or `"all"`, `false` or `"none"`, or a list of kinds.
358///
359/// `"all"` and `"none"` are words rather than list members, so no list can say both
360/// "everything" and "only s3".
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362#[serde(try_from = "CloudDiscoverValue", into = "CloudDiscoverValue")]
363pub enum CloudDiscover {
364    All,
365    None,
366    Kinds(Vec<String>),
367}
368
369impl CloudDiscover {
370    /// Whether sources of `kind` (`s3`, `gcs`, `azure`) are found.
371    pub fn allows(&self, kind: &str) -> bool {
372        match self {
373            CloudDiscover::All => true,
374            CloudDiscover::None => false,
375            CloudDiscover::Kinds(kinds) => kinds.iter().any(|k| k == kind),
376        }
377    }
378
379    fn from_kinds<S: AsRef<str>>(kinds: &[S]) -> std::result::Result<Self, String> {
380        let mut out: Vec<String> = Vec::new();
381        for kind in kinds {
382            let kind = kind.as_ref().trim().to_ascii_lowercase();
383            if !CLOUD_DISCOVER_KINDS.contains(&kind.as_str()) {
384                return Err(format!(
385                    "cloud.discover: unknown kind \"{kind}\"; use {}",
386                    CLOUD_DISCOVER_KINDS.join(", ")
387                ));
388            }
389            if !out.contains(&kind) {
390                out.push(kind);
391            }
392        }
393        Ok(CloudDiscover::Kinds(out))
394    }
395}
396
397impl std::str::FromStr for CloudDiscover {
398    type Err = String;
399
400    /// `all`, `none`, or kinds separated by commas: the command-line form.
401    fn from_str(text: &str) -> std::result::Result<Self, String> {
402        match text.trim().to_ascii_lowercase().as_str() {
403            "all" => Ok(CloudDiscover::All),
404            "none" => Ok(CloudDiscover::None),
405            _ => CloudDiscover::from_kinds(&text.split(',').collect::<Vec<_>>()).map_err(|_| {
406                format!(
407                    "cloud.discover: \"{text}\" is not \"all\", \"none\", or kinds from {}",
408                    CLOUD_DISCOVER_KINDS.join(", ")
409                )
410            }),
411        }
412    }
413}
414
415#[derive(Serialize, Deserialize)]
416#[serde(untagged)]
417enum CloudDiscoverValue {
418    Switch(bool),
419    Word(String),
420    Kinds(Vec<String>),
421}
422
423impl TryFrom<CloudDiscoverValue> for CloudDiscover {
424    type Error = String;
425
426    fn try_from(value: CloudDiscoverValue) -> std::result::Result<Self, String> {
427        match value {
428            CloudDiscoverValue::Switch(true) => Ok(CloudDiscover::All),
429            CloudDiscoverValue::Switch(false) => Ok(CloudDiscover::None),
430            // The command line's form: "all", "none", or "s3,gcs".
431            CloudDiscoverValue::Word(word) => word.parse(),
432            CloudDiscoverValue::Kinds(kinds) => CloudDiscover::from_kinds(&kinds),
433        }
434    }
435}
436
437impl From<CloudDiscover> for CloudDiscoverValue {
438    fn from(discover: CloudDiscover) -> Self {
439        match discover {
440            CloudDiscover::All => CloudDiscoverValue::Switch(true),
441            CloudDiscover::None => CloudDiscoverValue::Switch(false),
442            CloudDiscover::Kinds(kinds) => CloudDiscoverValue::Kinds(kinds),
443        }
444    }
445}
446
447/// One store in `[[cloud.connections]]`. Names and pointers only: a secret comes from the
448/// environment variable named here, never from the config file itself.
449#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
450#[serde(default)]
451pub struct CloudConnectionConfig {
452    /// The source's ID: used in `s3://<name>@bucket/key`, `hide` and cache keys.
453    pub name: String,
454    /// Shown instead of the name.
455    #[serde(skip_serializing_if = "Option::is_none")]
456    pub label: Option<String>,
457    /// `s3`, `gcs` or `azure`.
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub kind: Option<String>,
460    /// Buckets to show when the credentials can read but not list.
461    #[serde(skip_serializing_if = "Vec::is_empty")]
462    pub buckets: Vec<String>,
463    #[serde(skip_serializing_if = "Option::is_none")]
464    pub endpoint_url: Option<String>,
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub region: Option<String>,
467    /// `path` or `virtual`.
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub addressing: Option<String>,
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub access_key_id_env: Option<String>,
472    #[serde(skip_serializing_if = "Option::is_none")]
473    pub secret_access_key_env: Option<String>,
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub session_token_env: Option<String>,
476    /// An AWS profile to take the keys, endpoint and region from.
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub profile: Option<String>,
479    /// A `gcloud` configuration whose login to use.
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub configuration: Option<String>,
482    /// The Google Cloud project listed first, and the one listed when projects cannot
483    /// be searched.
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub project: Option<String>,
486    /// The Azure storage account.
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub account: Option<String>,
489    #[serde(skip_serializing_if = "Option::is_none")]
490    pub account_key_env: Option<String>,
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub sas_env: Option<String>,
493    #[serde(skip_serializing_if = "Option::is_none")]
494    pub connection_string_env: Option<String>,
495    /// A program that prints the secret: the S3 secret access key, or the Azure account
496    /// key. Run without a shell, its output kept in memory.
497    #[serde(skip_serializing_if = "Option::is_none")]
498    pub secret_command: Option<String>,
499    /// A Google service account or application-default JSON file.
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub credentials_file: Option<String>,
502    /// Keys that are not recognised, kept so validation can name them.
503    #[serde(flatten)]
504    pub unknown: std::collections::BTreeMap<String, toml::Value>,
505}
506
507/// Field names accepted in `[[cloud.connections]]`, for error messages.
508const CLOUD_SOURCE_KEYS: &str = "name, label, kind, buckets, endpoint_url, region, \
509     addressing, access_key_id_env, secret_access_key_env, session_token_env, profile, \
510     configuration, project, account, account_key_env, sas_env, connection_string_env, \
511     secret_command, credentials_file";
512
513/// Whether `id` can name a source: lowercase letters, digits and `-`, starting with a
514/// letter or digit, at most 40 characters. It goes into URLs and cache keys, so
515/// nothing that needs escaping is allowed in.
516pub fn is_valid_source_id(id: &str) -> bool {
517    let bytes = id.as_bytes();
518    !bytes.is_empty()
519        && bytes.len() <= 40
520        && bytes[0] != b'-'
521        && bytes
522            .iter()
523            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
524}
525
526impl CloudConnectionConfig {
527    fn validate(&self) -> Result<()> {
528        let name = &self.name;
529        if name.is_empty() {
530            return Err(eyre!("cloud.connections: every source needs a name"));
531        }
532        if !is_valid_source_id(name) {
533            return Err(eyre!(
534                "cloud.connections: \"{name}\" is not a valid name. Use lowercase letters, digits \
535                 and '-', up to 40 characters"
536            ));
537        }
538        // A secret written into the file is refused with the way out, rather than as
539        // one more unknown key.
540        for secret in ["access_key_id", "secret_access_key", "session_token"] {
541            if self.unknown.contains_key(secret) {
542                return Err(eyre!(
543                    "cloud.connections \"{name}\": {secret} cannot be written in the config. Put it \
544                     in an environment variable and name that with {secret}_env"
545                ));
546            }
547        }
548        if !self.unknown.is_empty() {
549            let keys: Vec<String> = self.unknown.keys().map(|k| format!("'{k}'")).collect();
550            return Err(eyre!(
551                "cloud.connections \"{name}\": unknown key{} {}. Expected one of: {}",
552                if keys.len() > 1 { "s" } else { "" },
553                keys.join(", "),
554                CLOUD_SOURCE_KEYS
555            ));
556        }
557        for secret in ["account_key", "sas", "sas_token", "connection_string"] {
558            if self.unknown.contains_key(secret) {
559                return Err(eyre!(
560                    "cloud.connections \"{name}\": {secret} cannot be written in the config. Put it \
561                     in an environment variable and name that with {}_env",
562                    secret.trim_end_matches("_token")
563                ));
564            }
565        }
566        let kind = match self.kind.as_deref() {
567            Some(kind @ ("s3" | "gcs" | "azure")) => kind,
568            Some(other) => {
569                return Err(eyre!(
570                    "cloud.connections \"{name}\": kind \"{other}\" is not supported. Expected s3, gcs or azure"
571                ));
572            }
573            None => {
574                return Err(eyre!(
575                    "cloud.connections \"{name}\": kind is required (s3, gcs or azure)"
576                ));
577            }
578        };
579        if let Some(command) = &self.secret_command {
580            if !matches!(kind, "s3" | "azure") {
581                return Err(eyre!(
582                    "cloud.connections \"{name}\": secret_command applies only to kind = \"s3\" or \"azure\""
583                ));
584            }
585            if command.trim().is_empty() {
586                return Err(eyre!(
587                    "cloud.connections \"{name}\": secret_command is not a command line"
588                ));
589            }
590            let clash = if kind == "s3" {
591                [
592                    (
593                        "secret_access_key_env",
594                        self.secret_access_key_env.is_some(),
595                    ),
596                    ("profile", self.profile.is_some()),
597                    ("", false),
598                ]
599            } else {
600                [
601                    ("account_key_env", self.account_key_env.is_some()),
602                    ("sas_env", self.sas_env.is_some()),
603                    (
604                        "connection_string_env",
605                        self.connection_string_env.is_some(),
606                    ),
607                ]
608            };
609            if let Some((field, _)) = clash.iter().find(|(_, set)| *set) {
610                return Err(eyre!(
611                    "cloud.connections \"{name}\": secret_command and {field} both say where the \
612                     secret comes from. Use one"
613                ));
614            }
615            if kind == "s3" && self.access_key_id_env.is_none() {
616                return Err(eyre!(
617                    "cloud.connections \"{name}\": secret_command prints the secret; name the key \
618                     ID with access_key_id_env"
619                ));
620            }
621        }
622        if let Some(_file) = &self.credentials_file {
623            if kind != "gcs" {
624                return Err(eyre!(
625                    "cloud.connections \"{name}\": credentials_file applies only to kind = \"gcs\""
626                ));
627            }
628            if self.configuration.is_some() {
629                return Err(eyre!(
630                    "cloud.connections \"{name}\": credentials_file and configuration both say how \
631                     to log in. Use one"
632                ));
633            }
634        }
635        if kind != "azure" {
636            let azure_only = [
637                ("account", self.account.is_some()),
638                ("account_key_env", self.account_key_env.is_some()),
639                ("sas_env", self.sas_env.is_some()),
640                (
641                    "connection_string_env",
642                    self.connection_string_env.is_some(),
643                ),
644            ];
645            if let Some((field, _)) = azure_only.iter().find(|(_, set)| *set) {
646                return Err(eyre!(
647                    "cloud.connections \"{name}\": {field} applies only to kind = \"azure\""
648                ));
649            }
650        } else {
651            let secrets = [
652                self.account_key_env.is_some(),
653                self.sas_env.is_some(),
654                self.connection_string_env.is_some(),
655            ];
656            if secrets.iter().filter(|set| **set).count() > 1 {
657                return Err(eyre!(
658                    "cloud.connections \"{name}\": account_key_env, sas_env and \
659                     connection_string_env each say how to sign in. Use one"
660                ));
661            }
662            if self.account.is_none() && self.connection_string_env.is_none() {
663                return Err(eyre!(
664                    "cloud.connections \"{name}\": an azure source needs account, or \
665                     connection_string_env"
666                ));
667            }
668            if !self.buckets.is_empty() {
669                return Err(eyre!(
670                    "cloud.connections \"{name}\": buckets does not apply to kind = \"azure\""
671                ));
672            }
673        }
674        if kind != "s3" {
675            let s3_only = [
676                ("endpoint_url", self.endpoint_url.is_some()),
677                ("region", self.region.is_some()),
678                ("addressing", self.addressing.is_some()),
679                ("access_key_id_env", self.access_key_id_env.is_some()),
680                (
681                    "secret_access_key_env",
682                    self.secret_access_key_env.is_some(),
683                ),
684                ("session_token_env", self.session_token_env.is_some()),
685                ("profile", self.profile.is_some()),
686            ];
687            if let Some((field, _)) = s3_only.iter().find(|(_, set)| *set) {
688                return Err(eyre!(
689                    "cloud.connections \"{name}\": {field} applies only to kind = \"s3\""
690                ));
691            }
692        }
693        if kind != "gcs" {
694            let gcs_only = [
695                ("configuration", self.configuration.is_some()),
696                ("project", self.project.is_some()),
697            ];
698            if let Some((field, _)) = gcs_only.iter().find(|(_, set)| *set) {
699                return Err(eyre!(
700                    "cloud.connections \"{name}\": {field} applies only to kind = \"gcs\""
701                ));
702            }
703        }
704        if self.profile.is_some()
705            && (self.access_key_id_env.is_some()
706                || self.secret_access_key_env.is_some()
707                || self.session_token_env.is_some())
708        {
709            return Err(eyre!(
710                "cloud.connections \"{name}\": profile and the *_env keys both say where the keys \
711                 come from. Use one"
712            ));
713        }
714        if let Some(addressing) = self.addressing.as_deref()
715            && !matches!(addressing, "path" | "virtual")
716        {
717            return Err(eyre!(
718                "cloud.connections \"{name}\": addressing \"{addressing}\" is not valid. Expected path \
719                 or virtual"
720            ));
721        }
722        if let Some(bucket) = self.buckets.iter().find(|b| b.contains(['/', '@'])) {
723            return Err(eyre!(
724                "cloud.connections \"{name}\": \"{bucket}\" is not a bucket name{}",
725                if bucket.contains("://") {
726                    ". A dataset URL goes in a catalog"
727                } else {
728                    ""
729                }
730            ));
731        }
732        Ok(())
733    }
734}
735
736/// Write `contents` to `path`, readable only by the owner.
737///
738/// The generated config carries a `[cloud]` section inviting an S3 access key
739/// and secret. A plain `fs::write` creates the file at 0666 minus the umask,
740/// which on most systems is 0644: world-readable. On a machine with more than
741/// one account that hands the user's credentials to everybody, and it is not a
742/// choice the user made knowingly, since datui is the one that wrote the file.
743///
744/// The mode is applied twice on purpose. `OpenOptions::mode` only takes effect
745/// when the file is created, so it does nothing for `datui config init --force`
746/// over a config that already exists at 0644; `set_permissions` fixes that
747/// case. Creating with the mode still matters, because it closes the window
748/// where a new file exists at 0644 before the permissions are corrected.
749///
750/// Non-Unix platforms fall back to a plain write: Windows inherits ACLs from
751/// the containing directory, which is already per-user.
752fn write_private(path: &Path, contents: &str) -> Result<()> {
753    #[cfg(unix)]
754    {
755        use std::io::Write;
756        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
757
758        let mut file = std::fs::OpenOptions::new()
759            .write(true)
760            .create(true)
761            .truncate(true)
762            .mode(0o600)
763            .open(path)?;
764        file.write_all(contents.as_bytes())?;
765        file.sync_all()?;
766        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
767    }
768    #[cfg(not(unix))]
769    {
770        std::fs::write(path, contents)?;
771    }
772    Ok(())
773}
774
775/// The variables that name an S3 endpoint, in the order they are consulted. The AWS
776/// SDKs read the service-specific one first, then the general one; `AWS_ENDPOINT` is
777/// what `object_store` accepts.
778pub const S3_ENDPOINT_VARS: [&str; 3] = ["AWS_ENDPOINT_URL_S3", "AWS_ENDPOINT_URL", "AWS_ENDPOINT"];
779
780/// A value that says something. `AWS_ENDPOINT_URL=` in a shell, or an empty flag, is
781/// not an endpoint and must not erase the one in the config file.
782fn non_blank(value: String) -> Option<String> {
783    let trimmed = value.trim();
784    (!trimmed.is_empty()).then(|| trimmed.to_string())
785}
786
787impl CloudConfig {
788    /// The S3 settings the environment sets. The variable list lives here and nowhere
789    /// else, so discovery, listing and opening cannot disagree about it. `var` is the
790    /// environment, passed in so a test can supply one.
791    pub fn from_env(var: &dyn Fn(&str) -> Option<String>) -> Self {
792        let first = |keys: &[&str]| keys.iter().find_map(|key| var(key).and_then(non_blank));
793        Self {
794            s3_endpoint_url: first(&S3_ENDPOINT_VARS),
795            s3_access_key_id: first(&["AWS_ACCESS_KEY_ID"]),
796            s3_secret_access_key: first(&["AWS_SECRET_ACCESS_KEY"]),
797            s3_region: first(&["AWS_REGION", "AWS_DEFAULT_REGION"]),
798            ..Default::default()
799        }
800    }
801
802    /// Lay the environment's S3 settings over these: each one `over` gives wins, and a
803    /// blank value says nothing. Nothing else in `over` is read.
804    pub fn overlay(&mut self, over: Self) {
805        for (slot, value) in [
806            (&mut self.s3_endpoint_url, over.s3_endpoint_url),
807            (&mut self.s3_access_key_id, over.s3_access_key_id),
808            (&mut self.s3_secret_access_key, over.s3_secret_access_key),
809            (&mut self.s3_region, over.s3_region),
810        ] {
811            if let Some(value) = value.and_then(non_blank) {
812                *slot = Some(value);
813            }
814        }
815    }
816
817    /// Reject `[[cloud.connections]]` entries that would be silently wrong.
818    pub fn validate(&self) -> Result<()> {
819        for (i, connection) in self.connections.iter().enumerate() {
820            connection.validate()?;
821            if self.connections[..i]
822                .iter()
823                .any(|c| c.name == connection.name)
824            {
825                return Err(eyre!(
826                    "cloud.connections: the name \"{}\" is used twice",
827                    connection.name
828                ));
829            }
830        }
831        Ok(())
832    }
833}
834
835/// `[read]`: how files are read.
836#[derive(Debug, Clone, Serialize, Deserialize)]
837#[serde(default)]
838pub struct ReadConfig {
839    /// Which string columns are typed: all, none, or those named.
840    pub infer_types: InferTypes,
841    /// How a partitioned Parquet dataset's schema is found.
842    pub parquet_schema: ParquetSchema,
843    /// Decompress a compressed CSV, TSV or PSV into memory instead of to a temp file.
844    pub decompress_in_memory: bool,
845    /// Directory for decompression temp files. Unset: the system's (e.g. TMPDIR).
846    pub temp_dir: Option<String>,
847    /// `--follow`: how often a followed file is checked; on Linux, where a change is
848    /// heard of as it happens, the least time between two reads. A burst of appends
849    /// within one interval is one refresh.
850    pub follow_interval: Interval,
851    /// A dataset of more files than this shows an estimated row count until asked to
852    /// count exactly. 0 always counts.
853    pub exact_count_files: usize,
854    /// Ask before reading more than this of a file whole into memory (JSON, Avro, ORC,
855    /// Excel and the other formats read in memory). 0 never asks.
856    pub memory_warning: ByteSize,
857    /// Integer audio samples as float in [-1, 1].
858    pub audio_float: bool,
859}
860
861impl ReadConfig {
862    /// The bytes past which a read into memory is asked about first; `None` when
863    /// `memory_warning` is 0, which never asks.
864    pub fn memory_warning(&self) -> Option<u64> {
865        let bytes = self.memory_warning.bytes();
866        (bytes > 0).then_some(bytes)
867    }
868}
869
870impl Default for ReadConfig {
871    fn default() -> Self {
872        Self {
873            infer_types: InferTypes::Switch(true),
874            parquet_schema: ParquetSchema::Union,
875            decompress_in_memory: false,
876            temp_dir: None,
877            follow_interval: Interval(crate::follow::DEFAULT_INTERVAL),
878            exact_count_files: 50_000,
879            memory_warning: ByteSize::mib(1024),
880            audio_float: false,
881        }
882    }
883}
884
885/// `[read] infer_types`: every string column, none, or the columns named.
886#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
887#[serde(untagged)]
888pub enum InferTypes {
889    Switch(bool),
890    Columns(Vec<String>),
891}
892
893/// `[read] parquet_schema`.
894#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
895#[serde(rename_all = "lowercase")]
896pub enum ParquetSchema {
897    /// Every column any file has, from their footers.
898    Union,
899    /// Polars' schema from one file.
900    First,
901}
902
903/// The bounds of `[read] follow_interval`: faster than ten checks a second redraws
904/// for nothing anyone can read, and slower than a minute is not following.
905const FOLLOW_INTERVAL: std::ops::RangeInclusive<std::time::Duration> =
906    std::time::Duration::from_millis(10)..=std::time::Duration::from_secs(60);
907
908/// `[csv]`: CSV, TSV and PSV, and the dialect a delimited spec writes with these keys.
909#[derive(Debug, Clone, Serialize, Deserialize)]
910#[serde(default)]
911pub struct CsvConfig {
912    /// Lines starting with this are comments, before the header and in the data
913    /// (Frictionless `commentChar`).
914    pub comment: Option<String>,
915    /// What joins a column's pieces when `--header-rows` names several lines
916    /// (Frictionless `headerJoin`).
917    pub header_join: String,
918    /// Ignore the spaces after a delimiter (Frictionless `skipInitialSpace`).
919    pub skip_initial_space: bool,
920    /// Read as null: `VAL` everywhere, `COL=VAL` in one column.
921    pub null_values: Vec<String>,
922    /// Rows read to infer column types, by Polars and by datui's string typing.
923    pub infer_rows: usize,
924    /// Skip rows that do not parse instead of failing.
925    pub ignore_errors: bool,
926}
927
928impl Default for CsvConfig {
929    fn default() -> Self {
930        Self {
931            comment: None,
932            header_join: crate::csv_dialect::DEFAULT_HEADER_JOIN.to_string(),
933            skip_initial_space: false,
934            null_values: Vec::new(),
935            infer_rows: 1000,
936            ignore_errors: false,
937        }
938    }
939}
940
941#[derive(Debug, Clone, Serialize, Deserialize)]
942#[serde(default)]
943pub struct DisplayConfig {
944    /// Whether to draw box-drawing and arrow characters, or fall back to ASCII.
945    pub unicode: crate::glyphs::UnicodeMode,
946    pub row_numbers: RowNumbers,
947    /// The first row's number.
948    pub row_numbers_start: usize,
949    /// Spacing between table columns: `"comfortable"`, `"compact"` or a count of cells.
950    pub cell_padding: CellPadding,
951    /// When true, colorize main table cells by column type (string, int, float, bool, temporal).
952    pub column_colors: bool,
953    /// Show a second header row naming each column's type. `D` toggles it for the session.
954    pub type_row: bool,
955    /// Give the `i` key a quiet accent when datui has noticed something about the data
956    /// and the Info panel has not been opened since. The notes are collected either
957    /// way; this only decides whether the control bar points at them.
958    pub notes_accent: bool,
959    /// Take the mouse: the wheel scrolls and a click selects. The terminal's own text
960    /// selection then needs its bypass modifier (Shift in most terminals).
961    pub mouse: bool,
962    /// A fixed width for every sidebar (Info, Sort & Filter, Views, Pivot & Melt). None:
963    /// each sidebar's own.
964    pub sidebar_width: Option<u16>,
965    /// Right-align numeric columns and their headers in the data table.
966    pub right_align_numbers: bool,
967    /// How numbers are displayed. Either a preset name (`number_format = "thousands"`)
968    /// or a `[display.number_format]` table for finer control.
969    pub number_format: NumberFormatConfig,
970}
971
972/// Whether `#` shows row numbers when a file opens: for text and logs (`"auto"`), or
973/// for every format or none.
974#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
975pub enum RowNumbers {
976    #[default]
977    Auto,
978    On,
979    Off,
980}
981
982impl RowNumbers {
983    /// Whether a file read as `format` opens with them.
984    pub fn for_format(self, format: Option<crate::FileFormat>) -> bool {
985        match self {
986            Self::On => true,
987            Self::Off => false,
988            Self::Auto => matches!(
989                format,
990                Some(crate::FileFormat::Text | crate::FileFormat::Journal)
991            ),
992        }
993    }
994}
995
996impl From<bool> for RowNumbers {
997    fn from(on: bool) -> Self {
998        if on { Self::On } else { Self::Off }
999    }
1000}
1001
1002impl Serialize for RowNumbers {
1003    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1004        match self {
1005            Self::Auto => serializer.serialize_str("auto"),
1006            Self::On => serializer.serialize_bool(true),
1007            Self::Off => serializer.serialize_bool(false),
1008        }
1009    }
1010}
1011
1012impl<'de> Deserialize<'de> for RowNumbers {
1013    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1014        use serde::de::Error;
1015        #[derive(Deserialize)]
1016        #[serde(untagged)]
1017        enum Raw {
1018            Bool(bool),
1019            Name(String),
1020        }
1021        const EXPECTED: &str = "row_numbers is \"auto\", true or false";
1022        match Raw::deserialize(deserializer).map_err(|_| D::Error::custom(EXPECTED))? {
1023            Raw::Bool(on) => Ok(on.into()),
1024            Raw::Name(name) if name == "auto" => Ok(Self::Auto),
1025            Raw::Name(other) => Err(D::Error::custom(format!("{EXPECTED}, not {other:?}"))),
1026        }
1027    }
1028}
1029
1030/// Spacing between the main table's columns, frozen and scrolling alike: a density
1031/// by name, or a count of cells.
1032#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1033#[serde(untagged)]
1034pub enum CellPadding {
1035    Cells(usize),
1036    Density(Density),
1037}
1038
1039/// The named spacings.
1040#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1041#[serde(rename_all = "lowercase")]
1042pub enum Density {
1043    /// One cell between columns: more columns on screen.
1044    Compact,
1045    /// Two cells between columns, the default.
1046    Comfortable,
1047}
1048
1049impl Default for CellPadding {
1050    fn default() -> Self {
1051        Self::Density(Density::Comfortable)
1052    }
1053}
1054
1055impl CellPadding {
1056    /// Cells between two columns.
1057    pub fn cells(self) -> u16 {
1058        match self {
1059            Self::Cells(n) => u16::try_from(n).unwrap_or(u16::MAX),
1060            Self::Density(Density::Compact) => 1,
1061            Self::Density(Density::Comfortable) => 2,
1062        }
1063    }
1064}
1065
1066impl<'de> Deserialize<'de> for CellPadding {
1067    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1068        use serde::de::Error;
1069        #[derive(Deserialize)]
1070        #[serde(untagged)]
1071        enum Raw {
1072            Cells(usize),
1073            Name(String),
1074        }
1075        const EXPECTED: &str = "cell_padding is \"compact\", \"comfortable\" or a number of cells";
1076        match Raw::deserialize(deserializer).map_err(|_| D::Error::custom(EXPECTED))? {
1077            Raw::Cells(n) => Ok(Self::Cells(n)),
1078            Raw::Name(name) => match name.as_str() {
1079                "compact" => Ok(Self::Density(Density::Compact)),
1080                "comfortable" => Ok(Self::Density(Density::Comfortable)),
1081                other => Err(D::Error::custom(format!("{EXPECTED}, not {other:?}"))),
1082            },
1083        }
1084    }
1085}
1086
1087/// Number display settings: a preset name shorthand, or a full table.
1088///
1089/// Both forms are accepted:
1090/// ```toml
1091/// [display]
1092/// number_format = "thousands"
1093/// ```
1094/// ```toml
1095/// [display.number_format]
1096/// grouping = "thousands"
1097/// min_digits = 5
1098/// ```
1099#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1100#[serde(untagged)]
1101pub enum NumberFormatConfig {
1102    /// Shorthand: one of [`NumberFormat::PRESET_NAMES`].
1103    Preset(String),
1104    /// Long form with individual overrides.
1105    Custom(Box<NumberFormatTable>),
1106}
1107
1108impl Default for NumberFormatConfig {
1109    fn default() -> Self {
1110        // Default renders exactly as before, so upgrading changes nothing.
1111        NumberFormatConfig::Preset("none".to_string())
1112    }
1113}
1114
1115/// Long-form number formatting options. Every field is optional; unset fields
1116/// take their value from the preset named by `grouping` (or the default).
1117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1118#[serde(default)]
1119pub struct NumberFormatTable {
1120    /// `none` | `thousands` | `indian` | `system` | any preset name.
1121    pub grouping: Option<String>,
1122    /// Character placed between digit groups.
1123    pub group_separator: Option<String>,
1124    /// Character used as the decimal point.
1125    pub decimal_separator: Option<String>,
1126    /// Whether float columns get grouping too.
1127    pub floats: Option<bool>,
1128    /// Fixed decimal places for floats. Unset keeps Polars' own rendering.
1129    pub float_precision: Option<u8>,
1130    /// Columns never formatted. Supports `*` and `?` globs.
1131    pub exclude_columns: Vec<String>,
1132    /// Keys that are not recognised, captured rather than discarded.
1133    ///
1134    /// A misspelled key here would otherwise be invisible: every field has a
1135    /// default, so the table resolves to "no formatting" — which is also what
1136    /// the default config does. The user would see identical output whether
1137    /// they typo'd the key or never wrote it. Capturing unknown keys lets
1138    /// [`NumberFormatConfig::resolve`] name the offending one instead.
1139    #[serde(flatten)]
1140    pub unknown: std::collections::BTreeMap<String, toml::Value>,
1141}
1142
1143/// Field names accepted inside `[display.number_format]`, for error messages.
1144const NUMBER_FORMAT_KEYS: &str =
1145    "grouping, group_separator, decimal_separator, floats, float_precision, exclude_columns";
1146
1147impl NumberFormatConfig {
1148    /// Resolve into the runtime settings used by the renderer.
1149    ///
1150    /// Returns a descriptive error for unknown preset names, multi-character
1151    /// separators, and a group separator equal to the decimal separator (which
1152    /// would render `1.234.567` ambiguously).
1153    pub fn resolve(&self, align_numeric_right: bool) -> Result<NumberFormatSettings> {
1154        let (format, exclude) = match self {
1155            NumberFormatConfig::Preset(name) => (Self::lookup_preset(name)?, Vec::new()),
1156            NumberFormatConfig::Custom(table) => {
1157                if !table.unknown.is_empty() {
1158                    let keys: Vec<&str> = table.unknown.keys().map(String::as_str).collect();
1159                    return Err(eyre!(
1160                        "display.number_format: unknown key{} {}. Expected one of: {}",
1161                        if keys.len() > 1 { "s" } else { "" },
1162                        keys.iter()
1163                            .map(|k| format!("'{}'", k))
1164                            .collect::<Vec<_>>()
1165                            .join(", "),
1166                        NUMBER_FORMAT_KEYS
1167                    ));
1168                }
1169                let base = match table.grouping.as_deref() {
1170                    Some(name) => Self::lookup_preset(name)?,
1171                    None => NumberFormat::PLAIN,
1172                };
1173                let mut fmt = base;
1174                if let Some(sep) = table.group_separator.as_deref() {
1175                    fmt.group_sep = Self::single_char(sep, "group_separator")?;
1176                }
1177                if let Some(sep) = table.decimal_separator.as_deref() {
1178                    fmt.decimal_sep = Self::single_char(sep, "decimal_separator")?;
1179                }
1180                if let Some(v) = table.floats {
1181                    fmt.floats = v;
1182                }
1183                if table.float_precision.is_some() {
1184                    fmt.float_precision = table.float_precision;
1185                }
1186                (fmt, table.exclude_columns.iter().map(Glob::new).collect())
1187            }
1188        };
1189
1190        if format.grouping != Grouping::None && format.group_sep == format.decimal_sep {
1191            return Err(eyre!(
1192                "display.number_format: group_separator and decimal_separator are both '{}'; \
1193                 they must differ or numbers become ambiguous",
1194                format.group_sep
1195            ));
1196        }
1197
1198        // Formatting starts on only if the user actually configured something.
1199        // When they did not, `,` still needs a format to turn on, so the toggle
1200        // target becomes Thousands grouping while keeping every other setting
1201        // they chose (separators, min_digits, precision). Comma grouping is what
1202        // the default user pressing `,` is asking for.
1203        let enabled = !format.is_noop();
1204        let format = if enabled {
1205            format
1206        } else {
1207            NumberFormat {
1208                grouping: Grouping::Thousands,
1209                ..format
1210            }
1211        };
1212
1213        Ok(NumberFormatSettings {
1214            format,
1215            enabled,
1216            exclude,
1217            align_numeric_right,
1218        })
1219    }
1220
1221    /// Override just the grouping style, keeping any long-form settings the
1222    /// user configured.
1223    ///
1224    /// `--number-format thousands` should change the grouping without silently
1225    /// discarding the `exclude_columns` / `min_digits` / precision a user set up
1226    /// in `[display.number_format]`.
1227    pub fn with_grouping_override(&self, name: &str) -> Self {
1228        match self {
1229            NumberFormatConfig::Preset(_) => NumberFormatConfig::Preset(name.to_string()),
1230            NumberFormatConfig::Custom(table) => {
1231                let mut table = table.clone();
1232                table.grouping = Some(name.to_string());
1233                NumberFormatConfig::Custom(table)
1234            }
1235        }
1236    }
1237
1238    /// Resolve a preset name, expanding the opt-in `system` value.
1239    fn lookup_preset(name: &str) -> Result<NumberFormat> {
1240        // "system" is the only environment-dependent value, and it is opt-in:
1241        // data files are locale-neutral, so rendering does not follow the
1242        // ambient locale unless the user explicitly asks for it.
1243        let name = if name == "system" {
1244            match numfmt::system_locale_tag() {
1245                Some(tag) => numfmt::preset_for_locale_tag(&tag),
1246                // Unset or C/POSIX: no meaningful locale, so group plainly
1247                // rather than silently doing nothing.
1248                None => "thousands",
1249            }
1250        } else {
1251            name
1252        };
1253        NumberFormat::preset(name).ok_or_else(|| {
1254            eyre!(
1255                "display.number_format: unknown value '{}'. Expected one of: {}, system",
1256                name,
1257                NumberFormat::PRESET_NAMES.join(", ")
1258            )
1259        })
1260    }
1261
1262    fn single_char(s: &str, field: &str) -> Result<char> {
1263        let mut chars = s.chars();
1264        match (chars.next(), chars.next()) {
1265            (Some(c), None) => Ok(c),
1266            _ => Err(eyre!(
1267                "display.number_format.{}: expected a single character, got {:?}",
1268                field,
1269                s
1270            )),
1271        }
1272    }
1273}
1274
1275/// Rows an analysis samples by default. Enough that a distribution's shape and a
1276/// correlation are stable to two decimals; few enough to read in seconds.
1277pub const DEFAULT_ANALYSIS_SAMPLE_ROWS: usize = 100_000;
1278
1279#[derive(Debug, Clone, Serialize, Deserialize)]
1280#[serde(default)]
1281pub struct PerformanceConfig {
1282    /// Pages of rows buffered ahead of and behind the screen.
1283    pub pages_ahead: usize,
1284    pub pages_behind: usize,
1285    /// Most rows the table buffers between reads; 0 for no limit.
1286    pub max_buffered_rows: usize,
1287    /// Most memory the buffered rows may take, estimated from the schema; 0 for no
1288    /// limit. A cap on the rows kept between reads, not on the process.
1289    pub max_buffered: ByteSize,
1290    /// Use the Polars streaming engine for collects where it applies.
1291    pub streaming: bool,
1292}
1293
1294impl PerformanceConfig {
1295    /// `max_buffered` in whole MiB, as the table counts it; a nonzero cap below one
1296    /// MiB is one, not none.
1297    pub fn max_buffered_mb(&self) -> usize {
1298        usize::try_from(self.max_buffered.bytes().div_ceil(1 << 20)).unwrap_or(usize::MAX)
1299    }
1300}
1301
1302/// Default for `analysis.quality_local_copy`: 2 GiB.
1303pub const DEFAULT_QUALITY_LOCAL_COPY: ByteSize = ByteSize::mib(2048);
1304
1305/// Default maximum rows used for chart data when not overridden by config or UI.
1306pub const DEFAULT_CHART_ROW_LIMIT: usize = 10_000;
1307/// Maximum chart row limit (Polars slice takes u32).
1308pub const MAX_CHART_ROW_LIMIT: usize = u32::MAX as usize;
1309
1310/// `[analysis]`: Analysis, Data Quality and charts.
1311#[derive(Debug, Clone, Serialize, Deserialize)]
1312#[serde(default)]
1313pub struct AnalysisConfig {
1314    /// The analysis sample's starting size: the rows every tool (Describe,
1315    /// Distribution, Correlation, Data Quality) reads from a table with more, spread
1316    /// across all of it. 0 starts at every row.
1317    pub sample_rows: usize,
1318    /// Rows a chart reads: every row up to n, and a sample of n spread across the
1319    /// table past it.
1320    pub chart_rows: usize,
1321    /// Whether a chart starts with its grid at the major ticks.
1322    pub chart_grid: bool,
1323    /// The most a Data Quality full scan of a remote dataset may copy into the cache
1324    /// directory, to read the objects once instead of once per pass. 0 never copies.
1325    pub quality_local_copy: ByteSize,
1326    /// The most memory a view's sample may take. Unset: the memory available now
1327    /// decides, before the draw and as it runs. 0: no warning and no stop.
1328    pub sample_memory_limit: Option<ByteSize>,
1329}
1330
1331impl Default for AnalysisConfig {
1332    fn default() -> Self {
1333        Self {
1334            sample_rows: DEFAULT_ANALYSIS_SAMPLE_ROWS,
1335            chart_rows: DEFAULT_CHART_ROW_LIMIT,
1336            chart_grid: false,
1337            quality_local_copy: DEFAULT_QUALITY_LOCAL_COPY,
1338            sample_memory_limit: None,
1339        }
1340    }
1341}
1342
1343/// Which set of built-in colour defaults to start from.
1344///
1345/// datui's stock chrome (header fills, row striping, borders, secondary text) has
1346/// to sit *near* the terminal background without matching it. There is no ANSI
1347/// colour that means "slightly off from the background", so those slots resolve to
1348/// fixed values — and a set tuned for a dark terminal is unreadable on a light one.
1349/// This selects which set to use.
1350#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1351#[serde(rename_all = "lowercase")]
1352pub enum ThemeMode {
1353    /// Detect from the environment, falling back to `Dark`.
1354    #[default]
1355    Auto,
1356    Dark,
1357    Light,
1358}
1359
1360impl ThemeMode {
1361    /// Resolve `Auto` against the environment. `Dark` and `Light` pass through.
1362    ///
1363    /// Detection reads `COLORFGBG`, which several terminals set to `fg;bg` using
1364    /// ANSI colour numbers — a background of 7 or 15 (white) means a light terminal.
1365    /// Terminals that do not set it fall back to `Dark`. This is the guess before the
1366    /// terminal is asked: its own answer about its background, when it gives one,
1367    /// replaces it ([`crate::terminal_color`]).
1368    pub fn resolve(self) -> Self {
1369        match self {
1370            Self::Auto => detect_terminal_mode(),
1371            other => other,
1372        }
1373    }
1374}
1375
1376/// Best-effort light/dark detection from `COLORFGBG`. Defaults to `Dark`.
1377fn detect_terminal_mode() -> ThemeMode {
1378    let Ok(raw) = std::env::var("COLORFGBG") else {
1379        return ThemeMode::Dark;
1380    };
1381    // Format is "fg;bg" or "fg;default;bg" — the background is the last field.
1382    match raw
1383        .rsplit(';')
1384        .next()
1385        .and_then(|b| b.trim().parse::<u8>().ok())
1386    {
1387        Some(7) | Some(15) => ThemeMode::Light,
1388        _ => ThemeMode::Dark,
1389    }
1390}
1391
1392/// Where datui looks for datasets on the home screen.
1393///
1394/// This is `PATH`-shaped: a short, stable list of *places*, not per-dataset
1395/// metadata. datui records nothing about the datasets it finds there.
1396#[derive(Debug, Clone, Serialize, Deserialize)]
1397#[serde(default)]
1398pub struct HomeConfig {
1399    /// Whether to also offer directories the desktop records you opening data from.
1400    /// Only the directories are used, never the file names.
1401    pub desktop_recents: bool,
1402    /// Whether the home screen lists files datui has no reader for, dimmed, from the
1403    /// start. `Ctrl+A` flips it for the session either way.
1404    pub show_unreadable: bool,
1405    /// Catalogs never shown on the home screen, by id: `mine`, `public`, or a listed
1406    /// file's name.
1407    pub hide: Vec<String>,
1408    /// The largest local file whose first rows the home screen reads for its preview
1409    /// (Parquet: its average row group). Those rows are the open's first page, so
1410    /// opening the file reads them only once. 0 turns the preview off.
1411    pub preview_max: ByteSize,
1412    /// Recursive search of the working directory from the home screen's filter.
1413    pub search: SearchConfig,
1414}
1415
1416/// Recursive search under the working directory, driven by the home screen's filter.
1417///
1418/// The walk happens once, in the background, the first time you type; every keystroke
1419/// after that scores what it found, off the UI thread. The limits here bound that one
1420/// walk and what is listed from it.
1421#[derive(Debug, Clone, Serialize, Deserialize)]
1422#[serde(default)]
1423pub struct SearchConfig {
1424    /// Search below the working directory at all.
1425    pub enabled: bool,
1426    /// How deep to descend. Data is rarely twelve directories down, and the cost of
1427    /// looking is paid on every branch.
1428    pub max_depth: usize,
1429    /// List at most this many matches, best first; the heading counts the rest. The
1430    /// walk itself keeps every data file it finds, so a match is never lost behind
1431    /// files that do not match. The list is a way to find something, not an inventory.
1432    pub max_results: usize,
1433    /// Give up walking after this long and keep what was found. A cold or enormous
1434    /// tree must degrade to partial results, never to a wait.
1435    pub time_budget: Interval,
1436    /// Descend into directories on a different filesystem than the one started in.
1437    ///
1438    /// Off by default, and the most important limit here: it is what stops a walk
1439    /// from wandering onto a network share, and on a machine using autofs it is what
1440    /// stops the walk from *mounting* one by looking at it.
1441    pub cross_filesystems: bool,
1442    /// Obey `.gitignore`.
1443    ///
1444    /// Off by default, and deliberately: people gitignore data directories precisely
1445    /// because the data is too big to commit, which is the same reason they want to
1446    /// open it in datui. In datui's own repository, honouring it hides 38 real test
1447    /// datasets while hiding 69 files of virtualenv noise — wrong in both directions.
1448    /// The skip list below is the mechanism for the noise.
1449    pub follow_gitignore: bool,
1450    /// Directory names never descended into. Replaces the defaults entirely.
1451    pub skip: Vec<String>,
1452    /// Directory names to skip *in addition* to the defaults, so adding one does not
1453    /// mean restating the list.
1454    pub skip_extra: Vec<String>,
1455    /// File extensions searched for. Empty means every format datui can open, which
1456    /// includes `json` and `txt` — noisy in a source tree, so narrow this if that
1457    /// bothers you.
1458    pub extensions: Vec<String>,
1459}
1460
1461/// Directories that are never data, and are always expensive.
1462///
1463/// Hidden directories are already skipped, which covers `.git`, `.venv`, `.tox` and
1464/// the various caches. What is left is the offenders that are not hidden — and they
1465/// matter: `node_modules` and `site-packages` are full of `.json`, which datui can
1466/// open, so without this every package manifest on the machine is a search result.
1467pub const DEFAULT_SEARCH_SKIP: &[&str] = &[
1468    "node_modules",
1469    "target",
1470    "build",
1471    "dist",
1472    "vendor",
1473    "site-packages",
1474    "__pycache__",
1475    "venv",
1476    "env",
1477];
1478
1479impl Default for SearchConfig {
1480    fn default() -> Self {
1481        Self {
1482            enabled: true,
1483            max_depth: 8,
1484            max_results: 1_000,
1485            time_budget: Interval::ms(1_500),
1486            cross_filesystems: false,
1487            follow_gitignore: false,
1488            skip: DEFAULT_SEARCH_SKIP.iter().map(|s| s.to_string()).collect(),
1489            skip_extra: Vec::new(),
1490            extensions: Vec::new(),
1491        }
1492    }
1493}
1494
1495impl SearchConfig {
1496    /// Every directory name to skip: the configured list plus the additions.
1497    pub fn skipped_dirs(&self) -> Vec<String> {
1498        let mut out = self.skip.clone();
1499        out.extend(self.skip_extra.iter().cloned());
1500        out
1501    }
1502}
1503
1504impl Default for HomeConfig {
1505    fn default() -> Self {
1506        Self {
1507            // On by default: it only ever contributes *places*, and it is the one
1508            // thing that gives a fresh install somewhere to point you.
1509            desktop_recents: true,
1510            show_unreadable: false,
1511            hide: Vec::new(),
1512            preview_max: ByteSize::mib(64),
1513            search: SearchConfig::default(),
1514        }
1515    }
1516}
1517
1518#[derive(Debug, Clone, Serialize, Deserialize)]
1519#[serde(default)]
1520pub struct ThemeConfig {
1521    /// Which mode's theme to use. `None` means the key was absent, which is treated
1522    /// as `Auto`; a loaded config holds the resolved mode.
1523    pub mode: Option<ThemeMode>,
1524    /// The theme used when the terminal is dark: a built-in's name or a file's in
1525    /// `themes/`.
1526    pub dark: String,
1527    /// The theme used when the terminal is light.
1528    pub light: String,
1529    pub colors: ColorConfig,
1530    /// The mode was `auto`: the palette follows what the terminal says about its
1531    /// background, at startup and when asked again. Set by `from_layers`.
1532    #[serde(skip)]
1533    pub follow: bool,
1534    /// The `theme.colors` slots the configuration set, laid over the active theme
1535    /// whichever mode it is for.
1536    #[serde(skip)]
1537    pub overrides: toml::Table,
1538    /// The themes there are, read from the config directory's `themes/`.
1539    #[serde(skip)]
1540    pub library: crate::themes::Library,
1541    /// The theme in use for each mode: `dark` and `light`, or the built-in when the
1542    /// named one could not be used.
1543    #[serde(skip)]
1544    pub dark_theme: String,
1545    #[serde(skip)]
1546    pub light_theme: String,
1547    /// Each mode's theme resolved, before `overrides`.
1548    #[serde(skip)]
1549    pub dark_palette: ColorConfig,
1550    #[serde(skip)]
1551    pub light_palette: ColorConfig,
1552    /// Why a named theme was not used, one line each, for a warning.
1553    #[serde(skip)]
1554    pub problems: Vec<String>,
1555    /// The same, without the why: short enough for the footer.
1556    #[serde(skip)]
1557    pub fallbacks: Vec<String>,
1558}
1559
1560impl Default for ThemeConfig {
1561    fn default() -> Self {
1562        Self {
1563            mode: None,
1564            dark: crate::themes::NIGHT_MARKET.to_string(),
1565            light: crate::themes::DAY_MARKET.to_string(),
1566            colors: ColorConfig::default(),
1567            follow: false,
1568            overrides: toml::Table::new(),
1569            library: crate::themes::Library::default(),
1570            dark_theme: crate::themes::NIGHT_MARKET.to_string(),
1571            light_theme: crate::themes::DAY_MARKET.to_string(),
1572            dark_palette: ColorConfig::dark(),
1573            light_palette: ColorConfig::light(),
1574            problems: Vec::new(),
1575            fallbacks: Vec::new(),
1576        }
1577    }
1578}
1579
1580impl ThemeConfig {
1581    /// The theme for `mode` with the configured slots laid over it.
1582    pub fn palette_for(&self, mode: ThemeMode) -> Result<ColorConfig> {
1583        let base = match mode.resolve() {
1584            ThemeMode::Light => &self.light_palette,
1585            _ => &self.dark_palette,
1586        };
1587        let mut palette = crate::themes::slots(base);
1588        palette.extend(self.overrides.clone());
1589        Ok(toml::Value::Table(palette).try_into()?)
1590    }
1591
1592    /// Every theme file left out and every name not used, one warning each.
1593    pub fn warnings(&self) -> Vec<String> {
1594        let broken = self
1595            .library
1596            .broken
1597            .iter()
1598            .map(|b| format!("warning: theme left out: {}", b.full()));
1599        let problems = self.problems.iter().map(|p| format!("warning: {p}"));
1600        broken.chain(problems).collect()
1601    }
1602
1603    /// Resolve `dark` and `light` against `library`, which it keeps. A name that
1604    /// cannot be used falls back to its mode's built-in, with a line in `problems`
1605    /// when that mode can be in use: either under `auto`, else only the pinned one.
1606    pub fn use_library(&mut self, library: crate::themes::Library, active: ThemeMode) {
1607        self.problems.clear();
1608        self.fallbacks.clear();
1609        for mode in [ThemeMode::Dark, ThemeMode::Light] {
1610            let (key, name) = match mode {
1611                ThemeMode::Light => ("theme.light", self.light.clone()),
1612                _ => ("theme.dark", self.dark.clone()),
1613            };
1614            let (used, palette) = match library.resolve(&name, mode) {
1615                Ok(palette) => (name, palette),
1616                Err(why) => {
1617                    let fallback = crate::themes::built_in_name(mode);
1618                    if self.follow || active == mode {
1619                        let short = format!("{key}: using {fallback}, not {name}");
1620                        self.problems.push(format!("{short}: {why}"));
1621                        self.fallbacks.push(short);
1622                    }
1623                    (fallback.to_string(), ColorConfig::for_mode(mode))
1624                }
1625            };
1626            match mode {
1627                ThemeMode::Light => (self.light_theme, self.light_palette) = (used, palette),
1628                _ => (self.dark_theme, self.dark_palette) = (used, palette),
1629            }
1630        }
1631        self.library = library;
1632    }
1633}
1634
1635/// Color configuration for the application theme: one slot per role, each a name
1636/// (`"cyan"`, `"default"`), `"#rrggbb"` or `"indexed(N)"`. The option registry
1637/// documents every slot and holds its dark and light defaults.
1638#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1639#[serde(default)]
1640pub struct ColorConfig {
1641    pub chip_key: String,
1642    pub chip_label: String,
1643    pub throbber: String,
1644    pub success: String,
1645    pub error: String,
1646    pub warning: String,
1647    pub dimmed: String,
1648    pub background: String,
1649    pub surface: String,
1650    pub controls_bg: String,
1651    pub text_primary: String,
1652    pub text_secondary: String,
1653    pub text_inverse: String,
1654    pub table_header: String,
1655    pub table_header_bg: String,
1656    /// The row-number column. "default" is the terminal's.
1657    pub table_row_numbers: String,
1658    pub table_column_separator: String,
1659    /// Tint under the current row; "reversed" swaps text and background instead.
1660    pub table_selected: String,
1661    /// Tint under the column cursor's cells.
1662    pub table_column_cursor: String,
1663    /// The column cursor's header and the current cell.
1664    pub table_cell_cursor: String,
1665    /// Every other row; "default" turns the stripe off.
1666    pub table_alternate_row: String,
1667    pub sidebar_border: String,
1668    pub modal_border_active: String,
1669    pub modal_border_error: String,
1670    pub distribution_normal: String,
1671    pub distribution_skewed: String,
1672    pub distribution_other: String,
1673    pub outlier_marker: String,
1674    /// The text caret; "default" reverses the text under it.
1675    pub input_cursor: String,
1676    /// Text under the caret block; "default" picks black or white by contrast.
1677    pub input_cursor_text: String,
1678    /// Cells and headers by column type.
1679    pub type_str: String,
1680    pub type_int: String,
1681    pub type_float: String,
1682    pub type_bool: String,
1683    pub type_temporal: String,
1684    /// The `‹binary›` stub of a binary column.
1685    pub type_binary: String,
1686    /// The chart series, in order; `chart_1` is also histogram bars and Q-Q points.
1687    pub chart_1: String,
1688    pub chart_2: String,
1689    pub chart_3: String,
1690    pub chart_4: String,
1691    pub chart_5: String,
1692    pub chart_6: String,
1693    pub chart_7: String,
1694    pub chart_8: String,
1695    pub chart_9: String,
1696    pub chart_10: String,
1697    /// The chart grid, a shade dimmer than `dimmed`.
1698    pub chart_grid: String,
1699    /// The one colour that means "this is the thing": focused titles, key chips, the
1700    /// selection rail.
1701    pub accent: String,
1702    /// A brighter accent for a focused title or a value that just changed.
1703    pub accent_bright: String,
1704    /// Two stops for the wordmark on the home screen. Used nowhere else on purpose:
1705    /// a gradient on data would be decoration.
1706    pub gradient_start: String,
1707    pub gradient_end: String,
1708    /// Behind the cell a find landed on; its text takes black or white by contrast.
1709    pub find_match: String,
1710    /// The hex view's bytes by class, as hexyl colors them.
1711    pub hex_null: String,
1712    pub hex_printable: String,
1713    pub hex_whitespace: String,
1714    pub hex_control: String,
1715    pub hex_high: String,
1716    pub hex_ff: String,
1717}
1718
1719/// `[http]`: what every request datui makes says about it.
1720#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1721#[serde(default)]
1722pub struct HttpConfig {
1723    /// The User-Agent header; empty sends [`crate::user_agent::DEFAULT`].
1724    pub user_agent: String,
1725}
1726
1727#[derive(Debug, Clone, Serialize, Deserialize)]
1728#[serde(default)]
1729pub struct QueryConfig {
1730    pub history_limit: usize,
1731    /// Remember queries.
1732    pub history: bool,
1733    pub default_mode: QueryMode,
1734}
1735
1736/// The language the command line runs a query in.
1737#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1738#[serde(rename_all = "kebab-case")]
1739pub enum QueryMode {
1740    #[default]
1741    Sql,
1742    /// Datui's q-inspired language.
1743    Q,
1744}
1745
1746impl QueryMode {
1747    /// The languages this build offers. SQL is absent without the `sql` feature
1748    /// rather than present and broken.
1749    pub fn available() -> &'static [QueryMode] {
1750        #[cfg(feature = "sql")]
1751        {
1752            &[QueryMode::Sql, QueryMode::Q]
1753        }
1754        #[cfg(not(feature = "sql"))]
1755        {
1756            &[QueryMode::Q]
1757        }
1758    }
1759
1760    /// This language if the build offers it, otherwise q.
1761    pub fn resolve(self) -> QueryMode {
1762        if Self::available().contains(&self) {
1763            self
1764        } else {
1765            QueryMode::Q
1766        }
1767    }
1768
1769    /// The command line's prefix for it: `sql`, `q`.
1770    pub fn prefix(self) -> &'static str {
1771        match self {
1772            QueryMode::Sql => "sql",
1773            QueryMode::Q => "q",
1774        }
1775    }
1776
1777    /// The prefix as the command line draws it: `sql:`, `q:`.
1778    pub fn prefix_colon(self) -> &'static str {
1779        match self {
1780            QueryMode::Sql => "sql:",
1781            QueryMode::Q => "q:",
1782        }
1783    }
1784
1785    /// The other language, where the build has one.
1786    pub fn next(self) -> QueryMode {
1787        let modes = Self::available();
1788        let at = modes.iter().position(|&m| m == self).unwrap_or(0);
1789        modes[(at + 1) % modes.len()]
1790    }
1791}
1792
1793/// `[chart]`: charts exported to a file.
1794#[derive(Debug, Clone, Serialize, Deserialize)]
1795#[serde(default)]
1796pub struct ChartConfig {
1797    /// Whether an exported chart carries its recipe: the source, query, chart and
1798    /// sample it was made from. The export dialog's Recipe row starts from it.
1799    pub export_recipe: bool,
1800}
1801
1802impl Default for ChartConfig {
1803    fn default() -> Self {
1804        Self {
1805            export_recipe: true,
1806        }
1807    }
1808}
1809
1810/// `[views]`: saved views.
1811#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1812#[serde(default)]
1813pub struct ViewsConfig {
1814    pub auto_apply: bool,
1815}
1816
1817/// `[log]`: the log file and how much it says.
1818#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1819#[serde(default)]
1820pub struct LogConfig {
1821    /// Where the log goes. Unset: `datui.log` in the cache directory.
1822    pub file: Option<String>,
1823    /// error, warn, info, debug, trace or off. Unset: `DATUI_LOG`, else warn.
1824    pub level: Option<String>,
1825}
1826
1827/// `[formats]`: where format specs and dictionaries are found.
1828#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1829#[serde(default)]
1830pub struct FormatsConfig {
1831    /// Directories (or files) of specs and dictionaries, searched after the config
1832    /// directory's `formats` and `$DATUI_FORMATS_PATH`. Adds up across imports.
1833    pub path: Vec<String>,
1834}
1835
1836// Default implementations
1837/// `[clipboard]`: how the copy dialog reaches the system clipboard.
1838#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1839#[serde(default)]
1840pub struct ClipboardConfig {
1841    /// "auto", "native" (display server through arboard) or "osc52" (an
1842    /// escape sequence the terminal applies; what works over SSH).
1843    pub backend: String,
1844    /// Longest OSC 52 payload to attempt, as base64. Terminals cap the sequences
1845    /// they accept; a generous terminal's user can raise this.
1846    pub osc52_limit: ByteSize,
1847}
1848
1849impl Default for ClipboardConfig {
1850    fn default() -> Self {
1851        Self {
1852            backend: "auto".to_string(),
1853            osc52_limit: ByteSize::kib(100),
1854        }
1855    }
1856}
1857
1858/// `[glyphs]`: per-slot overrides laid over the Unicode set, so a font that has
1859/// more than the coverage floor gets to use it — `☁` back for the object-store
1860/// mark, a Nerd Font icon for a checkbox. Keys are the slot names in
1861/// `glyphs.rs`; values keep the display width of the glyph they replace.
1862/// Overrides never touch the ASCII set, which stays the tested floor. Layered
1863/// like every section: defaults, then each import, then the user's file.
1864#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1865#[serde(default)]
1866pub struct GlyphsConfig {
1867    #[serde(flatten)]
1868    pub overrides: std::collections::BTreeMap<String, crate::glyphs::SlotOverride>,
1869}
1870
1871impl Default for AppConfig {
1872    fn default() -> Self {
1873        let mut config = Self {
1874            import: Vec::new(),
1875            catalogs: Vec::new(),
1876            read_catalogs: Vec::new(),
1877            catalog_dir: None,
1878            broken_catalogs: Vec::new(),
1879            read: ReadConfig::default(),
1880            csv: CsvConfig::default(),
1881            display: DisplayConfig::default(),
1882            performance: PerformanceConfig::default(),
1883            analysis: AnalysisConfig::default(),
1884            chart: ChartConfig::default(),
1885            home: HomeConfig::default(),
1886            cloud: CloudConfig::default(),
1887            http: HttpConfig::default(),
1888            query: QueryConfig::default(),
1889            views: ViewsConfig::default(),
1890            clipboard: ClipboardConfig::default(),
1891            formats: FormatsConfig::default(),
1892            log: LogConfig::default(),
1893            theme: ThemeConfig::default(),
1894            glyphs: GlyphsConfig::default(),
1895        };
1896        config.sync_dataset_access();
1897        config
1898    }
1899}
1900
1901impl Default for DisplayConfig {
1902    fn default() -> Self {
1903        Self {
1904            unicode: crate::glyphs::UnicodeMode::default(),
1905            row_numbers: RowNumbers::Auto,
1906            row_numbers_start: 1,
1907            cell_padding: CellPadding::default(),
1908            column_colors: true,
1909            type_row: true,
1910            notes_accent: true,
1911            mouse: true,
1912            sidebar_width: None,
1913            right_align_numbers: true,
1914            number_format: NumberFormatConfig::default(),
1915        }
1916    }
1917}
1918
1919impl Default for PerformanceConfig {
1920    fn default() -> Self {
1921        Self {
1922            pages_ahead: 3,
1923            pages_behind: 3,
1924            max_buffered_rows: crate::widgets::datatable::DEFAULT_MAX_BUFFERED_ROWS,
1925            max_buffered: ByteSize::mib(512),
1926            streaming: true,
1927        }
1928    }
1929}
1930
1931impl Default for ColorConfig {
1932    /// Dark, preserving datui's historical defaults. Light is opt-in via
1933    /// `theme.mode`, so no existing config changes appearance.
1934    fn default() -> Self {
1935        Self::dark()
1936    }
1937}
1938
1939impl ColorConfig {
1940    /// The built-in set for whichever mode is in effect.
1941    pub fn for_mode(mode: ThemeMode) -> Self {
1942        match mode.resolve() {
1943            ThemeMode::Light => Self::light(),
1944            _ => Self::dark(),
1945        }
1946    }
1947
1948    /// Defaults tuned for a dark terminal background.
1949    pub fn dark() -> Self {
1950        // "Night Market": Tokyo Night's palette with one cyan accent. Chrome sits in
1951        // three tiers a few percent apart (controls_bg, table_header_bg, the stripe)
1952        // rather than one grey shared by everything, and the row under the cursor is
1953        // tinted rather than reversed so cell colours survive on it.
1954        Self {
1955            chip_key: "#7dcfff".to_string(),
1956            chip_label: "#a9b1d6".to_string(),
1957            throbber: "#7dcfff".to_string(),
1958            success: "#9ece6a".to_string(),
1959            error: "#f7768e".to_string(),
1960            warning: "#e0af68".to_string(),
1961            dimmed: "#565f89".to_string(),
1962            background: "default".to_string(),
1963            surface: "default".to_string(),
1964            controls_bg: "#262a3f".to_string(),
1965            text_primary: "default".to_string(),
1966            text_secondary: "#737aa2".to_string(),
1967            text_inverse: "#1a1b26".to_string(),
1968            table_header: "#c0caf5".to_string(),
1969            table_header_bg: "#2b3047".to_string(),
1970            table_row_numbers: "#565f89".to_string(),
1971            table_column_separator: "#3b4261".to_string(),
1972            table_selected: "#283457".to_string(),
1973            // A grey a step off the stripe for the column, and a lighter one where it
1974            // crosses the current row, so the cell stands out from both.
1975            table_column_cursor: "#292e42".to_string(),
1976            table_cell_cursor: "#3b4261".to_string(),
1977            // Box titles are drawn in the border colour, so this has to read as text:
1978            // the theme's comment grey, not the hairline shade the rules use.
1979            sidebar_border: "#565f89".to_string(),
1980            modal_border_active: "#7dcfff".to_string(),
1981            modal_border_error: "#f7768e".to_string(),
1982            distribution_normal: "#9ece6a".to_string(),
1983            distribution_skewed: "#e0af68".to_string(),
1984            distribution_other: "#c0caf5".to_string(),
1985            outlier_marker: "#f7768e".to_string(),
1986            input_cursor: "default".to_string(),
1987            input_cursor_text: "default".to_string(),
1988            table_alternate_row: "#1e2030".to_string(),
1989            type_str: "#9ece6a".to_string(),
1990            type_int: "#7aa2f7".to_string(),
1991            type_float: "#2ac3de".to_string(),
1992            type_bool: "#e0af68".to_string(),
1993            type_temporal: "#bb9af7".to_string(),
1994            type_binary: "#565f89".to_string(),
1995            chart_1: "#7dcfff".to_string(),
1996            chart_2: "#bb9af7".to_string(),
1997            chart_3: "#9ece6a".to_string(),
1998            chart_4: "#e0af68".to_string(),
1999            chart_5: "#7aa2f7".to_string(),
2000            chart_6: "#f7768e".to_string(),
2001            chart_7: "#ff9e64".to_string(),
2002            // Tokyo Night's teal, a pink-magenta and a light yellow: apart from the
2003            // seven by lightness as much as hue, so they stay apart under the common
2004            // color-vision deficiencies.
2005            chart_8: "#1abc9c".to_string(),
2006            chart_9: "#ff5fd2".to_string(),
2007            chart_10: "#f4ef8a".to_string(),
2008            // Dimmer than `dimmed`, and still blue rather than black on a 16-color
2009            // terminal, where black is the background.
2010            chart_grid: "#3d4785".to_string(),
2011            accent: "#7dcfff".to_string(),
2012            accent_bright: "#a4daff".to_string(),
2013            gradient_start: "#7aa2f7".to_string(),
2014            gradient_end: "#bb9af7".to_string(),
2015            find_match: "#e0af68".to_string(),
2016            hex_null: "#565f89".to_string(),
2017            hex_printable: "#7dcfff".to_string(),
2018            hex_whitespace: "#9ece6a".to_string(),
2019            hex_control: "#bb9af7".to_string(),
2020            hex_high: "#e0af68".to_string(),
2021            hex_ff: "#f7768e".to_string(),
2022        }
2023    }
2024
2025    /// Defaults tuned for a light terminal background.
2026    ///
2027    /// The chrome shades are inverted rather than merely lightened: on a light
2028    /// terminal the "slightly off from background" shades must be *darker* than the
2029    /// background, where on a dark terminal they are lighter. Hues that are legible
2030    /// on black and not on white (plain `cyan`, plain `yellow`) are replaced with
2031    /// darker equivalents from the 256-colour cube.
2032    pub fn light() -> Self {
2033        // Tokyo Night's "day" variant: the same hues, darkened until every one of them
2034        // clears 4.5:1 on a white or near-white background. The chrome tiers go the
2035        // other way — a little darker than the terminal rather than lighter.
2036        Self {
2037            chip_key: "#2e7de9".to_string(),
2038            chip_label: "#3760bf".to_string(),
2039            throbber: "#2e7de9".to_string(),
2040            success: "#587539".to_string(),
2041            error: "#f52a65".to_string(),
2042            warning: "#8c6c3e".to_string(),
2043            dimmed: "#848cb5".to_string(),
2044            background: "default".to_string(),
2045            surface: "default".to_string(),
2046            controls_bg: "#d0d5e3".to_string(),
2047            text_primary: "default".to_string(),
2048            text_secondary: "#6172b0".to_string(),
2049            text_inverse: "#e1e2e7".to_string(),
2050            table_header: "#3760bf".to_string(),
2051            table_header_bg: "#c4c8da".to_string(),
2052            table_row_numbers: "#848cb5".to_string(),
2053            table_column_separator: "#a8aecb".to_string(),
2054            table_selected: "#b6bfe2".to_string(),
2055            table_column_cursor: "#cbd3f2".to_string(),
2056            table_cell_cursor: "#a0aef0".to_string(),
2057            sidebar_border: "#6172b0".to_string(),
2058            modal_border_active: "#2e7de9".to_string(),
2059            modal_border_error: "#f52a65".to_string(),
2060            distribution_normal: "#587539".to_string(),
2061            distribution_skewed: "#8c6c3e".to_string(),
2062            distribution_other: "#3760bf".to_string(),
2063            outlier_marker: "#f52a65".to_string(),
2064            input_cursor: "default".to_string(),
2065            input_cursor_text: "default".to_string(),
2066            table_alternate_row: "#dcdfea".to_string(),
2067            type_str: "#587539".to_string(),
2068            type_int: "#2e7de9".to_string(),
2069            type_float: "#007197".to_string(),
2070            type_bool: "#8c6c3e".to_string(),
2071            type_temporal: "#9854f1".to_string(),
2072            type_binary: "#848cb5".to_string(),
2073            chart_1: "#2e7de9".to_string(),
2074            chart_2: "#9854f1".to_string(),
2075            chart_3: "#587539".to_string(),
2076            chart_4: "#8c6c3e".to_string(),
2077            chart_5: "#007197".to_string(),
2078            chart_6: "#f52a65".to_string(),
2079            chart_7: "#b15c00".to_string(),
2080            // A yellow does not read on white: a deep navy takes its place.
2081            chart_8: "#118c74".to_string(),
2082            chart_9: "#d1188c".to_string(),
2083            chart_10: "#24357a".to_string(),
2084            // The theme's cyan halfway to the background: a grey this light is white
2085            // on a 16-color terminal, and the grid vanished into the background.
2086            chart_grid: "#70aabf".to_string(),
2087            accent: "#2e7de9".to_string(),
2088            accent_bright: "#1a6cd0".to_string(),
2089            gradient_start: "#2e7de9".to_string(),
2090            gradient_end: "#9854f1".to_string(),
2091            find_match: "#f0c35a".to_string(),
2092            hex_null: "#848cb5".to_string(),
2093            hex_printable: "#007197".to_string(),
2094            hex_whitespace: "#587539".to_string(),
2095            hex_control: "#9854f1".to_string(),
2096            hex_high: "#8c6c3e".to_string(),
2097            hex_ff: "#f52a65".to_string(),
2098        }
2099    }
2100}
2101
2102impl Default for QueryConfig {
2103    fn default() -> Self {
2104        Self {
2105            history_limit: 1000,
2106            history: true,
2107            default_mode: QueryMode::default(),
2108        }
2109    }
2110}
2111
2112/// Maximum number of config files an `import` chain may stack up.
2113///
2114/// Chains this deep are a mistake rather than a use case; the cap turns a
2115/// runaway (or merely confusing) graph into a clear error.
2116const MAX_IMPORT_DEPTH: usize = 8;
2117
2118/// Expand a leading `~` and any `$VAR` / `${VAR}` reference in a config path.
2119///
2120/// Unset variables expand to nothing, as in a shell. This is what lets a config
2121/// name a path such as `~/.local/state/omarchy/current/theme/datui.toml` without
2122/// hardcoding a home directory.
2123pub fn expand_config_path(raw: &str) -> PathBuf {
2124    expand_path(raw)
2125}
2126
2127/// `path` with a leading `~` expanded, and nothing else. For a path from the command
2128/// line: cmd, and PowerShell before 7.4, pass `~\data\a.csv` on as typed, as every
2129/// shell does a quoted `"~/a.csv"`. A `$` there has been through the shell already
2130/// and is part of a name. A path that is there as typed, such as a file named `~` in
2131/// the working directory, is that path.
2132pub fn expand_home(path: &Path) -> PathBuf {
2133    expand_home_unless(path, |p| p.symlink_metadata().is_ok())
2134}
2135
2136fn expand_home_unless(path: &Path, there: impl FnOnce(&Path) -> bool) -> PathBuf {
2137    path.to_str()
2138        .and_then(home_path)
2139        .filter(|_| !there(path))
2140        .unwrap_or_else(|| path.to_path_buf())
2141}
2142
2143/// `~`, `~/x` and, on Windows, `~\x` under the home directory; `None` for anything
2144/// else, or with no home directory.
2145fn home_path(text: &str) -> Option<PathBuf> {
2146    if text == "~" {
2147        return dirs::home_dir();
2148    }
2149    let rest = text
2150        .strip_prefix("~/")
2151        // What `display_path` writes there, and what a Windows user types.
2152        .or_else(|| text.strip_prefix("~\\").filter(|_| cfg!(windows)))?;
2153    dirs::home_dir().map(|home| home.join(rest))
2154}
2155
2156/// `path` spelled one way, without asking the filesystem: rebuilt from its components,
2157/// so separators compare as one (on Windows `~/a.csv` expands to `C:\Users\me\a.csv`
2158/// and `$USERPROFILE/a.csv` to `C:\Users\me/a.csv`), with no `.` and a trailing
2159/// separator dropped. A drive letter is one case and a UNC prefix takes backslashes.
2160/// `..` stays: past a symlink it is not the parent the text names.
2161pub(crate) fn path_place(path: &Path) -> PathBuf {
2162    use std::path::{Component, Prefix};
2163    let mut place = PathBuf::new();
2164    for component in path.components() {
2165        match component {
2166            Component::CurDir => {}
2167            Component::Prefix(prefix) => match prefix.kind() {
2168                Prefix::Disk(drive) => {
2169                    place.push(format!("{}:", char::from(drive.to_ascii_uppercase())));
2170                }
2171                Prefix::UNC(server, share) => {
2172                    let mut unc = std::ffi::OsString::from(r"\\");
2173                    unc.push(server);
2174                    unc.push(r"\");
2175                    unc.push(share);
2176                    place.push(unc);
2177                }
2178                _ => place.push(prefix.as_os_str()),
2179            },
2180            other => place.push(other),
2181        }
2182    }
2183    place
2184}
2185
2186pub(crate) fn expand_path(raw: &str) -> PathBuf {
2187    let mut expanded = String::with_capacity(raw.len());
2188    let mut chars = raw.chars().peekable();
2189
2190    while let Some(c) = chars.next() {
2191        if c != '$' {
2192            expanded.push(c);
2193            continue;
2194        }
2195
2196        let braced = chars.peek() == Some(&'{');
2197        if braced {
2198            chars.next();
2199        }
2200
2201        let mut name = String::new();
2202        while let Some(&next) = chars.peek() {
2203            if braced && next == '}' {
2204                chars.next();
2205                break;
2206            }
2207            if !next.is_ascii_alphanumeric() && next != '_' {
2208                break;
2209            }
2210            name.push(next);
2211            chars.next();
2212        }
2213
2214        if name.is_empty() {
2215            // A bare `$`, or `${}` — leave it as written rather than guessing.
2216            expanded.push('$');
2217        } else if let Ok(value) = std::env::var(&name) {
2218            expanded.push_str(&value);
2219        }
2220    }
2221
2222    // `~` expands only at the start of the path, as in a shell.
2223    home_path(&expanded).unwrap_or_else(|| PathBuf::from(expanded))
2224}
2225
2226/// One config file's settings as written: the keys it sets and nothing else.
2227///
2228/// Keeping a layer partial is what lets a later file set a value back to its
2229/// default: `notes_accent = true` in your config undoes an import's `false`, and a
2230/// file that leaves the key out changes nothing. [`AppConfig::from_layers`] fills in
2231/// the defaults once, after every layer is merged.
2232#[derive(Debug, Clone, Default, PartialEq)]
2233pub struct ConfigLayer {
2234    table: toml::Table,
2235    /// The files this one imports, as written. Never merged: a load-time directive.
2236    imports: Vec<String>,
2237}
2238
2239/// Where a layer of the configuration came from.
2240#[derive(Debug, Clone, PartialEq, Eq)]
2241pub enum LayerSource {
2242    /// A config file: the user's, or one it imports.
2243    File(PathBuf),
2244    /// `-c KEY=VALUE` on the command line.
2245    Override,
2246}
2247
2248impl std::fmt::Display for LayerSource {
2249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2250        match self {
2251            Self::File(path) => write!(f, "{}", path.display()),
2252            Self::Override => f.write_str("-c"),
2253        }
2254    }
2255}
2256
2257/// How a key combines across layers when a later layer does not simply replace it.
2258#[derive(Debug, Clone, Copy)]
2259enum Combine {
2260    /// An array of tables matched by `name`: an entry replaces the earlier one of its
2261    /// name whole, and a new name appends. Two of one name in one file are both kept,
2262    /// for validation to name.
2263    ByName,
2264    /// A list that adds up across files, without repeats.
2265    Union,
2266}
2267
2268/// The keys that do not follow "a later layer's value replaces the earlier one".
2269/// Tables merge key by key; everything else not listed here is replaced whole.
2270const COMBINED_KEYS: &[(&str, Combine)] = &[
2271    ("formats.path", Combine::Union),
2272    ("catalogs", Combine::Union),
2273    ("cloud.connections", Combine::ByName),
2274    ("cloud.hide", Combine::Union),
2275    ("cloud.env_files", Combine::Union),
2276    ("home.hide", Combine::Union),
2277];
2278
2279/// The line after a config file's mistake: how to get going again. An import that
2280/// is not there is skipped, and `datui config init` writes a root file only where
2281/// there is none.
2282pub fn way_out(imported: bool, what: &str) -> String {
2283    if imported {
2284        format!("Fix that {what}, or move the file aside: a missing import is skipped.")
2285    } else {
2286        format!(
2287            "Fix that {what}, or move the file aside to start from the defaults; \
2288             `datui config init` then writes a fresh one."
2289        )
2290    }
2291}
2292
2293impl ConfigLayer {
2294    /// A layer from TOML text. Types are checked here, so a mistake is reported
2295    /// against the file that holds it rather than after merging.
2296    pub fn parse(text: &str) -> Result<Self> {
2297        let typed: AppConfig = toml::from_str(text)?;
2298        let table: toml::Table = toml::from_str(text)?;
2299        Ok(Self::from_table(table, typed.import))
2300    }
2301
2302    /// The layer `-c KEY=VALUE` makes: each key at its place, the last of one key
2303    /// winning. Keys and value shapes were checked as the command line was read; the
2304    /// types are checked here, as a file's are.
2305    pub fn from_overrides(overrides: &[datui_cli::settings::Override]) -> Result<Self> {
2306        let mut table = toml::Table::new();
2307        for o in overrides {
2308            let mut at = &mut table;
2309            let mut parts: Vec<&str> = o.key.split('.').collect();
2310            let last = parts.pop().unwrap_or_default();
2311            for part in parts {
2312                let entry = at
2313                    .entry(part.to_string())
2314                    .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2315                if !entry.is_table() {
2316                    *entry = toml::Value::Table(toml::Table::new());
2317                }
2318                at = entry.as_table_mut().expect("just made a table");
2319            }
2320            at.insert(last.to_string(), o.value.clone());
2321        }
2322        toml::Value::Table(table.clone())
2323            .try_into::<AppConfig>()
2324            .map_err(|e| eyre!("-c: {}", e.message().trim_end()))?;
2325        Ok(Self::from_table(table, Vec::new()))
2326    }
2327
2328    fn from_table(mut table: toml::Table, imports: Vec<String>) -> Self {
2329        table.remove("import");
2330        Self { table, imports }
2331    }
2332
2333    /// The layer in `path`, or `None` when there is no such file. A file that exists
2334    /// but cannot be read or parsed is an error naming it, and `importer`, the file
2335    /// that imported it, if any.
2336    fn read(path: &Path, importer: Option<&Path>) -> Result<Option<Self>> {
2337        // On the first line, ahead of a parse error's excerpt of the file.
2338        let named = match importer {
2339            Some(importer) => format!("{} (imported by {})", path.display(), importer.display()),
2340            None => path.display().to_string(),
2341        };
2342        let content = match std::fs::read_to_string(path) {
2343            Ok(content) => content,
2344            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2345            Err(e) => return Err(eyre!("Failed to read config file at {named}: {e}")),
2346        };
2347        let mut layer = Self::parse(&content).map_err(|e| {
2348            eyre!(
2349                "Failed to parse config file at {named}: {}\n{}",
2350                parse_reason(&e),
2351                way_out(importer.is_some(), "line")
2352            )
2353        })?;
2354        // Serde passes over a key it does not know; a renamed or misspelled one would
2355        // otherwise change nothing without a word.
2356        for unknown in unknown_keys_in(&layer.table) {
2357            eprintln!("datui: warning: {}: {unknown}", path.display());
2358        }
2359        layer.anchor_paths(path.parent().unwrap_or_else(|| Path::new(".")));
2360        Ok(Some(layer))
2361    }
2362
2363    /// Resolve relative format and catalog paths against `dir`, the directory of the
2364    /// file that named them, before a layer from another directory can be merged.
2365    fn anchor_paths(&mut self, dir: &Path) {
2366        if let Some(toml::Value::Array(entries)) = self
2367            .table
2368            .get_mut("formats")
2369            .and_then(|f| f.get_mut("path"))
2370        {
2371            for entry in entries {
2372                if let toml::Value::String(path) = entry
2373                    && !path.trim().is_empty()
2374                    && expand_path(path).is_relative()
2375                {
2376                    *path = dir.join(expand_path(path)).to_string_lossy().into_owned();
2377                }
2378            }
2379        }
2380        if let Some(toml::Value::Array(files)) = self.table.get_mut("catalogs") {
2381            for entry in files {
2382                // A path, or a table's `path`.
2383                let path = match entry {
2384                    toml::Value::Table(table) => table.get_mut("path"),
2385                    other => Some(other),
2386                };
2387                if let Some(toml::Value::String(path)) = path
2388                    && !path.trim().is_empty()
2389                    && expand_path(path).is_relative()
2390                {
2391                    *path = dir.join(expand_path(path)).to_string_lossy().into_owned();
2392                }
2393            }
2394        }
2395    }
2396
2397    /// The value this layer writes at the dotted `key`, if it writes one.
2398    pub fn get(&self, key: &str) -> Option<&toml::Value> {
2399        let mut parts = key.split('.');
2400        let mut value = self.table.get(parts.next()?)?;
2401        for part in parts {
2402            value = value.as_table()?.get(part)?;
2403        }
2404        Some(value)
2405    }
2406
2407    /// Lay `upper` over this layer: every key `upper` writes wins, except the
2408    /// combined keys in [`COMBINED_KEYS`], and keys it leaves out keep this layer's
2409    /// value. `upper`'s imports are not carried over.
2410    pub fn merge(&mut self, upper: ConfigLayer) {
2411        merge_tables(&mut self.table, upper.table, "");
2412    }
2413}
2414
2415/// Keys 0.4.0 retired, and where what they said goes now.
2416const RETIRED_KEYS: &[(&str, &str)] = &[
2417    (
2418        "sources",
2419        "a collection is a catalog file now; put its datasets in catalog.toml as [id] \
2420         tables, or list the file in catalogs = [...] (datui catalog check FILE)",
2421    ),
2422    (
2423        "home.directories",
2424        "a directory is a catalog entry now; Ctrl+D on its row adds it to catalog.toml",
2425    ),
2426    (
2427        "home.builtin_catalog",
2428        "home.hide = [\"examples\"] hides the example datasets",
2429    ),
2430];
2431
2432/// The keys `table` writes that the option registry does not know, each with the
2433/// nearest known keys, sorted. A registered key's value is not looked into: a table
2434/// such as `[display.number_format]` or `[[cloud.connections]]` is that key's business.
2435fn unknown_keys_in(table: &toml::Table) -> Vec<String> {
2436    fn walk(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
2437        for (key, value) in table {
2438            let path = if prefix.is_empty() {
2439                key.clone()
2440            } else {
2441                format!("{prefix}.{key}")
2442            };
2443            if datui_cli::settings::find(&path).is_some() {
2444                continue;
2445            }
2446            if let Some((_, moved)) = RETIRED_KEYS.iter().find(|(key, _)| *key == path) {
2447                out.push(format!("{path} is not read any more: {moved}"));
2448                continue;
2449            }
2450            match value {
2451                // A section, known or not: its keys are named one by one, so a renamed
2452                // section's keys each find their new place.
2453                toml::Value::Table(inner) => walk(inner, &path, out),
2454                _ => {
2455                    let near = datui_cli::settings::suggestions(&path);
2456                    let mut said = format!("{path} is not a config key, and is not read");
2457                    if !near.is_empty() {
2458                        said.push_str(&format!("; did you mean {}?", near.join(" or ")));
2459                    }
2460                    out.push(said);
2461                }
2462            }
2463        }
2464    }
2465    let mut out = Vec::new();
2466    walk(table, "", &mut out);
2467    out.sort();
2468    out
2469}
2470
2471/// A TOML error with its reason and place on the first line, then the excerpt of the
2472/// file under it. TOML puts the reason last, but some callers, such as the Python
2473/// binding, show only the first line.
2474fn parse_reason(error: &color_eyre::eyre::Report) -> String {
2475    let Some(toml_error) = error.downcast_ref::<toml::de::Error>() else {
2476        return error.to_string();
2477    };
2478    let message = toml_error.message().trim_end();
2479    let full = toml_error.to_string();
2480    let body = full.trim_end().strip_suffix(message).unwrap_or(&full);
2481    match body.split_once('\n') {
2482        Some((head, excerpt)) if head.starts_with("TOML parse error at ") => format!(
2483            "{message} ({})\n{}",
2484            head.trim_start_matches("TOML parse error at "),
2485            excerpt.trim_end()
2486        ),
2487        _ => message.to_string(),
2488    }
2489}
2490
2491fn merge_tables(lower: &mut toml::Table, upper: toml::Table, prefix: &str) {
2492    for (key, value) in upper {
2493        let path = if prefix.is_empty() {
2494            key.clone()
2495        } else {
2496            format!("{prefix}.{key}")
2497        };
2498        let combine = COMBINED_KEYS
2499            .iter()
2500            .find(|(p, _)| *p == path)
2501            .map(|(_, c)| *c);
2502        match (combine, value) {
2503            (Some(combine), toml::Value::Array(upper)) => {
2504                let mut combined = match lower.remove(&key) {
2505                    Some(toml::Value::Array(lower)) => lower,
2506                    _ => Vec::new(),
2507                };
2508                match combine {
2509                    Combine::ByName => merge_by_name(&mut combined, upper),
2510                    Combine::Union => {
2511                        for item in upper {
2512                            if !combined.contains(&item) {
2513                                combined.push(item);
2514                            }
2515                        }
2516                    }
2517                }
2518                lower.insert(key, toml::Value::Array(combined));
2519            }
2520            (None, toml::Value::Table(upper)) => match lower.get_mut(&key) {
2521                Some(toml::Value::Table(lower)) => merge_tables(lower, upper, &path),
2522                _ => {
2523                    lower.insert(key, toml::Value::Table(upper));
2524                }
2525            },
2526            (_, value) => {
2527                lower.insert(key, value);
2528            }
2529        }
2530    }
2531}
2532
2533fn merge_by_name(lower: &mut Vec<toml::Value>, upper: Vec<toml::Value>) {
2534    let name_of = |entry: &toml::Value| {
2535        entry
2536            .get("name")
2537            .and_then(toml::Value::as_str)
2538            .map(str::to_owned)
2539    };
2540    let mut seen = std::collections::HashSet::new();
2541    for entry in upper {
2542        let name = name_of(&entry);
2543        let first = name.clone().is_some_and(|n| seen.insert(n));
2544        match lower
2545            .iter()
2546            .position(|e| name.is_some() && name_of(e) == name)
2547        {
2548            Some(i) if first => lower[i] = entry,
2549            _ => lower.push(entry),
2550        }
2551    }
2552}
2553
2554// Configuration loading and layering
2555impl AppConfig {
2556    /// Load configuration from all layers (default → imports → user config)
2557    pub fn load(app_name: &str) -> Result<Self> {
2558        Self::load_with(app_name, &[])
2559    }
2560
2561    /// [`Self::load`], with `-c KEY=VALUE` over the files.
2562    pub fn load_with(app_name: &str, overrides: &[datui_cli::settings::Override]) -> Result<Self> {
2563        match ConfigManager::new(app_name) {
2564            Ok(manager) => {
2565                Self::load_from_file_with(&manager.config_path("config.toml"), overrides)
2566            }
2567            // No config directory on this platform: defaults are all there is.
2568            Err(_) => {
2569                let layers = vec![ConfigLayer::from_overrides(overrides)?];
2570                let mut config =
2571                    Self::from_layers(layers).map_err(|e| eyre!("Invalid configuration: {}", e))?;
2572                config.read_theme_files(None)?;
2573                config
2574                    .validate()
2575                    .map_err(|e| eyre!("Invalid configuration: {}", e))?;
2576                Ok(config)
2577            }
2578        }
2579    }
2580
2581    /// Load configuration rooted at `config_path`, resolving its `import` chain.
2582    ///
2583    /// Layers apply lowest precedence first: datui's defaults, then every file named
2584    /// by `import` in declaration order (depth-first, so an imported file's own
2585    /// imports land before it), then `config_path`'s own values. Each layer changes
2586    /// only the keys it writes, so an imported theme restyles datui while anything
2587    /// the user writes, a default value included, still wins.
2588    ///
2589    /// A missing root file means defaults. A root file that exists but cannot be read
2590    /// or parsed is an error naming it: running on defaults would quietly discard every
2591    /// setting in it. A missing import is skipped with a warning — the file is often
2592    /// generated by a theme system that may not have run yet — but an import that
2593    /// exists and cannot be read or parsed is an error too.
2594    pub fn load_from_file(config_path: &Path) -> Result<Self> {
2595        Self::load_from_file_with(config_path, &[])
2596    }
2597
2598    /// [`Self::load_from_file`], with `-c KEY=VALUE` laid over every file.
2599    pub fn load_from_file_with(
2600        config_path: &Path,
2601        overrides: &[datui_cli::settings::Override],
2602    ) -> Result<Self> {
2603        let layers = Self::read_layers(config_path, overrides)?;
2604        Self::from_read_layers(config_path, overrides, &layers)
2605    }
2606
2607    /// Every layer the configuration rooted at `config_path` is built from, lowest
2608    /// precedence first: each import, depth-first, then the file, then `-c`. Each is
2609    /// named by where it came from. A missing root file contributes nothing.
2610    pub fn read_layers(
2611        config_path: &Path,
2612        overrides: &[datui_cli::settings::Override],
2613    ) -> Result<Vec<(LayerSource, ConfigLayer)>> {
2614        let mut layers = Vec::new();
2615        if let Some(root) = ConfigLayer::read(config_path, None)? {
2616            let canonical = crate::canonical::canonicalize(config_path)
2617                .unwrap_or_else(|_| config_path.to_path_buf());
2618            let mut stack = vec![canonical];
2619            Self::collect_imports(&root.imports, config_path, &mut stack, &mut layers)?;
2620            layers.push((LayerSource::File(config_path.to_path_buf()), root));
2621        }
2622        if !overrides.is_empty() {
2623            layers.push((
2624                LayerSource::Override,
2625                ConfigLayer::from_overrides(overrides)?,
2626            ));
2627        }
2628        Ok(layers)
2629    }
2630
2631    /// The configuration `layers`, read by [`Self::read_layers`] for `config_path`,
2632    /// describe: merged over the defaults and validated.
2633    pub fn from_read_layers(
2634        config_path: &Path,
2635        overrides: &[datui_cli::settings::Override],
2636        layers: &[(LayerSource, ConfigLayer)],
2637    ) -> Result<Self> {
2638        // A bad value may be the file's or a `-c`'s.
2639        let place = if overrides.is_empty() {
2640            config_path.display().to_string()
2641        } else {
2642            format!("{} with -c", config_path.display())
2643        };
2644        let imports = layers
2645            .iter()
2646            .find(|(source, _)| *source == LayerSource::File(config_path.to_path_buf()))
2647            .map(|(_, root)| root.imports.clone())
2648            .unwrap_or_default();
2649
2650        let mut config = Self::from_layers(layers.iter().map(|(_, layer)| layer.clone()))
2651            .map_err(|e| eyre!("Invalid configuration in {place}: {e}"))?;
2652        // `import` is a load-time directive, never merged; report what the root declared.
2653        config.import = imports;
2654        config.read_theme_files(config_path.parent())?;
2655        // A catalog's mistake names its own file and line.
2656        config.read_catalog_files(config_path.parent())?;
2657        for broken in &config.broken_catalogs {
2658            eprintln!("datui: warning: catalog left out: {}", broken.full());
2659            log::warn!(target: "datui", "catalog left out: {}", broken.full());
2660        }
2661        // A name that hides nothing is likely a typo, but not worth refusing to start.
2662        for name in config.unknown_hidden() {
2663            eprintln!("datui: warning: home.hide: {}", Self::hides_nothing(&name));
2664        }
2665
2666        config.validate().map_err(|e| {
2667            eyre!(
2668                "Invalid configuration in {place}: {e}\n{}",
2669                way_out(false, "setting")
2670            )
2671        })?;
2672
2673        Ok(config)
2674    }
2675
2676    /// Append every file named by `imports` to `out`, depth-first, in order.
2677    ///
2678    /// `origin` is the file that declared them; relative paths resolve against its
2679    /// directory. `stack` holds the canonical paths currently being loaded, so a
2680    /// cycle is reported instead of followed.
2681    fn collect_imports(
2682        imports: &[String],
2683        origin: &Path,
2684        stack: &mut Vec<PathBuf>,
2685        out: &mut Vec<(LayerSource, ConfigLayer)>,
2686    ) -> Result<()> {
2687        if imports.is_empty() {
2688            return Ok(());
2689        }
2690
2691        if stack.len() >= MAX_IMPORT_DEPTH {
2692            return Err(eyre!(
2693                "config import chain is more than {} files deep (at {}); \
2694                 flatten the chain or remove the extra levels",
2695                MAX_IMPORT_DEPTH,
2696                origin.display()
2697            ));
2698        }
2699
2700        let origin_dir = origin.parent().unwrap_or_else(|| Path::new("."));
2701
2702        for entry in imports {
2703            let expanded = expand_path(entry);
2704            let path = if expanded.is_absolute() {
2705                expanded
2706            } else {
2707                origin_dir.join(expanded)
2708            };
2709
2710            let canonical = crate::canonical::canonicalize(&path).unwrap_or_else(|_| path.clone());
2711            if stack.contains(&canonical) {
2712                return Err(eyre!(
2713                    "circular config import: {} is already being loaded (imported by {})",
2714                    canonical.display(),
2715                    origin.display()
2716                ));
2717            }
2718
2719            let Some(layer) = ConfigLayer::read(&path, Some(origin))? else {
2720                eprintln!(
2721                    "datui: warning: config import not found, skipping: {} (imported by {})",
2722                    path.display(),
2723                    origin.display()
2724                );
2725                continue;
2726            };
2727
2728            stack.push(canonical);
2729            Self::collect_imports(&layer.imports, &path, stack, out)?;
2730            stack.pop();
2731
2732            out.push((LayerSource::File(path), layer));
2733        }
2734
2735        Ok(())
2736    }
2737
2738    /// The configuration `layers` describe, lowest precedence first, over datui's
2739    /// defaults. Defaults are resolved here, once: a layer holds only what it wrote.
2740    ///
2741    /// The colors start from the theme `theme.dark` or `theme.light` names for the
2742    /// `theme.mode` the layers declare, built-ins only: `from_read_layers` adds the
2743    /// theme files. `import` is left empty; `load_from_file` follows imports and
2744    /// reports them. Not validated.
2745    pub fn from_layers(layers: impl IntoIterator<Item = ConfigLayer>) -> Result<Self> {
2746        let mut merged = ConfigLayer::default();
2747        for layer in layers {
2748            merged.merge(layer);
2749        }
2750        let mut table = merged.table;
2751
2752        let theme = table.get_mut("theme").and_then(toml::Value::as_table_mut);
2753        let mode: ThemeMode = match theme.as_ref().and_then(|t| t.get("mode")) {
2754            Some(mode) => mode.clone().try_into()?,
2755            None => ThemeMode::default(),
2756        };
2757        let resolved = mode.resolve();
2758        let colors = theme.and_then(|t| t.remove("colors"));
2759
2760        let mut config: AppConfig = toml::Value::Table(table).try_into()?;
2761
2762        if let Some(toml::Value::Table(colors)) = colors {
2763            config.theme.overrides = colors;
2764        }
2765        config.theme.follow = mode == ThemeMode::Auto;
2766        config.theme.mode = Some(resolved);
2767        // The built-ins only; `from_read_layers` reads the theme files and resolves again.
2768        config
2769            .theme
2770            .use_library(crate::themes::Library::default(), resolved);
2771        config.theme.colors = config.theme.palette_for(resolved)?;
2772        config.sync_dataset_access();
2773        Ok(config)
2774    }
2775
2776    /// Resolve `theme.dark` and `theme.light` with the theme files in `config_dir`'s
2777    /// `themes/` as well as the built-ins. A file with a mistake, or a name that
2778    /// cannot be used, is said on stderr and the log, and its mode falls back to
2779    /// the built-in: as with catalogs, it never stops datui from starting.
2780    pub fn read_theme_files(&mut self, config_dir: Option<&Path>) -> Result<()> {
2781        let library = crate::themes::Library::read(config_dir);
2782        let active = self.theme.mode.unwrap_or_default().resolve();
2783        self.theme.use_library(library, active);
2784        for warning in self.theme.warnings() {
2785            eprintln!("datui: {warning}");
2786            log::warn!(target: "datui", "{warning}");
2787        }
2788        self.theme.colors = self.theme.palette_for(active)?;
2789        Ok(())
2790    }
2791
2792    /// Every catalog, hidden ones included: `catalog.toml`, the listed files in order,
2793    /// then the bundled `examples` catalog, unless a listed file named `examples.toml`
2794    /// replaces it.
2795    pub fn catalogs(&self) -> Vec<crate::catalog::Catalog> {
2796        let mut all = self.read_catalogs.clone();
2797        if !all.iter().any(|c| c.id == crate::catalog::EXAMPLES) {
2798            all.push(crate::catalog::bundled());
2799        }
2800        all
2801    }
2802
2803    /// The catalogs the home screen shows: [`Self::catalogs`] less `[home] hide`, which
2804    /// names a whole catalog by its id or one entry as `catalog/id`.
2805    pub fn shown_catalogs(&self) -> Vec<crate::catalog::Catalog> {
2806        self.catalogs()
2807            .into_iter()
2808            .filter(|c| !self.home.hide.contains(&c.id))
2809            .map(|mut c| {
2810                c.datasets.retain(|d| {
2811                    !self
2812                        .home
2813                        .hide
2814                        .iter()
2815                        .any(|h| h.split_once('/') == Some((c.id.as_str(), d.id.as_str())))
2816                });
2817                c
2818            })
2819            .collect()
2820    }
2821
2822    /// The `[home] hide` names no catalog or entry has, each once.
2823    /// Why `name` in `home.hide` hides nothing, with the fix when the name is the
2824    /// bundled catalog's old id: `public` is now `examples`.
2825    pub fn hides_nothing(name: &str) -> String {
2826        let old = crate::catalog::OLD_EXAMPLES_ID;
2827        let renamed = match name.split_once('/') {
2828            None if name == old => Some(crate::catalog::EXAMPLES.to_string()),
2829            Some((catalog, id)) if catalog == old => {
2830                Some(format!("{}/{id}", crate::catalog::EXAMPLES))
2831            }
2832            _ => None,
2833        };
2834        match renamed {
2835            Some(new) => format!("`{name}` is now `{new}`: hide = [\"{new}\"]"),
2836            None => format!("no catalog or entry is named {name}"),
2837        }
2838    }
2839
2840    pub fn unknown_hidden(&self) -> Vec<String> {
2841        let catalogs = self.catalogs();
2842        let mut out: Vec<String> = Vec::new();
2843        for name in &self.home.hide {
2844            let known = match name.split_once('/') {
2845                None => {
2846                    catalogs.iter().any(|c| c.id == *name)
2847                        || self.broken_catalogs.iter().any(|b| b.id == *name)
2848                }
2849                Some((catalog, _)) if self.broken_catalogs.iter().any(|b| b.id == catalog) => true,
2850                Some((catalog, id)) => catalogs
2851                    .iter()
2852                    .any(|c| c.id == catalog && c.datasets.iter().any(|d| d.id == id)),
2853            };
2854            if !known && !out.contains(name) {
2855                out.push(name.clone());
2856            }
2857        }
2858        out
2859    }
2860
2861    /// Read `catalog.toml` from `config_dir`, when there is one, every `*.toml` in its
2862    /// `catalogs/` directory, by name, and every file `catalogs` lists. A listed file
2863    /// that is not there is skipped with a warning, as a missing import is: it may be on
2864    /// a share that is not mounted.
2865    pub fn read_catalog_files(&mut self, config_dir: Option<&Path>) -> Result<()> {
2866        use crate::catalog::{self, Origin};
2867        let mut read: Vec<catalog::Catalog> = Vec::new();
2868        let mut broken: Vec<catalog::Broken> = Vec::new();
2869        let connections = self.cloud.connections.clone();
2870        // A file with a mistake is left out and said: one broken team file must not keep
2871        // datui from starting.
2872        let take = |found: std::result::Result<Option<catalog::Catalog>, catalog::Broken>,
2873                    read: &mut Vec<catalog::Catalog>,
2874                    broken: &mut Vec<catalog::Broken>|
2875         -> bool {
2876            match found {
2877                Ok(Some(c)) => match c.check_connections(&connections) {
2878                    Ok(()) => {
2879                        read.push(c);
2880                        true
2881                    }
2882                    Err(e) => {
2883                        broken.push(catalog::Broken {
2884                            id: c.id.clone(),
2885                            origin: c.origin,
2886                            file: c.file.clone().unwrap_or_default(),
2887                            line: e.line,
2888                            message: e.message,
2889                        });
2890                        true
2891                    }
2892                },
2893                Ok(None) => false,
2894                Err(b) => {
2895                    broken.push(b);
2896                    true
2897                }
2898            }
2899        };
2900        // Each file, where it was found, and the id and label a `catalogs` table gives it.
2901        let mut files: Vec<(PathBuf, Origin, Option<String>, Option<String>)> = Vec::new();
2902        if let Some(dir) = config_dir {
2903            take(
2904                catalog::load(&dir.join(catalog::MINE_FILE), catalog::MINE, Origin::Mine),
2905                &mut read,
2906                &mut broken,
2907            );
2908            let folder = dir.join(catalog::FOLDER);
2909            let mut found: Vec<PathBuf> = match std::fs::read_dir(&folder) {
2910                Ok(entries) => entries
2911                    .filter_map(|e| e.ok().map(|e| e.path()))
2912                    .filter(|p| {
2913                        p.extension().is_some_and(|x| x == "toml")
2914                            && std::fs::metadata(p).is_ok_and(|m| m.is_file())
2915                    })
2916                    .collect(),
2917                Err(_) => Vec::new(),
2918            };
2919            found.sort();
2920            files.extend(found.into_iter().map(|p| (p, Origin::Folder, None, None)));
2921        }
2922        files.extend(self.catalogs.iter().map(|entry| {
2923            (
2924                expand_path(entry.path()),
2925                Origin::Listed,
2926                entry.id().map(str::to_string),
2927                entry.label().map(str::to_string),
2928            )
2929        }));
2930        for (path, origin, given_id, label) in files {
2931            let id = given_id
2932                .clone()
2933                .unwrap_or_else(|| catalog::id_of_file(&path));
2934            let refuse = |message: String| catalog::Broken {
2935                id: id.clone(),
2936                origin,
2937                file: path.clone(),
2938                line: None,
2939                message,
2940            };
2941            if !is_valid_source_id(&id) || id == catalog::MINE {
2942                broken.push(refuse(format!(
2943                    "\"{id}\" cannot be a catalog's id: lowercase letters, digits and '-', \
2944                     and not \"{}\", which is catalog.toml's. {}",
2945                    catalog::MINE,
2946                    if given_id.is_some() {
2947                        "Give another id = \"...\""
2948                    } else {
2949                        "Rename the file, or list it as { path = \"...\", id = \"...\" }"
2950                    }
2951                )));
2952                continue;
2953            }
2954            if let Some(first) = read.iter().find(|c| c.id == id) {
2955                let first = first.file_name();
2956                broken.push(refuse(format!(
2957                    "{first} and this file are both the catalog \"{id}\". Rename one, or \
2958                     list one as {{ path = \"...\", id = \"...\" }}"
2959                )));
2960                continue;
2961            }
2962            let found = catalog::load(&path, &id, origin).map(|found| {
2963                found.map(|mut listed| {
2964                    if let Some(label) = &label {
2965                        listed.label = label.clone();
2966                    }
2967                    listed
2968                })
2969            });
2970            if !take(found, &mut read, &mut broken) {
2971                eprintln!(
2972                    "datui: warning: catalog not found, skipping: {}",
2973                    path.display()
2974                );
2975            }
2976        }
2977        self.read_catalogs = read;
2978        self.broken_catalogs = broken;
2979        self.catalog_dir = config_dir.map(Path::to_path_buf);
2980        self.sync_dataset_access();
2981        Ok(())
2982    }
2983
2984    /// Derive `[cloud]`'s view of how catalog URLs are read. Called by `from_layers`,
2985    /// `read_catalog_files` and `default`; call it after changing the catalogs by hand.
2986    pub fn sync_dataset_access(&mut self) {
2987        self.cloud.dataset_access = self
2988            .catalogs()
2989            .iter()
2990            .flat_map(|catalog| {
2991                catalog.datasets.iter().filter_map(|dataset| {
2992                    Some(DatasetAccess {
2993                        url: dataset.url.clone()?,
2994                        catalog: catalog.id.clone(),
2995                        auth: dataset.object_store_auth()?,
2996                    })
2997                })
2998            })
2999            .collect();
3000    }
3001
3002    /// Validate configuration values
3003    pub fn validate(&self) -> Result<()> {
3004        let rows = self.analysis.chart_rows;
3005        if rows == 0 || rows > MAX_CHART_ROW_LIMIT {
3006            return Err(eyre!(
3007                "analysis.chart_rows must be between 1 and {MAX_CHART_ROW_LIMIT}, got {rows}"
3008            ));
3009        }
3010
3011        // Resolve number formatting so bad preset names and separator clashes
3012        // are reported at load time rather than silently ignored at render time.
3013        self.display
3014            .number_format
3015            .resolve(self.display.right_align_numbers)?;
3016
3017        let interval = self.read.follow_interval;
3018        if !FOLLOW_INTERVAL.contains(&interval.duration()) {
3019            return Err(eyre!(
3020                "read.follow_interval must be between 10ms and 1m, got {interval}"
3021            ));
3022        }
3023
3024        if let Some(c) = &self.csv.comment {
3025            crate::csv_dialect::check_comment_char(c).map_err(|e| eyre!("csv.comment: {e}"))?;
3026        }
3027
3028        if let Some(level) = &self.log.level
3029            && !datui_cli::LOG_LEVELS.contains(&level.as_str())
3030        {
3031            return Err(eyre!(
3032                "log.level must be one of {}, got {level:?}",
3033                datui_cli::LOG_LEVELS.join(", ")
3034            ));
3035        }
3036
3037        self.cloud.validate()?;
3038        for catalog in &self.read_catalogs {
3039            catalog
3040                .check_connections(&self.cloud.connections)
3041                .map_err(|e| eyre!("{}", e.in_file(&catalog.file_name())))?;
3042        }
3043        let hide_name = |name: &str| match name.split_once('/') {
3044            Some((catalog, id)) => is_valid_source_id(catalog) && is_valid_source_id(id),
3045            None => is_valid_source_id(name),
3046        };
3047        if let Some(name) = self.home.hide.iter().find(|name| !hide_name(name)) {
3048            return Err(eyre!(
3049                "home.hide: \"{name}\" is not a catalog id or catalog/id. Use the ids (mine, \
3050                 examples, a listed file's name; examples/nyc-taxis for one entry), not the labels"
3051            ));
3052        }
3053
3054        // Validate all colors can be parsed
3055        let parser = ColorParser::new();
3056        self.theme.colors.validate(&parser)?;
3057
3058        crate::glyphs::validate_overrides(&self.glyphs.overrides)
3059            .map_err(|e| eyre!("[glyphs]: {e}"))?;
3060
3061        if crate::clipboard::BackendChoice::parse(&self.clipboard.backend).is_none() {
3062            return Err(eyre!(
3063                "[clipboard] backend must be auto, native or osc52, got {:?}",
3064                self.clipboard.backend
3065            ));
3066        }
3067        if self.clipboard.osc52_limit.bytes() == 0 {
3068            return Err(eyre!("[clipboard] osc52_limit must be greater than 0"));
3069        }
3070        if !crate::user_agent::is_valid(&self.http.user_agent) {
3071            return Err(eyre!(
3072                "[http] user_agent must be printable ASCII, got {:?}",
3073                self.http.user_agent
3074            ));
3075        }
3076
3077        Ok(())
3078    }
3079}
3080
3081impl ColorConfig {
3082    /// Every slot by name, as the config writes it.
3083    fn slots(&self) -> Vec<(String, String)> {
3084        match toml::Value::try_from(self) {
3085            Ok(toml::Value::Table(table)) => table
3086                .into_iter()
3087                .map(|(name, value)| (name, value.as_str().unwrap_or_default().to_string()))
3088                .collect(),
3089            _ => Vec::new(),
3090        }
3091    }
3092
3093    /// Validate all color strings can be parsed
3094    fn validate(&self, parser: &ColorParser) -> Result<()> {
3095        for (name, value) in self.slots() {
3096            // "default" is no stripe, not a color.
3097            if name == "table_alternate_row" && value == "default" {
3098                continue;
3099            }
3100            parser.parse(&value).map_err(|e| {
3101                eyre!(
3102                    "theme.colors.{name}: {e}. Use a valid color name (e.g. red, cyan, \
3103                     bright_red), hex (#rrggbb), or indexed(0-255)"
3104                )
3105            })?;
3106        }
3107        Ok(())
3108    }
3109}
3110
3111/// Color parser with terminal capability detection
3112pub struct ColorParser {
3113    supports_true_color: bool,
3114    supports_256: bool,
3115    no_color: bool,
3116}
3117
3118impl ColorParser {
3119    /// Create a new ColorParser with automatic terminal capability detection
3120    pub fn new() -> Self {
3121        let no_color = std::env::var("NO_COLOR").is_ok();
3122        let support = supports_color::on(Stream::Stdout);
3123        #[cfg(windows)]
3124        let console = windows_console_true_color(
3125            // `FORCE_COLOR` names a level for `supports_color` to answer with.
3126            std::env::var_os("TERM").is_some() || std::env::var_os("FORCE_COLOR").is_some(),
3127            std::io::IsTerminal::is_terminal(&std::io::stdout()),
3128            crossterm::ansi_support::supports_ansi,
3129        );
3130        #[cfg(not(windows))]
3131        let console = false;
3132
3133        Self {
3134            supports_true_color: console || support.as_ref().is_some_and(|s| s.has_16m),
3135            supports_256: console || support.as_ref().is_some_and(|s| s.has_256),
3136            no_color,
3137        }
3138    }
3139
3140    /// Parse a color string (hex or named) and convert to appropriate terminal color
3141    pub fn parse(&self, s: &str) -> Result<Color> {
3142        if self.no_color {
3143            return Ok(Color::Reset);
3144        }
3145
3146        let trimmed = s.trim();
3147
3148        // Hex format: "#ff0000" or "#FF0000" (6-character hex)
3149        if trimmed.starts_with('#') && trimmed.len() == 7 {
3150            let (r, g, b) = parse_hex(trimmed)?;
3151            return Ok(self.convert_rgb_to_terminal_color(r, g, b));
3152        }
3153
3154        // Indexed colors: "indexed(236)" for explicit 256-color palette
3155        if trimmed.to_lowercase().starts_with("indexed(") && trimmed.ends_with(')') {
3156            let num_str = &trimmed[8..trimmed.len() - 1]; // Extract number between parentheses
3157            let num = num_str.parse::<u8>().map_err(|_| {
3158                eyre!(
3159                    "Invalid indexed color: '{}'. Expected format: indexed(0-255)",
3160                    trimmed
3161                )
3162            })?;
3163            return Ok(Color::Indexed(num));
3164        }
3165
3166        // Named colors (case-insensitive)
3167        let lower = trimmed.to_lowercase();
3168        match lower.as_str() {
3169            // Basic ANSI colors
3170            "black" => Ok(Color::Black),
3171            "red" => Ok(Color::Red),
3172            "green" => Ok(Color::Green),
3173            "yellow" => Ok(Color::Yellow),
3174            "blue" => Ok(Color::Blue),
3175            "magenta" => Ok(Color::Magenta),
3176            "cyan" => Ok(Color::Cyan),
3177            "white" => Ok(Color::White),
3178
3179            // Bright variants (256-color palette)
3180            "bright_black" | "bright black" => Ok(Color::Indexed(8)),
3181            "bright_red" | "bright red" => Ok(Color::Indexed(9)),
3182            "bright_green" | "bright green" => Ok(Color::Indexed(10)),
3183            "bright_yellow" | "bright yellow" => Ok(Color::Indexed(11)),
3184            "bright_blue" | "bright blue" => Ok(Color::Indexed(12)),
3185            "bright_magenta" | "bright magenta" => Ok(Color::Indexed(13)),
3186            "bright_cyan" | "bright cyan" => Ok(Color::Indexed(14)),
3187            "bright_white" | "bright white" => Ok(Color::Indexed(15)),
3188
3189            // Gray aliases
3190            "gray" | "grey" => Ok(Color::Indexed(8)),
3191            "dark_gray" | "dark gray" | "dark_grey" | "dark grey" => Ok(Color::Indexed(8)),
3192            "light_gray" | "light gray" | "light_grey" | "light grey" => Ok(Color::Indexed(7)),
3193
3194            // Special modifiers (pass through as Reset - handled specially in rendering)
3195            "reset" | "default" | "none" | "reversed" => Ok(Color::Reset),
3196
3197            _ => Err(eyre!(
3198                "Unknown color name: '{}'. Supported: basic ANSI colors (red, blue, etc.), \
3199                 bright variants (bright_red, etc.), or hex colors (#ff0000)",
3200                trimmed
3201            )),
3202        }
3203    }
3204
3205    /// Convert RGB values to appropriate terminal color based on capabilities
3206    fn convert_rgb_to_terminal_color(&self, r: u8, g: u8, b: u8) -> Color {
3207        if self.supports_true_color {
3208            Color::Rgb(r, g, b)
3209        } else if self.supports_256 {
3210            Color::Indexed(rgb_to_256_color(r, g, b))
3211        } else {
3212            rgb_to_basic_ansi(r, g, b)
3213        }
3214    }
3215}
3216
3217/// Whether a Windows console draws 24-bit color, where `supports_color` cannot tell.
3218/// It reads `TERM` and `COLORTERM`, which Windows Terminal and conhost do not set, and
3219/// so takes both for a 16-color terminal. Both draw 24-bit color once virtual
3220/// terminal processing is on, which crossterm turns on where it can (`vt`); a legacy
3221/// console refuses it and keeps the 16 colors. With `TERM` set (mintty, an MSYS2
3222/// shell), or `FORCE_COLOR`, its answer stands.
3223#[cfg(windows)]
3224fn windows_console_true_color(env_says: bool, terminal: bool, vt: impl FnOnce() -> bool) -> bool {
3225    !env_says && terminal && vt()
3226}
3227
3228impl Default for ColorParser {
3229    fn default() -> Self {
3230        Self::new()
3231    }
3232}
3233
3234/// Parse hex color string (#ff0000) to RGB components
3235fn parse_hex(s: &str) -> Result<(u8, u8, u8)> {
3236    // `len()` counts bytes, so a seven-byte length is not seven characters and the
3237    // fixed offsets below are only safe once the rest is known to be ASCII. "#\u{1f600}xy"
3238    // is also seven bytes, and slicing it at 3 lands inside the emoji.
3239    let hex = s
3240        .strip_prefix('#')
3241        .filter(|hex| hex.len() == 6 && hex.is_ascii())
3242        .ok_or_else(|| {
3243            eyre!(
3244                "Invalid hex color format: '{}'. Expected format: #rrggbb",
3245                s
3246            )
3247        })?;
3248
3249    let r = u8::from_str_radix(&hex[0..2], 16)
3250        .map_err(|_| eyre!("Invalid red component in hex color: {}", s))?;
3251    let g = u8::from_str_radix(&hex[2..4], 16)
3252        .map_err(|_| eyre!("Invalid green component in hex color: {}", s))?;
3253    let b = u8::from_str_radix(&hex[4..6], 16)
3254        .map_err(|_| eyre!("Invalid blue component in hex color: {}", s))?;
3255
3256    Ok((r, g, b))
3257}
3258
3259/// Convert RGB to nearest 256-color palette index
3260/// Uses standard xterm 256-color palette
3261pub fn rgb_to_256_color(r: u8, g: u8, b: u8) -> u8 {
3262    // The nearest entry of the xterm palette, by distance in RGB. The cube's six
3263    // levels are far apart (0, 95, 135, 175, 215, 255), so a dark tint like #262a3f
3264    // is nearer a grey on the ramp than any cube colour; rounding each channel to
3265    // a cube level instead sent every dark tint to the same navy or black.
3266    let dist = |cr: i32, cg: i32, cb: i32| -> i32 {
3267        let (dr, dg, db) = (cr - r as i32, cg - g as i32, cb - b as i32);
3268        dr * dr + dg * dg + db * db
3269    };
3270    const LEVELS: [i32; 6] = [0, 95, 135, 175, 215, 255];
3271    let mut best = (i32::MAX, 16u8);
3272    for (ri, &cr) in LEVELS.iter().enumerate() {
3273        for (gi, &cg) in LEVELS.iter().enumerate() {
3274            for (bi, &cb) in LEVELS.iter().enumerate() {
3275                let d = dist(cr, cg, cb);
3276                if d < best.0 {
3277                    best = (d, 16 + 36 * ri as u8 + 6 * gi as u8 + bi as u8);
3278                }
3279            }
3280        }
3281    }
3282    for i in 0..24u8 {
3283        let v = 8 + 10 * i as i32;
3284        let d = dist(v, v, v);
3285        if d < best.0 {
3286            best = (d, 232 + i);
3287        }
3288    }
3289    best.1
3290}
3291
3292/// Convert RGB to nearest basic ANSI color (8 colors)
3293pub fn rgb_to_basic_ansi(r: u8, g: u8, b: u8) -> Color {
3294    // Simple threshold-based conversion
3295    let r_bright = r > 128;
3296    let g_bright = g > 128;
3297    let b_bright = b > 128;
3298
3299    // Check for grayscale
3300    let max_diff = r.max(g).max(b) as i16 - r.min(g).min(b) as i16;
3301    if max_diff < 30 {
3302        let avg = (r as u16 + g as u16 + b as u16) / 3;
3303        return if avg < 64 { Color::Black } else { Color::White };
3304    }
3305
3306    // Map to primary/secondary colors
3307    match (r_bright, g_bright, b_bright) {
3308        (false, false, false) => Color::Black,
3309        (true, false, false) => Color::Red,
3310        (false, true, false) => Color::Green,
3311        (true, true, false) => Color::Yellow,
3312        (false, false, true) => Color::Blue,
3313        (true, false, true) => Color::Magenta,
3314        (false, true, true) => Color::Cyan,
3315        (true, true, true) => Color::White,
3316    }
3317}
3318
3319/// Theme containing parsed colors ready for use
3320#[derive(Debug, Clone)]
3321pub struct Theme {
3322    pub colors: HashMap<String, Color>,
3323}
3324
3325/// The theme's chart series slots, `chart_1` to `chart_10`.
3326pub const CHART_SERIES_SLOTS: usize = 10;
3327
3328impl Theme {
3329    /// Create a Theme from a ThemeConfig by parsing all color strings
3330    pub fn from_config(config: &ThemeConfig) -> Result<Self> {
3331        let parser = ColorParser::new();
3332        let mut colors = HashMap::new();
3333        for (name, value) in config.colors.slots() {
3334            // Left out of the map so a widget can ask `get_optional` and tell them apart:
3335            // "reversed" swaps the current row's text and background instead of tinting
3336            // it, and "default" is no stripe.
3337            let absent = match name.as_str() {
3338                "table_selected" => value.trim().eq_ignore_ascii_case("reversed"),
3339                "table_alternate_row" => value == "default",
3340                _ => false,
3341            };
3342            if !absent {
3343                colors.insert(name, parser.parse(&value)?);
3344            }
3345        }
3346        // Every sidebar and the input strip draw their resting border from
3347        // `modal_border`, the slot the config calls `sidebar_border`; labels are
3348        // secondary text.
3349        colors.insert("modal_border".to_string(), colors["sidebar_border"]);
3350        colors.insert("label".to_string(), colors["text_secondary"]);
3351        Ok(Self { colors })
3352    }
3353
3354    /// Get a color by name, returns Reset if not found
3355    pub fn get(&self, name: &str) -> Color {
3356        self.colors.get(name).copied().unwrap_or(Color::Reset)
3357    }
3358
3359    /// Get a color by name, returns None if not found
3360    pub fn get_optional(&self, name: &str) -> Option<Color> {
3361        self.colors.get(name).copied()
3362    }
3363
3364    /// The colors chart series are drawn in: `chart_1` to `chart_10` as this terminal
3365    /// shows them, each once. Slots that come out the same (a theme that repeats a
3366    /// color, a 16-color terminal, `NO_COLOR`) are one color, so two series never
3367    /// share one: a chart draws at most this many.
3368    pub fn series_colors(&self) -> Vec<Color> {
3369        let mut colors: Vec<Color> = Vec::with_capacity(CHART_SERIES_SLOTS);
3370        for i in 1..=CHART_SERIES_SLOTS {
3371            let color = self.get(&format!("chart_{i}"));
3372            if !colors.contains(&color) {
3373                colors.push(color);
3374            }
3375        }
3376        colors
3377    }
3378
3379    /// Style of the row or item the cursor is on: the theme's tint, or reversed video
3380    /// when `table_selected = "reversed"`.
3381    pub fn highlight_style(&self) -> ratatui::style::Style {
3382        match self.get_optional("table_selected") {
3383            Some(bg) => ratatui::style::Style::default().bg(bg),
3384            None => {
3385                ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
3386            }
3387        }
3388    }
3389
3390    /// Style of the column cursor's cells; see [`column_cursor_style`].
3391    pub fn column_cursor_style(&self) -> ratatui::style::Style {
3392        column_cursor_style(self.get_optional("table_column_cursor"))
3393    }
3394
3395    /// Style of the column cursor's header and the current cell; see
3396    /// [`cell_cursor_style`].
3397    pub fn cell_cursor_style(&self) -> ratatui::style::Style {
3398        cell_cursor_style(self.get_optional("table_cell_cursor"))
3399    }
3400
3401    /// Style of selected text in a field: the highlight tint, or reversed video
3402    /// where the tint could match the terminal's own background. A 16-color
3403    /// terminal turns the default tints into black or white and `NO_COLOR` into
3404    /// none, and a field has no rail to show the selection instead.
3405    pub fn text_selection_style(&self) -> ratatui::style::Style {
3406        match self.get_optional("table_selected") {
3407            Some(Color::Reset | Color::Black | Color::White) => {
3408                ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
3409            }
3410            _ => self.highlight_style(),
3411        }
3412    }
3413
3414    /// The cell a find landed on: the `find_match` tint under black or white text,
3415    /// whichever reads on it, or reversed bold video where there is no color.
3416    pub fn find_match_style(&self) -> ratatui::style::Style {
3417        use ratatui::style::{Modifier, Style};
3418        match self.get("find_match") {
3419            Color::Reset => Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
3420            bg => Style::default().bg(bg).fg(contrasting_text(bg)),
3421        }
3422    }
3423
3424    /// Text color for the solid cursor block: the `cursor_text` slot, or black or
3425    /// white by the cursor color's luminance when the slot says "default". Lives
3426    /// here so widgets never pick colors themselves.
3427    pub fn cursor_text_for(&self, cursor: Color) -> Color {
3428        match self.get("input_cursor_text") {
3429            Color::Reset => contrasting_text(cursor),
3430            configured => configured,
3431        }
3432    }
3433}
3434
3435/// Whether a tint can be told from the terminal's own background: a 16-color terminal
3436/// turns the default tints into black or white, and `NO_COLOR` into none.
3437pub fn tint_shows(tint: Option<Color>) -> Option<Color> {
3438    tint.filter(|c| !matches!(c, Color::Reset | Color::Black | Color::White))
3439}
3440
3441/// Style of the column cursor's cells: the `column_cursor` tint, or nothing where the
3442/// tint would not show; the header and the current cell still mark the column there.
3443pub fn column_cursor_style(tint: Option<Color>) -> ratatui::style::Style {
3444    match tint_shows(tint) {
3445        Some(bg) => ratatui::style::Style::default().bg(bg),
3446        None => ratatui::style::Style::default(),
3447    }
3448}
3449
3450/// Style of the column cursor's header and of the current cell: the `cell_cursor`
3451/// tint in bold, or reversed video where the tint would not show, so the cell is
3452/// marked on any terminal.
3453pub fn cell_cursor_style(tint: Option<Color>) -> ratatui::style::Style {
3454    use ratatui::style::{Modifier, Style};
3455    match tint_shows(tint) {
3456        Some(bg) => Style::default().bg(bg).add_modifier(Modifier::BOLD),
3457        None => Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
3458    }
3459}
3460
3461/// Black or white, whichever reads on a solid block of `bg` (Rec. 601 luma).
3462fn contrasting_text(bg: Color) -> Color {
3463    let (r, g, b) = approx_rgb(bg);
3464    let luma = 299 * r as u32 + 587 * g as u32 + 114 * b as u32;
3465    if luma >= 128_000 {
3466        Color::Black
3467    } else {
3468        Color::White
3469    }
3470}
3471
3472/// A representative RGB for any terminal color, for luminance arithmetic. The
3473/// named colors use the xterm defaults; the real palette is the terminal's, so
3474/// this is an estimate — good enough to pick black or white.
3475fn approx_rgb(color: Color) -> (u8, u8, u8) {
3476    match color {
3477        Color::Rgb(r, g, b) => (r, g, b),
3478        Color::Indexed(i) => xterm_rgb(i),
3479        Color::Black => (0, 0, 0),
3480        Color::Red => (205, 0, 0),
3481        Color::Green => (0, 205, 0),
3482        Color::Yellow => (205, 205, 0),
3483        Color::Blue => (0, 0, 238),
3484        Color::Magenta => (205, 0, 205),
3485        Color::Cyan => (0, 205, 205),
3486        Color::Gray => (229, 229, 229),
3487        Color::DarkGray => (127, 127, 127),
3488        Color::LightRed => (255, 0, 0),
3489        Color::LightGreen => (0, 255, 0),
3490        Color::LightYellow => (255, 255, 0),
3491        Color::LightBlue => (92, 92, 255),
3492        Color::LightMagenta => (255, 0, 255),
3493        Color::LightCyan => (0, 255, 255),
3494        Color::White => (255, 255, 255),
3495        Color::Reset => (0, 0, 0),
3496    }
3497}
3498
3499/// The standard xterm 256-color palette entry, as RGB.
3500fn xterm_rgb(i: u8) -> (u8, u8, u8) {
3501    match i {
3502        0..=15 => approx_rgb(match i {
3503            0 => Color::Black,
3504            1 => Color::Red,
3505            2 => Color::Green,
3506            3 => Color::Yellow,
3507            4 => Color::Blue,
3508            5 => Color::Magenta,
3509            6 => Color::Cyan,
3510            7 => Color::Gray,
3511            8 => Color::DarkGray,
3512            9 => Color::LightRed,
3513            10 => Color::LightGreen,
3514            11 => Color::LightYellow,
3515            12 => Color::LightBlue,
3516            13 => Color::LightMagenta,
3517            14 => Color::LightCyan,
3518            _ => Color::White,
3519        }),
3520        16..=231 => {
3521            let level = |n: u8| if n == 0 { 0 } else { 55 + 40 * n };
3522            let c = i - 16;
3523            (level(c / 36), level(c / 6 % 6), level(c % 6))
3524        }
3525        232..=255 => {
3526            let v = 8 + 10 * (i - 232);
3527            (v, v, v)
3528        }
3529    }
3530}
3531
3532/// `text` in lines of at most `width` characters, broken between words.
3533fn wrap(text: &str, width: usize) -> Vec<String> {
3534    let mut lines: Vec<String> = Vec::new();
3535    for word in text.split_whitespace() {
3536        match lines.last_mut() {
3537            Some(line) if line.len() + 1 + word.len() <= width => {
3538                line.push(' ');
3539                line.push_str(word);
3540            }
3541            _ => lines.push(word.to_string()),
3542        }
3543    }
3544    lines
3545}
3546
3547#[cfg(test)]
3548mod tests {
3549    use std::path::{Path, PathBuf};
3550
3551    /// A path from the command line has been through the shell: only a leading `~`
3552    /// is left for datui to expand.
3553    #[test]
3554    fn a_command_line_path_expands_only_a_leading_tilde() {
3555        let home = dirs::home_dir().expect("a home directory");
3556        let expand = |p: &str| super::expand_home(Path::new(p));
3557        assert_eq!(expand("~"), home);
3558        assert_eq!(expand("~/data/a.csv"), home.join("data/a.csv"));
3559        for kept in [
3560            "a/~/b.csv",
3561            "~user/a.csv",
3562            "$HOME/a.csv",
3563            "-",
3564            "s3://b/~/a.csv",
3565        ] {
3566            assert_eq!(expand(kept), PathBuf::from(kept), "{kept}");
3567        }
3568        #[cfg(windows)]
3569        assert_eq!(expand(r"~\data\a.csv"), home.join(r"data\a.csv"));
3570        // A backslash is part of a name off Windows.
3571        #[cfg(not(windows))]
3572        assert_eq!(expand(r"~\a.csv"), PathBuf::from(r"~\a.csv"));
3573        // A file named `~`, or under a directory named `~`, is that file.
3574        for there in ["~", "~/a.csv"] {
3575            let kept = super::expand_home_unless(Path::new(there), |_| true);
3576            assert_eq!(kept, PathBuf::from(there), "{there}");
3577        }
3578    }
3579
3580    /// Windows Terminal and conhost set no `TERM`; with virtual terminal processing
3581    /// on, they take 24-bit color. A legacy console, or a terminal that sets `TERM`
3582    /// for `supports_color` to read, is left to it.
3583    #[cfg(windows)]
3584    #[test]
3585    fn a_windows_console_with_vt_takes_true_color() {
3586        use super::windows_console_true_color as rule;
3587        assert!(rule(false, true, || true));
3588        assert!(!rule(false, true, || false), "a legacy console");
3589        assert!(
3590            !rule(true, true, || true),
3591            "TERM or FORCE_COLOR set: supports_color decides"
3592        );
3593        assert!(!rule(false, false, || true), "not a terminal");
3594    }
3595
3596    #[test]
3597    fn a_path_place_ignores_spelling_but_not_meaning() {
3598        let place = |p: &str| super::path_place(std::path::Path::new(p));
3599        for (a, b) in [
3600            ("/d/a.csv", "/d//a.csv"),
3601            ("/d/a.csv", "/d/./a.csv"),
3602            ("/d/sub", "/d/sub/"),
3603            ("a.csv", "./a.csv"),
3604        ] {
3605            assert_eq!(place(a), place(b), "{a} and {b}");
3606        }
3607        for (a, b) in [
3608            ("/d/../a.csv", "/a.csv"),
3609            ("/d/a.csv", "/d/A.csv"),
3610            ("/d/a.csv", "d/a.csv"),
3611            ("/d/a.csv", "/d/a.csv.gz"),
3612        ] {
3613            assert_ne!(place(a), place(b), "{a} and {b}");
3614        }
3615        #[cfg(windows)]
3616        {
3617            for (a, b) in [
3618                (r"C:\d\a.csv", r"c:\d\a.csv"),
3619                (r"C:\d\a.csv", "C:/d/a.csv"),
3620                (r"\\srv\share\a.csv", "//srv/share/a.csv"),
3621            ] {
3622                assert_eq!(place(a), place(b), "{a} and {b}");
3623            }
3624            for (a, b) in [
3625                (r"C:\d\a.csv", r"D:\d\a.csv"),
3626                (r"C:\a.csv", "C:a.csv"),
3627                (r"\\srv\share\a.csv", r"\\srv\other\a.csv"),
3628            ] {
3629                assert_ne!(place(a), place(b), "{a} and {b}");
3630            }
3631        }
3632    }
3633
3634    use super::*;
3635
3636    #[test]
3637    fn a_key_the_registry_does_not_know_is_named_with_the_nearest() {
3638        let found = |text: &str| unknown_keys_in(&toml::from_str(text).unwrap());
3639        let unknown = found(
3640            "[file_loading]\ncomment_char = \"#\"\n[display]\nmouse = false\nrow_numbr = true\n\
3641             number_format = { grouping = \"thousands\" }\n[glyphs]\nspinner = [\"a\"]\n\
3642             [theme.colors]\naccent = \"red\"\n[[cloud.connections]]\nname = \"x\"\n\
3643             [[sources]]\nname = \"x\"\n[home]\ndirectories = [\"/d\"]\n",
3644        );
3645        assert_eq!(unknown.len(), 4, "{unknown:?}");
3646        // A retired key says where what it said goes now.
3647        assert!(
3648            unknown[2].starts_with("home.directories is not read any more")
3649                && unknown[2].contains("Ctrl+D"),
3650            "{unknown:?}"
3651        );
3652        assert!(
3653            unknown[3].starts_with("sources is not read any more")
3654                && unknown[3].contains("catalog.toml"),
3655            "{unknown:?}"
3656        );
3657        assert!(
3658            unknown[0].starts_with("display.row_numbr")
3659                && unknown[0].contains("display.row_numbers")
3660        );
3661        assert!(
3662            unknown[1].starts_with("file_loading.comment_char")
3663                && unknown[1].contains("csv.comment")
3664        );
3665    }
3666
3667    #[test]
3668    fn a_field_selection_stays_visible_when_the_tint_degrades() {
3669        use ratatui::style::{Modifier, Style};
3670        let theme_with = |tint: Option<Color>| Theme {
3671            colors: tint
3672                .map(|c| HashMap::from([("table_selected".to_string(), c)]))
3673                .unwrap_or_default(),
3674        };
3675        let reversed = Style::default().add_modifier(Modifier::REVERSED);
3676        // The default tints on a 16-color terminal, and NO_COLOR.
3677        for tint in [Color::Black, Color::White, Color::Reset] {
3678            assert_eq!(
3679                theme_with(Some(tint)).text_selection_style(),
3680                reversed,
3681                "{tint:?}"
3682            );
3683        }
3684        assert_eq!(theme_with(None).text_selection_style(), reversed);
3685        for tint in [
3686            Color::Rgb(0x28, 0x34, 0x57),
3687            Color::Indexed(237),
3688            Color::Blue,
3689        ] {
3690            assert_eq!(
3691                theme_with(Some(tint)).text_selection_style(),
3692                Style::default().bg(tint)
3693            );
3694        }
3695    }
3696
3697    /// Every setting the defaults serialize, plus the ones unset by default, as dotted
3698    /// paths with their values. Arrays of tables (`[[sources]]`) are not settings.
3699    fn leaf_settings() -> Vec<(String, toml::Value)> {
3700        fn walk(table: &toml::Table, prefix: &str, out: &mut Vec<(String, toml::Value)>) {
3701            for (key, value) in table {
3702                let path = if prefix.is_empty() {
3703                    key.clone()
3704                } else {
3705                    format!("{prefix}.{key}")
3706                };
3707                match value {
3708                    toml::Value::Table(inner) => walk(inner, &path, out),
3709                    toml::Value::Array(items) if items.iter().any(toml::Value::is_table) => {}
3710                    _ => out.push((path, value.clone())),
3711                }
3712            }
3713        }
3714        let toml::Value::Table(defaults) = toml::Value::try_from(AppConfig::default()).unwrap()
3715        else {
3716            unreachable!("a struct serializes to a table")
3717        };
3718        let mut out = Vec::new();
3719        walk(&defaults, "", &mut out);
3720        for setting in datui_cli::settings::SETTINGS {
3721            if let datui_cli::settings::DefaultValue::Unset(example) = setting.default
3722                && !setting.key.ends_with(".*")
3723                && setting.kind != datui_cli::settings::Kind::Tables
3724            {
3725                let value: toml::Table = toml::from_str(&format!("v = {example}")).unwrap();
3726                out.push((setting.key.to_string(), value["v"].clone()));
3727            }
3728        }
3729        out
3730    }
3731
3732    /// A layer that writes `value` at the dotted `path` and nothing else.
3733    fn layer_at(path: &str, value: toml::Value) -> Result<ConfigLayer> {
3734        let table = path.rsplit('.').fold(value, |inner, key| {
3735            toml::Value::Table(toml::Table::from_iter([(key.to_string(), inner)]))
3736        });
3737        ConfigLayer::parse(&toml::to_string(&table)?)
3738    }
3739
3740    fn value_at<'a>(table: &'a toml::Table, path: &str) -> Option<&'a toml::Value> {
3741        let (parents, key) = path.rsplit_once('.').unwrap_or(("", path));
3742        let mut table = table;
3743        for part in parents.split('.').filter(|p| !p.is_empty()) {
3744            table = table.get(part)?.as_table()?;
3745        }
3746        table.get(key)
3747    }
3748
3749    /// The registry and the config structs describe the same keys with the same
3750    /// defaults: every key the defaults serialize is registered with that value, and
3751    /// every registered key is one the structs read.
3752    #[test]
3753    fn the_registry_and_the_config_structs_agree() {
3754        use datui_cli::settings::{DefaultValue, Kind, SETTINGS, find};
3755        let toml::Value::Table(defaults) = toml::Value::try_from(AppConfig::default()).unwrap()
3756        else {
3757            unreachable!("a struct serializes to a table")
3758        };
3759        let light = toml::Value::try_from(ColorConfig::light()).unwrap();
3760        for (path, value) in leaf_settings() {
3761            let setting = find(&path).unwrap_or_else(|| panic!("{path} is not registered"));
3762            let registered = match setting.default {
3763                DefaultValue::Value(v) | DefaultValue::Unset(v) => {
3764                    toml::from_str::<toml::Table>(&format!("v = {v}")).unwrap()["v"].clone()
3765                }
3766                DefaultValue::Color { dark, light: lit } => {
3767                    let name = setting.name();
3768                    assert_eq!(
3769                        light.get(name).and_then(|v| v.as_str()),
3770                        Some(lit),
3771                        "{path} (light)"
3772                    );
3773                    toml::Value::String(dark.to_string())
3774                }
3775            };
3776            assert_eq!(value, registered, "{path}: the registry's default differs");
3777        }
3778        for setting in SETTINGS {
3779            if setting.key.ends_with(".*") || setting.kind == Kind::Tables {
3780                continue;
3781            }
3782            let serialized = value_at(&defaults, setting.key).is_some();
3783            let unset = matches!(setting.default, DefaultValue::Unset(_));
3784            assert_eq!(
3785                serialized,
3786                !unset,
3787                "{}: registered as {}set by default",
3788                setting.key,
3789                if unset { "un" } else { "" }
3790            );
3791            // Each value it shows is one the config reads.
3792            let example = match setting.default {
3793                DefaultValue::Value(v) | DefaultValue::Unset(v) => v.to_string(),
3794                DefaultValue::Color { dark, .. } => format!("\"{dark}\""),
3795            };
3796            let value =
3797                toml::from_str::<toml::Table>(&format!("v = {example}")).unwrap()["v"].clone();
3798            layer_at(setting.key, value).unwrap_or_else(|e| panic!("{}: {e}", setting.key));
3799        }
3800    }
3801
3802    #[test]
3803    fn the_generated_config_shows_every_key_and_parses_uncommented() {
3804        let generated = ConfigManager::with_dir(PathBuf::new()).generate_default_config();
3805        let mut shown = std::collections::HashSet::new();
3806        let mut section = String::new();
3807        let mut uncommented = String::new();
3808        for line in generated.lines() {
3809            let Some(line) = line.strip_prefix("# ") else {
3810                uncommented.push_str(line);
3811                uncommented.push('\n');
3812                continue;
3813            };
3814            if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
3815                section = name.to_string();
3816                uncommented.push_str(line);
3817                uncommented.push('\n');
3818            } else if let Some((key, _)) = line.split_once(" = ")
3819                && !key.contains(' ')
3820            {
3821                shown.insert(match section.as_str() {
3822                    "" => key.to_string(),
3823                    s => format!("{s}.{key}"),
3824                });
3825                uncommented.push_str(line);
3826                uncommented.push('\n');
3827            }
3828        }
3829        for (path, _) in leaf_settings() {
3830            assert!(
3831                shown.contains(&path),
3832                "{path} is not in the generated config"
3833            );
3834        }
3835        // Every value shown, uncommented, is a config datui reads.
3836        ConfigLayer::parse(&uncommented).unwrap();
3837    }
3838
3839    #[test]
3840    fn every_setting_written_as_its_default_overrides_an_import() {
3841        // Layers are generic, so no option has merge code of its own; this holds every
3842        // one of them to it. The import moves each setting it can off its default, and
3843        // the user's file then writes every default back.
3844        let defaults = leaf_settings();
3845        let mut import = ConfigLayer::default();
3846        let mut moved = Vec::new();
3847        for (path, value) in &defaults {
3848            // These follow their own rules, tested on their own.
3849            if path == "import" || COMBINED_KEYS.iter().any(|(p, _)| p == path) {
3850                continue;
3851            }
3852            let other = match value {
3853                toml::Value::Boolean(b) => toml::Value::Boolean(!b),
3854                toml::Value::Integer(n) => toml::Value::Integer(n + 1),
3855                toml::Value::Array(items) if items.is_empty() => vec!["x"].into(),
3856                toml::Value::Array(_) => toml::Value::Array(Vec::new()),
3857                // An `auto` that also takes a bool.
3858                toml::Value::String(text) if text == "auto" && path == "display.row_numbers" => {
3859                    true.into()
3860                }
3861                toml::Value::String(text) => format!("{text}0").into(),
3862                other => panic!("{path}: no rule to change {other}"),
3863            };
3864            // A string naming a choice, such as `unicode`, has no generic other value.
3865            if let Ok(layer) = layer_at(path, other.clone()) {
3866                import.merge(layer);
3867                moved.push((path.clone(), other));
3868            }
3869        }
3870        assert!(moved.len() > 100, "only {} settings moved", moved.len());
3871
3872        let serialized = |config: AppConfig| match toml::Value::try_from(config).unwrap() {
3873            toml::Value::Table(table) => table,
3874            _ => unreachable!("a struct serializes to a table"),
3875        };
3876        let kept = serialized(AppConfig::from_layers([import.clone()]).unwrap());
3877        for (path, other) in &moved {
3878            assert_eq!(value_at(&kept, path), Some(other), "{path} was not read");
3879        }
3880
3881        let mut own = ConfigLayer::default();
3882        for (path, value) in &defaults {
3883            own.merge(layer_at(path, value.clone()).unwrap());
3884        }
3885        let restored = serialized(AppConfig::from_layers([import, own]).unwrap());
3886        for (path, _) in &moved {
3887            let default = defaults.iter().find(|(p, _)| p == path).map(|(_, v)| v);
3888            assert_eq!(value_at(&restored, path), default, "{path} kept the import");
3889        }
3890    }
3891}