Skip to main content

datui_lib/config/
mod.rs

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