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    /// Scroll the table by moving the terminal's lines (a scroll region) and drawing
940    /// only the new ones, instead of redrawing every line that moved.
941    pub scroll_region: bool,
942    /// A fixed width for every sidebar (Info, Sort & Filter, Views, Pivot & Melt). None:
943    /// each sidebar's own.
944    pub sidebar_width: Option<u16>,
945    /// Right-align numeric columns and their headers in the data table.
946    pub right_align_numbers: bool,
947    /// How numbers are displayed. Either a preset name (`number_format = "thousands"`)
948    /// or a `[display.number_format]` table for finer control.
949    pub number_format: NumberFormatConfig,
950}
951
952/// Whether `#` shows row numbers when a file opens: for text and logs (`"auto"`), or
953/// for every format or none.
954#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
955pub enum RowNumbers {
956    #[default]
957    Auto,
958    On,
959    Off,
960}
961
962impl RowNumbers {}
963
964impl From<bool> for RowNumbers {
965    fn from(on: bool) -> Self {
966        if on { Self::On } else { Self::Off }
967    }
968}
969
970impl Serialize for RowNumbers {
971    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
972        match self {
973            Self::Auto => serializer.serialize_str("auto"),
974            Self::On => serializer.serialize_bool(true),
975            Self::Off => serializer.serialize_bool(false),
976        }
977    }
978}
979
980impl<'de> Deserialize<'de> for RowNumbers {
981    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
982        use serde::de::Error;
983        #[derive(Deserialize)]
984        #[serde(untagged)]
985        enum Raw {
986            Bool(bool),
987            Name(String),
988        }
989        const EXPECTED: &str = "row_numbers is \"auto\", true or false";
990        match Raw::deserialize(deserializer).map_err(|_| D::Error::custom(EXPECTED))? {
991            Raw::Bool(on) => Ok(on.into()),
992            Raw::Name(name) if name == "auto" => Ok(Self::Auto),
993            Raw::Name(other) => Err(D::Error::custom(format!("{EXPECTED}, not {other:?}"))),
994        }
995    }
996}
997
998/// Spacing between the main table's columns, frozen and scrolling alike: a density
999/// by name, or a count of cells.
1000#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1001#[serde(untagged)]
1002pub enum CellPadding {
1003    Cells(usize),
1004    Density(Density),
1005}
1006
1007/// The named spacings.
1008#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1009#[serde(rename_all = "lowercase")]
1010pub enum Density {
1011    /// One cell between columns: more columns on screen.
1012    Compact,
1013    /// Two cells between columns, the default.
1014    Comfortable,
1015}
1016
1017impl Default for CellPadding {
1018    fn default() -> Self {
1019        Self::Density(Density::Comfortable)
1020    }
1021}
1022
1023impl CellPadding {
1024    /// Cells between two columns.
1025    pub fn cells(self) -> u16 {
1026        match self {
1027            Self::Cells(n) => u16::try_from(n).unwrap_or(u16::MAX),
1028            Self::Density(Density::Compact) => 1,
1029            Self::Density(Density::Comfortable) => 2,
1030        }
1031    }
1032}
1033
1034impl<'de> Deserialize<'de> for CellPadding {
1035    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1036        use serde::de::Error;
1037        #[derive(Deserialize)]
1038        #[serde(untagged)]
1039        enum Raw {
1040            Cells(usize),
1041            Name(String),
1042        }
1043        const EXPECTED: &str = "cell_padding is \"compact\", \"comfortable\" or a number of cells";
1044        match Raw::deserialize(deserializer).map_err(|_| D::Error::custom(EXPECTED))? {
1045            Raw::Cells(n) => Ok(Self::Cells(n)),
1046            Raw::Name(name) => match name.as_str() {
1047                "compact" => Ok(Self::Density(Density::Compact)),
1048                "comfortable" => Ok(Self::Density(Density::Comfortable)),
1049                other => Err(D::Error::custom(format!("{EXPECTED}, not {other:?}"))),
1050            },
1051        }
1052    }
1053}
1054
1055/// Number display settings: a preset name (`number_format = "thousands"`) or a
1056/// `[display.number_format]` table.
1057#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1058#[serde(untagged)]
1059pub enum NumberFormatConfig {
1060    /// Shorthand: one of [`NumberFormat::PRESET_NAMES`].
1061    Preset(String),
1062    /// Long form with individual overrides.
1063    Custom(Box<NumberFormatTable>),
1064}
1065
1066impl Default for NumberFormatConfig {
1067    fn default() -> Self {
1068        // Default renders exactly as before, so upgrading changes nothing.
1069        NumberFormatConfig::Preset("none".to_string())
1070    }
1071}
1072
1073/// Long-form number formatting options; unset fields take the value of the preset
1074/// named by `grouping` (or the default).
1075#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1076#[serde(default)]
1077pub struct NumberFormatTable {
1078    /// `none` | `thousands` | `indian` | `system` | any preset name.
1079    pub grouping: Option<String>,
1080    /// Character placed between digit groups.
1081    pub group_separator: Option<String>,
1082    /// Character used as the decimal point.
1083    pub decimal_separator: Option<String>,
1084    /// Whether float columns get grouping too.
1085    pub floats: Option<bool>,
1086    /// Fixed decimal places for floats. Unset keeps Polars' own rendering.
1087    pub float_precision: Option<u8>,
1088    /// Columns never formatted. Supports `*` and `?` globs.
1089    pub exclude_columns: Vec<String>,
1090    /// Unrecognized keys, kept so [`NumberFormatConfig::resolve`] can name a typo
1091    /// (otherwise it would look like no formatting, the default).
1092    #[serde(flatten)]
1093    pub unknown: std::collections::BTreeMap<String, toml::Value>,
1094}
1095
1096/// Field names accepted inside `[display.number_format]`, for error messages.
1097const NUMBER_FORMAT_KEYS: &str =
1098    "grouping, group_separator, decimal_separator, floats, float_precision, exclude_columns";
1099
1100impl NumberFormatConfig {
1101    /// Resolve into renderer settings. Errors on unknown presets, multi-character
1102    /// separators, and a group separator equal to the decimal one.
1103    pub fn resolve(&self, align_numeric_right: bool) -> Result<NumberFormatSettings> {
1104        let (format, exclude) = match self {
1105            NumberFormatConfig::Preset(name) => (Self::lookup_preset(name)?, Vec::new()),
1106            NumberFormatConfig::Custom(table) => {
1107                if !table.unknown.is_empty() {
1108                    let keys: Vec<&str> = table.unknown.keys().map(String::as_str).collect();
1109                    return Err(eyre!(
1110                        "display.number_format: unknown key{} {}. Expected one of: {}",
1111                        if keys.len() > 1 { "s" } else { "" },
1112                        keys.iter()
1113                            .map(|k| format!("'{}'", k))
1114                            .collect::<Vec<_>>()
1115                            .join(", "),
1116                        NUMBER_FORMAT_KEYS
1117                    ));
1118                }
1119                let base = match table.grouping.as_deref() {
1120                    Some(name) => Self::lookup_preset(name)?,
1121                    None => NumberFormat::PLAIN,
1122                };
1123                let mut fmt = base;
1124                if let Some(sep) = table.group_separator.as_deref() {
1125                    fmt.group_sep = Self::single_char(sep, "group_separator")?;
1126                }
1127                if let Some(sep) = table.decimal_separator.as_deref() {
1128                    fmt.decimal_sep = Self::single_char(sep, "decimal_separator")?;
1129                }
1130                if let Some(v) = table.floats {
1131                    fmt.floats = v;
1132                }
1133                if table.float_precision.is_some() {
1134                    fmt.float_precision = table.float_precision;
1135                }
1136                (fmt, table.exclude_columns.iter().map(Glob::new).collect())
1137            }
1138        };
1139
1140        if format.grouping != Grouping::None && format.group_sep == format.decimal_sep {
1141            return Err(eyre!(
1142                "display.number_format: group_separator and decimal_separator are both '{}'; \
1143                 they must differ or numbers become ambiguous",
1144                format.group_sep
1145            ));
1146        }
1147
1148        // Formatting is on only if the user configured something. Otherwise `,` still needs
1149        // a target: Thousands grouping, keeping any other settings they chose.
1150        let enabled = !format.is_noop();
1151        let format = if enabled {
1152            format
1153        } else {
1154            NumberFormat {
1155                grouping: Grouping::Thousands,
1156                ..format
1157            }
1158        };
1159
1160        Ok(NumberFormatSettings {
1161            format,
1162            enabled,
1163            exclude,
1164            align_numeric_right,
1165        })
1166    }
1167
1168    /// Override only the grouping style, keeping the user's long-form settings
1169    /// (`exclude_columns`, `min_digits`, precision).
1170    pub fn with_grouping_override(&self, name: &str) -> Self {
1171        match self {
1172            NumberFormatConfig::Preset(_) => NumberFormatConfig::Preset(name.to_string()),
1173            NumberFormatConfig::Custom(table) => {
1174                let mut table = table.clone();
1175                table.grouping = Some(name.to_string());
1176                NumberFormatConfig::Custom(table)
1177            }
1178        }
1179    }
1180
1181    /// Resolve a preset name, expanding the opt-in `system` value.
1182    fn lookup_preset(name: &str) -> Result<NumberFormat> {
1183        // "system" is the only locale-dependent value, opt-in: data files are
1184        // locale-neutral.
1185        let name = if name == "system" {
1186            match numfmt::system_locale_tag() {
1187                Some(tag) => numfmt::preset_for_locale_tag(&tag),
1188                // Unset or C/POSIX: group plainly rather than do nothing.
1189                None => "thousands",
1190            }
1191        } else {
1192            name
1193        };
1194        NumberFormat::preset(name).ok_or_else(|| {
1195            eyre!(
1196                "display.number_format: unknown value '{}'. Expected one of: {}, system",
1197                name,
1198                NumberFormat::PRESET_NAMES.join(", ")
1199            )
1200        })
1201    }
1202
1203    fn single_char(s: &str, field: &str) -> Result<char> {
1204        let mut chars = s.chars();
1205        match (chars.next(), chars.next()) {
1206            (Some(c), None) => Ok(c),
1207            _ => Err(eyre!(
1208                "display.number_format.{}: expected a single character, got {:?}",
1209                field,
1210                s
1211            )),
1212        }
1213    }
1214}
1215
1216/// Rows an analysis samples by default: stable distributions and correlations to two
1217/// decimals, read in seconds.
1218pub const DEFAULT_ANALYSIS_SAMPLE_ROWS: usize = 100_000;
1219
1220#[derive(Debug, Clone, Serialize, Deserialize)]
1221#[serde(default)]
1222pub struct PerformanceConfig {
1223    /// Pages of rows buffered ahead of and behind the screen.
1224    pub pages_ahead: usize,
1225    pub pages_behind: usize,
1226    /// Most rows the table buffers between reads; 0 for no limit.
1227    pub max_buffered_rows: usize,
1228    /// Most memory buffered rows may take, estimated from the schema; 0 for no limit. Caps
1229    /// rows kept between reads, not the process.
1230    pub max_buffered: ByteSize,
1231    /// Use the Polars streaming engine for collects where it applies.
1232    pub streaming: bool,
1233    /// Most threads Polars computes with; 0 for every core. Applied once, at startup.
1234    pub threads: usize,
1235}
1236
1237impl PerformanceConfig {
1238    /// `max_buffered` in whole MiB (rounded up, so a nonzero cap is at least one).
1239    pub fn max_buffered_mb(&self) -> usize {
1240        usize::try_from(self.max_buffered.bytes().div_ceil(1 << 20)).unwrap_or(usize::MAX)
1241    }
1242}
1243
1244/// Default for `analysis.quality_local_copy`: 2 GiB.
1245pub const DEFAULT_QUALITY_LOCAL_COPY: ByteSize = ByteSize::mib(2048);
1246
1247/// Default maximum rows used for chart data when not overridden by config or UI.
1248pub const DEFAULT_CHART_ROW_LIMIT: usize = 10_000;
1249/// Maximum chart row limit (Polars slice takes u32).
1250pub const MAX_CHART_ROW_LIMIT: usize = u32::MAX as usize;
1251
1252/// `[analysis]`: Analysis, Data Quality and charts.
1253#[derive(Debug, Clone, Serialize, Deserialize)]
1254#[serde(default)]
1255pub struct AnalysisConfig {
1256    /// The analysis sample's starting size: rows every tool reads, spread across a larger
1257    /// table. 0 starts at every row.
1258    pub sample_rows: usize,
1259    /// Rows a chart reads: all up to n, else a sample of n spread across the table.
1260    pub chart_rows: usize,
1261    /// Whether a chart starts with its grid at the major ticks.
1262    pub chart_grid: bool,
1263    /// The most a Data Quality full scan may copy of a remote dataset into the cache, to
1264    /// read objects once instead of per pass. 0 never copies.
1265    pub quality_local_copy: ByteSize,
1266    /// The most memory a view's sample may take. Unset: available memory decides, before
1267    /// and during the draw. 0: no warning, no stop.
1268    pub sample_memory_limit: Option<ByteSize>,
1269}
1270
1271impl Default for AnalysisConfig {
1272    fn default() -> Self {
1273        Self {
1274            sample_rows: DEFAULT_ANALYSIS_SAMPLE_ROWS,
1275            chart_rows: DEFAULT_CHART_ROW_LIMIT,
1276            chart_grid: false,
1277            quality_local_copy: DEFAULT_QUALITY_LOCAL_COPY,
1278            sample_memory_limit: None,
1279        }
1280    }
1281}
1282
1283/// Which built-in color defaults to start from. Chrome colors (header fills, striping,
1284/// borders) must sit near the background without matching it, which no ANSI color
1285/// expresses, so they are fixed per set.
1286#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1287#[serde(rename_all = "lowercase")]
1288pub enum ThemeMode {
1289    /// Detect from the environment, falling back to `Dark`.
1290    #[default]
1291    Auto,
1292    Dark,
1293    Light,
1294}
1295
1296impl ThemeMode {
1297    /// Resolve `Auto` from the environment (`Dark` and `Light` pass through): `COLORFGBG`
1298    /// with background 7 or 15 means light; otherwise dark. The terminal's own answer,
1299    /// when it comes, replaces this (`crate::app::terminal_color`).
1300    pub fn resolve(self) -> Self {
1301        match self {
1302            Self::Auto => detect_terminal_mode(),
1303            other => other,
1304        }
1305    }
1306}
1307
1308/// Best-effort light/dark detection from `COLORFGBG`. Defaults to `Dark`.
1309fn detect_terminal_mode() -> ThemeMode {
1310    let Ok(raw) = std::env::var("COLORFGBG") else {
1311        return ThemeMode::Dark;
1312    };
1313    // Format is "fg;bg" or "fg;default;bg" — the background is the last field.
1314    match raw
1315        .rsplit(';')
1316        .next()
1317        .and_then(|b| b.trim().parse::<u8>().ok())
1318    {
1319        Some(7) | Some(15) => ThemeMode::Light,
1320        _ => ThemeMode::Dark,
1321    }
1322}
1323
1324/// Where datui looks for datasets on home: a short list of places, `PATH`-shaped; it
1325/// records nothing about what it finds there.
1326#[derive(Debug, Clone, Serialize, Deserialize)]
1327#[serde(default)]
1328pub struct HomeConfig {
1329    /// Also offer directories the desktop records you opening data from (never file
1330    /// names).
1331    pub desktop_recents: bool,
1332    /// List unreadable files, dimmed, from the start; `Ctrl+A` flips it per session.
1333    pub show_unreadable: bool,
1334    /// The wordmark atop home; off, the one-line title bar small terminals get.
1335    pub wordmark: bool,
1336    /// Catalogs never shown on the home screen, by id: `mine`, `public`, or a listed
1337    /// file's name.
1338    pub hide: Vec<String>,
1339    /// The largest local file whose first rows home reads for its preview (Parquet: its
1340    /// average row group). Those rows are the open's first page, read once. 0 turns
1341    /// previews off.
1342    pub preview_max: ByteSize,
1343    /// Recursive search of the working directory from the home screen's filter.
1344    pub search: SearchConfig,
1345}
1346
1347/// Recursive search under the working directory, driven by home's filter: one
1348/// background walk on first typing, then each keystroke scores its results off the
1349/// UI thread. These limits bound the walk and the list.
1350#[derive(Debug, Clone, Serialize, Deserialize)]
1351#[serde(default)]
1352pub struct SearchConfig {
1353    /// Search below the working directory at all.
1354    pub enabled: bool,
1355    /// How deep to descend; data is rarely deep, and every branch pays.
1356    pub max_depth: usize,
1357    /// List at most this many matches, best first; the heading counts the rest. The walk
1358    /// keeps every data file, so no match is lost.
1359    pub max_results: usize,
1360    /// Stop walking after this long, keeping what was found: a huge tree degrades to
1361    /// partial results, never a wait.
1362    pub time_budget: Interval,
1363    /// Descend into other filesystems. Off by default: it keeps the walk off network
1364    /// shares, and off autofs mounts it would trigger.
1365    pub cross_filesystems: bool,
1366    /// Obey `.gitignore`. Off by default: data directories are gitignored because they
1367    /// are too big to commit, the reason to open them here. The skip list handles noise.
1368    pub follow_gitignore: bool,
1369    /// Directory names never descended into. Replaces the defaults entirely.
1370    pub skip: Vec<String>,
1371    /// Directory names to skip in addition to the defaults.
1372    pub skip_extra: Vec<String>,
1373    /// File extensions searched for. Empty: every format datui opens, `json` and `txt`
1374    /// included (noisy in a source tree).
1375    pub extensions: Vec<String>,
1376}
1377
1378/// Directories that are never data and always expensive. Hidden ones are skipped
1379/// already; these are the visible offenders, full of openable `.json`.
1380pub const DEFAULT_SEARCH_SKIP: &[&str] = &[
1381    "node_modules",
1382    "target",
1383    "build",
1384    "dist",
1385    "vendor",
1386    "site-packages",
1387    "__pycache__",
1388    "venv",
1389    "env",
1390];
1391
1392impl Default for SearchConfig {
1393    fn default() -> Self {
1394        Self {
1395            enabled: true,
1396            max_depth: 8,
1397            max_results: 1_000,
1398            time_budget: Interval::ms(1_500),
1399            cross_filesystems: false,
1400            follow_gitignore: false,
1401            skip: DEFAULT_SEARCH_SKIP.iter().map(|s| s.to_string()).collect(),
1402            skip_extra: Vec::new(),
1403            extensions: Vec::new(),
1404        }
1405    }
1406}
1407
1408impl SearchConfig {
1409    /// Every directory name to skip: the configured list plus the additions.
1410    pub fn skipped_dirs(&self) -> Vec<String> {
1411        let mut out = self.skip.clone();
1412        out.extend(self.skip_extra.iter().cloned());
1413        out
1414    }
1415}
1416
1417impl Default for HomeConfig {
1418    fn default() -> Self {
1419        Self {
1420            // On by default: it only adds places, giving a fresh install somewhere to point.
1421            desktop_recents: true,
1422            show_unreadable: false,
1423            wordmark: true,
1424            hide: Vec::new(),
1425            preview_max: ByteSize::mib(64),
1426            search: SearchConfig::default(),
1427        }
1428    }
1429}
1430
1431#[derive(Debug, Clone, Serialize, Deserialize)]
1432#[serde(default)]
1433pub struct ThemeConfig {
1434    /// Which mode's theme to use; `None` (absent) means `Auto`. A loaded config holds the
1435    /// resolved mode.
1436    pub mode: Option<ThemeMode>,
1437    /// The theme used when the terminal is dark: a built-in's name or a file's in
1438    /// `themes/`.
1439    pub dark: String,
1440    /// The theme used when the terminal is light.
1441    pub light: String,
1442    pub colors: ColorConfig,
1443    /// The mode was `auto`: the palette follows the terminal's background, at startup
1444    /// and when asked again. Set by `from_layers`.
1445    #[serde(skip)]
1446    pub follow: bool,
1447    /// The `theme.colors` slots the config set, laid over the active theme in either
1448    /// mode.
1449    #[serde(skip)]
1450    pub overrides: toml::Table,
1451    /// The themes there are, read from the config directory's `themes/`.
1452    #[serde(skip)]
1453    pub library: crate::config::themes::Library,
1454    /// The theme in use for each mode: `dark` and `light`, or the built-in when the named
1455    /// one could not be used.
1456    #[serde(skip)]
1457    pub dark_theme: String,
1458    #[serde(skip)]
1459    pub light_theme: String,
1460    /// Each mode's theme resolved, before `overrides`.
1461    #[serde(skip)]
1462    pub dark_palette: ColorConfig,
1463    #[serde(skip)]
1464    pub light_palette: ColorConfig,
1465    /// Why a named theme was not used, one line each, for a warning.
1466    #[serde(skip)]
1467    pub problems: Vec<String>,
1468    /// The same, without the why: short enough for the footer.
1469    #[serde(skip)]
1470    pub fallbacks: Vec<String>,
1471}
1472
1473impl Default for ThemeConfig {
1474    fn default() -> Self {
1475        Self {
1476            mode: None,
1477            dark: crate::config::themes::NIGHT_MARKET.to_string(),
1478            light: crate::config::themes::DAY_MARKET.to_string(),
1479            colors: ColorConfig::default(),
1480            follow: false,
1481            overrides: toml::Table::new(),
1482            library: crate::config::themes::Library::default(),
1483            dark_theme: crate::config::themes::NIGHT_MARKET.to_string(),
1484            light_theme: crate::config::themes::DAY_MARKET.to_string(),
1485            dark_palette: ColorConfig::dark(),
1486            light_palette: ColorConfig::light(),
1487            problems: Vec::new(),
1488            fallbacks: Vec::new(),
1489        }
1490    }
1491}
1492
1493impl ThemeConfig {
1494    /// The theme for `mode` with the configured slots laid over it.
1495    pub fn palette_for(&self, mode: ThemeMode) -> Result<ColorConfig> {
1496        let base = match mode.resolve() {
1497            ThemeMode::Light => &self.light_palette,
1498            _ => &self.dark_palette,
1499        };
1500        let mut palette = crate::config::themes::slots(base);
1501        palette.extend(self.overrides.clone());
1502        Ok(toml::Value::Table(palette).try_into()?)
1503    }
1504
1505    /// Every theme file left out and every name not used, one warning each.
1506    pub fn warnings(&self) -> Vec<String> {
1507        let broken = self
1508            .library
1509            .broken
1510            .iter()
1511            .map(|b| format!("warning: theme left out: {}", b.full()));
1512        let problems = self.problems.iter().map(|p| format!("warning: {p}"));
1513        broken.chain(problems).collect()
1514    }
1515
1516    /// Resolve `dark` and `light` against `library`, keeping it. An unusable name falls
1517    /// back to its mode's built-in, noted in `problems` when that mode can be in use.
1518    pub fn use_library(&mut self, library: crate::config::themes::Library, active: ThemeMode) {
1519        self.problems.clear();
1520        self.fallbacks.clear();
1521        for mode in [ThemeMode::Dark, ThemeMode::Light] {
1522            let (key, name) = match mode {
1523                ThemeMode::Light => ("theme.light", self.light.clone()),
1524                _ => ("theme.dark", self.dark.clone()),
1525            };
1526            let (used, palette) = match library.resolve(&name, mode) {
1527                Ok(palette) => (name, palette),
1528                Err(why) => {
1529                    let fallback = crate::config::themes::built_in_name(mode);
1530                    if self.follow || active == mode {
1531                        let short = format!("{key}: using {fallback}, not {name}");
1532                        self.problems.push(format!("{short}: {why}"));
1533                        self.fallbacks.push(short);
1534                    }
1535                    (fallback.to_string(), ColorConfig::for_mode(mode))
1536                }
1537            };
1538            match mode {
1539                ThemeMode::Light => (self.light_theme, self.light_palette) = (used, palette),
1540                _ => (self.dark_theme, self.dark_palette) = (used, palette),
1541            }
1542        }
1543        self.library = library;
1544    }
1545}
1546
1547/// Color configuration for the application theme: one slot per role, each a name
1548/// (`"cyan"`, `"default"`), `"#rrggbb"` or `"indexed(N)"`. The option registry
1549/// documents every slot and holds its dark and light defaults.
1550#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1551#[serde(default)]
1552pub struct ColorConfig {
1553    pub chip_key: String,
1554    pub chip_label: String,
1555    pub throbber: String,
1556    pub success: String,
1557    pub error: String,
1558    pub warning: String,
1559    pub dimmed: String,
1560    pub background: String,
1561    pub surface: String,
1562    pub controls_bg: String,
1563    pub text_primary: String,
1564    pub text_secondary: String,
1565    pub text_inverse: String,
1566    pub table_header: String,
1567    pub table_header_bg: String,
1568    /// The row-number column. "default" is the terminal's.
1569    pub table_row_numbers: String,
1570    pub table_column_separator: String,
1571    /// Tint under the current row; "reversed" swaps text and background instead.
1572    pub table_selected: String,
1573    /// Tint under the column cursor's cells.
1574    pub table_column_cursor: String,
1575    /// The column cursor's header and the current cell.
1576    pub table_cell_cursor: String,
1577    /// Every other row; "default" turns the stripe off.
1578    pub table_alternate_row: String,
1579    pub sidebar_border: String,
1580    pub modal_border_active: String,
1581    pub modal_border_error: String,
1582    pub distribution_normal: String,
1583    pub distribution_skewed: String,
1584    pub distribution_other: String,
1585    pub outlier_marker: String,
1586    /// The text caret; "default" reverses the text under it.
1587    pub input_cursor: String,
1588    /// Text under the caret block; "default" picks black or white by contrast.
1589    pub input_cursor_text: String,
1590    /// Cells and headers by column type.
1591    pub type_str: String,
1592    pub type_int: String,
1593    pub type_float: String,
1594    pub type_bool: String,
1595    pub type_temporal: String,
1596    /// The `‹binary›` stub of a binary column.
1597    pub type_binary: String,
1598    /// The chart series, in order; `chart_1` is also histogram bars and Q-Q points.
1599    pub chart_1: String,
1600    pub chart_2: String,
1601    pub chart_3: String,
1602    pub chart_4: String,
1603    pub chart_5: String,
1604    pub chart_6: String,
1605    pub chart_7: String,
1606    pub chart_8: String,
1607    pub chart_9: String,
1608    pub chart_10: String,
1609    /// The chart grid, a shade dimmer than `dimmed`.
1610    pub chart_grid: String,
1611    /// The one color meaning "this is the thing": focused titles, key chips, the selection
1612    /// rail.
1613    pub accent: String,
1614    /// A brighter accent for a focused title or a value that just changed.
1615    pub accent_bright: String,
1616    /// Two stops for home's wordmark, used nowhere else: on data it would be decoration.
1617    pub gradient_start: String,
1618    pub gradient_end: String,
1619    /// Behind the cell a find landed on; its text takes black or white by contrast.
1620    pub find_match: String,
1621    /// The hex view's bytes by class, as hexyl colors them.
1622    pub hex_null: String,
1623    pub hex_printable: String,
1624    pub hex_whitespace: String,
1625    pub hex_control: String,
1626    pub hex_high: String,
1627    pub hex_ff: String,
1628}
1629
1630/// `[http]`: what every request datui makes says about it.
1631#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1632#[serde(default)]
1633pub struct HttpConfig {
1634    /// The User-Agent header; empty sends [`crate::cloud::user_agent::DEFAULT`].
1635    pub user_agent: String,
1636}
1637
1638#[derive(Debug, Clone, Serialize, Deserialize)]
1639#[serde(default)]
1640pub struct QueryConfig {
1641    pub history_limit: usize,
1642    /// Remember queries.
1643    pub history: bool,
1644    pub default_mode: QueryMode,
1645}
1646
1647/// The language the command line runs a query in.
1648#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1649#[serde(rename_all = "kebab-case")]
1650pub enum QueryMode {
1651    #[default]
1652    Sql,
1653    /// Datui's q-inspired language.
1654    Q,
1655}
1656
1657impl QueryMode {
1658    /// The languages this build offers; SQL only with the `sql` feature.
1659    pub fn available() -> &'static [QueryMode] {
1660        #[cfg(feature = "sql")]
1661        {
1662            &[QueryMode::Sql, QueryMode::Q]
1663        }
1664        #[cfg(not(feature = "sql"))]
1665        {
1666            &[QueryMode::Q]
1667        }
1668    }
1669
1670    /// This language if the build offers it, otherwise q.
1671    pub fn resolve(self) -> QueryMode {
1672        if Self::available().contains(&self) {
1673            self
1674        } else {
1675            QueryMode::Q
1676        }
1677    }
1678
1679    /// The prefix as the command line draws it: `sql:`, `q:`.
1680    pub fn prefix_colon(self) -> &'static str {
1681        match self {
1682            QueryMode::Sql => "sql:",
1683            QueryMode::Q => "q:",
1684        }
1685    }
1686
1687    /// The other language, where the build has one.
1688    pub fn next(self) -> QueryMode {
1689        let modes = Self::available();
1690        let at = modes.iter().position(|&m| m == self).unwrap_or(0);
1691        modes[(at + 1) % modes.len()]
1692    }
1693}
1694
1695/// `[chart]`: charts exported to a file.
1696#[derive(Debug, Clone, Serialize, Deserialize)]
1697#[serde(default)]
1698pub struct ChartConfig {
1699    /// Whether an exported chart carries its recipe: the source, query, chart and
1700    /// sample it was made from. The export dialog's Recipe row starts from it.
1701    pub export_recipe: bool,
1702}
1703
1704impl Default for ChartConfig {
1705    fn default() -> Self {
1706        Self {
1707            export_recipe: true,
1708        }
1709    }
1710}
1711
1712/// `[views]`: saved views.
1713#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1714#[serde(default)]
1715pub struct ViewsConfig {
1716    pub auto_apply: bool,
1717}
1718
1719/// `[log]`: the log file and how much it says.
1720#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1721#[serde(default)]
1722pub struct LogConfig {
1723    /// Where the log goes. Unset: `datui.log` in the cache directory.
1724    pub file: Option<String>,
1725    /// error, warn, info, debug, trace or off. Unset: `DATUI_LOG`, else warn.
1726    pub level: Option<String>,
1727}
1728
1729/// `[formats]`: where format specs and dictionaries are found.
1730#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1731#[serde(default)]
1732pub struct FormatsConfig {
1733    /// Directories (or files) of specs and dictionaries, searched after the config
1734    /// directory's `formats` and `$DATUI_FORMATS_PATH`. Adds up across imports.
1735    pub path: Vec<String>,
1736}
1737
1738/// `[clipboard]`: how the copy dialog reaches the system clipboard.
1739#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1740#[serde(default)]
1741pub struct ClipboardConfig {
1742    /// "auto", "native" (display server via arboard) or "osc52" (a terminal escape; works
1743    /// over SSH).
1744    pub backend: String,
1745    /// Longest OSC 52 payload to attempt, as base64; terminals cap what they accept.
1746    pub osc52_limit: ByteSize,
1747}
1748
1749impl Default for ClipboardConfig {
1750    fn default() -> Self {
1751        Self {
1752            backend: "auto".to_string(),
1753            osc52_limit: ByteSize::kib(100),
1754        }
1755    }
1756}
1757
1758/// `[glyphs]`: per-slot overrides over the Unicode set, for fonts with more than the
1759/// coverage floor. Keys are `glyphs.rs` slot names; values keep the replaced glyph's
1760/// width. The ASCII set is never overridden. Layered like every section.
1761#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1762#[serde(default)]
1763pub struct GlyphsConfig {
1764    #[serde(flatten)]
1765    pub overrides: std::collections::BTreeMap<String, crate::glyphs::SlotOverride>,
1766}
1767
1768/// `[limits]`: the most of a file some readers take in. Each note or error that says a
1769/// cap was reached names its key here.
1770#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1771#[serde(default)]
1772pub struct LimitsConfig {
1773    /// Records one pass indexes, all types together (4 bytes each under 4 GiB, 8 past).
1774    pub indexed_records: usize,
1775    pub elf_symbols: usize,
1776    pub midi_bytes: ByteSize,
1777    /// Events of every MIDI file of one open together.
1778    pub midi_events: usize,
1779    /// Rows of a list on an Info panel tab.
1780    pub detail_rows: usize,
1781    /// Journal JSON read into memory, all files of one open together.
1782    pub journal_bytes: ByteSize,
1783    /// Fields of an SDF file, each a column.
1784    pub sdf_fields: usize,
1785    /// Signals of a VCD file, each a column.
1786    pub vcd_signals: usize,
1787    /// Tags of a FIX file, each a column.
1788    pub fix_tags: usize,
1789    /// Fields of one FIX message.
1790    pub fix_fields: usize,
1791    /// Extension fields of a GPX file, each a column.
1792    pub gpx_fields: usize,
1793    /// A NumPy file's header.
1794    pub npy_header_bytes: ByteSize,
1795}
1796
1797impl LimitsConfig {
1798    pub const DEFAULT: Self = Self {
1799        indexed_records: 64 << 20,
1800        elf_symbols: 10_000_000,
1801        midi_bytes: ByteSize::mib(64),
1802        midi_events: 10_000_000,
1803        detail_rows: 10_000,
1804        journal_bytes: ByteSize::mib(1024),
1805        sdf_fields: 4096,
1806        vcd_signals: 1 << 20,
1807        fix_tags: 4096,
1808        fix_fields: 4096,
1809        gpx_fields: 256,
1810        npy_header_bytes: ByteSize::mib(4),
1811    };
1812}
1813
1814impl Default for LimitsConfig {
1815    fn default() -> Self {
1816        Self::DEFAULT
1817    }
1818}
1819
1820impl Default for AppConfig {
1821    fn default() -> Self {
1822        let mut config = Self {
1823            import: Vec::new(),
1824            catalogs: Vec::new(),
1825            read_catalogs: Vec::new(),
1826            catalog_dir: None,
1827            broken_catalogs: Vec::new(),
1828            read: ReadConfig::default(),
1829            csv: CsvConfig::default(),
1830            display: DisplayConfig::default(),
1831            performance: PerformanceConfig::default(),
1832            analysis: AnalysisConfig::default(),
1833            chart: ChartConfig::default(),
1834            home: HomeConfig::default(),
1835            cloud: CloudConfig::default(),
1836            http: HttpConfig::default(),
1837            query: QueryConfig::default(),
1838            views: ViewsConfig::default(),
1839            clipboard: ClipboardConfig::default(),
1840            formats: FormatsConfig::default(),
1841            limits: LimitsConfig::DEFAULT,
1842            log: LogConfig::default(),
1843            theme: ThemeConfig::default(),
1844            glyphs: GlyphsConfig::default(),
1845        };
1846        config.sync_dataset_access();
1847        config
1848    }
1849}
1850
1851impl Default for DisplayConfig {
1852    fn default() -> Self {
1853        Self {
1854            unicode: crate::glyphs::UnicodeMode::default(),
1855            row_numbers: RowNumbers::Auto,
1856            row_numbers_start: 1,
1857            cell_padding: CellPadding::default(),
1858            column_colors: true,
1859            type_row: true,
1860            notes_accent: true,
1861            mouse: true,
1862            scroll_region: true,
1863            sidebar_width: None,
1864            right_align_numbers: true,
1865            number_format: NumberFormatConfig::default(),
1866        }
1867    }
1868}
1869
1870impl Default for PerformanceConfig {
1871    fn default() -> Self {
1872        Self {
1873            pages_ahead: 3,
1874            pages_behind: 3,
1875            max_buffered_rows: crate::table::DEFAULT_MAX_BUFFERED_ROWS,
1876            max_buffered: ByteSize::mib(512),
1877            streaming: true,
1878            threads: 0,
1879        }
1880    }
1881}
1882
1883impl Default for ColorConfig {
1884    /// Dark, datui's historical defaults; light is opt-in via `theme.mode`.
1885    fn default() -> Self {
1886        Self::dark()
1887    }
1888}
1889
1890impl ColorConfig {
1891    /// The built-in set for whichever mode is in effect.
1892    pub fn for_mode(mode: ThemeMode) -> Self {
1893        match mode.resolve() {
1894            ThemeMode::Light => Self::light(),
1895            _ => Self::dark(),
1896        }
1897    }
1898
1899    /// Defaults tuned for a dark terminal background.
1900    pub fn dark() -> Self {
1901        // "Night Market": Tokyo Night's palette with one cyan accent. Chrome sits in three
1902        // tiers a few percent apart, and the current row is tinted, not reversed, so cell
1903        // colors survive.
1904        Self {
1905            chip_key: "#7dcfff".to_string(),
1906            chip_label: "#a9b1d6".to_string(),
1907            throbber: "#7dcfff".to_string(),
1908            success: "#9ece6a".to_string(),
1909            error: "#f7768e".to_string(),
1910            warning: "#e0af68".to_string(),
1911            dimmed: "#565f89".to_string(),
1912            background: "default".to_string(),
1913            surface: "default".to_string(),
1914            controls_bg: "#262a3f".to_string(),
1915            text_primary: "default".to_string(),
1916            text_secondary: "#737aa2".to_string(),
1917            text_inverse: "#1a1b26".to_string(),
1918            table_header: "#c0caf5".to_string(),
1919            table_header_bg: "#2b3047".to_string(),
1920            table_row_numbers: "#565f89".to_string(),
1921            table_column_separator: "#3b4261".to_string(),
1922            table_selected: "#283457".to_string(),
1923            // A grey a step off the stripe for the column, lighter where it crosses the current
1924            // row.
1925            table_column_cursor: "#292e42".to_string(),
1926            table_cell_cursor: "#3b4261".to_string(),
1927            // Box titles use the border color, so it must read as text.
1928            sidebar_border: "#565f89".to_string(),
1929            modal_border_active: "#7dcfff".to_string(),
1930            modal_border_error: "#f7768e".to_string(),
1931            distribution_normal: "#9ece6a".to_string(),
1932            distribution_skewed: "#e0af68".to_string(),
1933            distribution_other: "#c0caf5".to_string(),
1934            outlier_marker: "#f7768e".to_string(),
1935            input_cursor: "default".to_string(),
1936            input_cursor_text: "default".to_string(),
1937            table_alternate_row: "#1e2030".to_string(),
1938            type_str: "#9ece6a".to_string(),
1939            type_int: "#7aa2f7".to_string(),
1940            type_float: "#2ac3de".to_string(),
1941            type_bool: "#e0af68".to_string(),
1942            type_temporal: "#bb9af7".to_string(),
1943            type_binary: "#565f89".to_string(),
1944            chart_1: "#7dcfff".to_string(),
1945            chart_2: "#bb9af7".to_string(),
1946            chart_3: "#9ece6a".to_string(),
1947            chart_4: "#e0af68".to_string(),
1948            chart_5: "#7aa2f7".to_string(),
1949            chart_6: "#f7768e".to_string(),
1950            chart_7: "#ff9e64".to_string(),
1951            // Teal, pink-magenta and light yellow: apart by lightness as well as hue, for common
1952            // color-vision deficiencies.
1953            chart_8: "#1abc9c".to_string(),
1954            chart_9: "#ff5fd2".to_string(),
1955            chart_10: "#f4ef8a".to_string(),
1956            // Dimmer than `dimmed`, but blue rather than black (the background) on 16 colors.
1957            chart_grid: "#3d4785".to_string(),
1958            accent: "#7dcfff".to_string(),
1959            accent_bright: "#a4daff".to_string(),
1960            gradient_start: "#7aa2f7".to_string(),
1961            gradient_end: "#bb9af7".to_string(),
1962            find_match: "#e0af68".to_string(),
1963            hex_null: "#565f89".to_string(),
1964            hex_printable: "#7dcfff".to_string(),
1965            hex_whitespace: "#9ece6a".to_string(),
1966            hex_control: "#bb9af7".to_string(),
1967            hex_high: "#e0af68".to_string(),
1968            hex_ff: "#f7768e".to_string(),
1969        }
1970    }
1971
1972    /// Defaults for a light terminal: chrome shades darker than the background rather
1973    /// than lighter, and hues illegible on white (plain cyan, yellow) replaced.
1974    pub fn light() -> Self {
1975        // Tokyo Night "day": the same hues, darkened to clear 4.5:1 on white; chrome tiers
1976        // darker than the terminal.
1977        Self {
1978            chip_key: "#2e7de9".to_string(),
1979            chip_label: "#3760bf".to_string(),
1980            throbber: "#2e7de9".to_string(),
1981            success: "#587539".to_string(),
1982            error: "#f52a65".to_string(),
1983            warning: "#8c6c3e".to_string(),
1984            dimmed: "#848cb5".to_string(),
1985            background: "default".to_string(),
1986            surface: "default".to_string(),
1987            controls_bg: "#d0d5e3".to_string(),
1988            text_primary: "default".to_string(),
1989            text_secondary: "#6172b0".to_string(),
1990            text_inverse: "#e1e2e7".to_string(),
1991            table_header: "#3760bf".to_string(),
1992            table_header_bg: "#c4c8da".to_string(),
1993            table_row_numbers: "#848cb5".to_string(),
1994            table_column_separator: "#a8aecb".to_string(),
1995            table_selected: "#b6bfe2".to_string(),
1996            table_column_cursor: "#cbd3f2".to_string(),
1997            table_cell_cursor: "#a0aef0".to_string(),
1998            sidebar_border: "#6172b0".to_string(),
1999            modal_border_active: "#2e7de9".to_string(),
2000            modal_border_error: "#f52a65".to_string(),
2001            distribution_normal: "#587539".to_string(),
2002            distribution_skewed: "#8c6c3e".to_string(),
2003            distribution_other: "#3760bf".to_string(),
2004            outlier_marker: "#f52a65".to_string(),
2005            input_cursor: "default".to_string(),
2006            input_cursor_text: "default".to_string(),
2007            table_alternate_row: "#dcdfea".to_string(),
2008            type_str: "#587539".to_string(),
2009            type_int: "#2e7de9".to_string(),
2010            type_float: "#007197".to_string(),
2011            type_bool: "#8c6c3e".to_string(),
2012            type_temporal: "#9854f1".to_string(),
2013            type_binary: "#848cb5".to_string(),
2014            chart_1: "#2e7de9".to_string(),
2015            chart_2: "#9854f1".to_string(),
2016            chart_3: "#587539".to_string(),
2017            chart_4: "#8c6c3e".to_string(),
2018            chart_5: "#007197".to_string(),
2019            chart_6: "#f52a65".to_string(),
2020            chart_7: "#b15c00".to_string(),
2021            // A yellow does not read on white: a deep navy takes its place.
2022            chart_8: "#118c74".to_string(),
2023            chart_9: "#d1188c".to_string(),
2024            chart_10: "#24357a".to_string(),
2025            // The cyan halfway to the background: a lighter grey is white on 16 colors.
2026            chart_grid: "#70aabf".to_string(),
2027            accent: "#2e7de9".to_string(),
2028            accent_bright: "#1a6cd0".to_string(),
2029            gradient_start: "#2e7de9".to_string(),
2030            gradient_end: "#9854f1".to_string(),
2031            find_match: "#f0c35a".to_string(),
2032            hex_null: "#848cb5".to_string(),
2033            hex_printable: "#007197".to_string(),
2034            hex_whitespace: "#587539".to_string(),
2035            hex_control: "#9854f1".to_string(),
2036            hex_high: "#8c6c3e".to_string(),
2037            hex_ff: "#f52a65".to_string(),
2038        }
2039    }
2040}
2041
2042impl Default for QueryConfig {
2043    fn default() -> Self {
2044        Self {
2045            history_limit: 1000,
2046            history: true,
2047            default_mode: QueryMode::default(),
2048        }
2049    }
2050}
2051
2052/// Maximum depth of an `import` chain; deeper is a mistake, reported clearly.
2053const MAX_IMPORT_DEPTH: usize = 8;
2054
2055/// Expand a leading `~` and any `$VAR` / `${VAR}` in a config path; unset variables
2056/// expand to nothing, as in a shell.
2057pub fn expand_config_path(raw: &str) -> PathBuf {
2058    expand_path(raw)
2059}
2060
2061/// `path` with a leading `~` expanded, nothing else: for command-line paths, which
2062/// cmd, older PowerShell and quoting pass through with `~` (a `$` was already
2063/// expanded and is part of a name). A path that exists as typed (a file named `~`)
2064/// is kept.
2065pub fn expand_home(path: &Path) -> PathBuf {
2066    expand_home_unless(path, |p| p.symlink_metadata().is_ok())
2067}
2068
2069fn expand_home_unless(path: &Path, there: impl FnOnce(&Path) -> bool) -> PathBuf {
2070    path.to_str()
2071        .and_then(home_path)
2072        .filter(|_| !there(path))
2073        .unwrap_or_else(|| path.to_path_buf())
2074}
2075
2076/// `~`, `~/x` and, on Windows, `~\x` under the home directory; `None` for anything
2077/// else, or with no home directory.
2078fn home_path(text: &str) -> Option<PathBuf> {
2079    if text == "~" {
2080        return dirs::home_dir();
2081    }
2082    let rest = text
2083        .strip_prefix("~/")
2084        // What `display_path` writes there, and what a Windows user types.
2085        .or_else(|| text.strip_prefix("~\\").filter(|_| cfg!(windows)))?;
2086    dirs::home_dir().map(|home| home.join(rest))
2087}
2088
2089/// `path` normalized without the filesystem: rebuilt from components so separators
2090/// compare equal (Windows mixes `\` and `/` after expansion), `.` and a trailing
2091/// separator dropped. `..` stays: past a symlink it is not the textual parent.
2092pub(crate) fn path_place(path: &Path) -> PathBuf {
2093    use std::path::{Component, Prefix};
2094    let mut place = PathBuf::new();
2095    for component in path.components() {
2096        match component {
2097            Component::CurDir => {}
2098            Component::Prefix(prefix) => match prefix.kind() {
2099                Prefix::Disk(drive) => {
2100                    place.push(format!("{}:", char::from(drive.to_ascii_uppercase())));
2101                }
2102                Prefix::UNC(server, share) => {
2103                    let mut unc = std::ffi::OsString::from(r"\\");
2104                    unc.push(server);
2105                    unc.push(r"\");
2106                    unc.push(share);
2107                    place.push(unc);
2108                }
2109                _ => place.push(prefix.as_os_str()),
2110            },
2111            other => place.push(other),
2112        }
2113    }
2114    place
2115}
2116
2117pub(crate) fn expand_path(raw: &str) -> PathBuf {
2118    let mut expanded = String::with_capacity(raw.len());
2119    let mut chars = raw.chars().peekable();
2120
2121    while let Some(c) = chars.next() {
2122        if c != '$' {
2123            expanded.push(c);
2124            continue;
2125        }
2126
2127        let braced = chars.peek() == Some(&'{');
2128        if braced {
2129            chars.next();
2130        }
2131
2132        let mut name = String::new();
2133        while let Some(&next) = chars.peek() {
2134            if braced && next == '}' {
2135                chars.next();
2136                break;
2137            }
2138            if !next.is_ascii_alphanumeric() && next != '_' {
2139                break;
2140            }
2141            name.push(next);
2142            chars.next();
2143        }
2144
2145        if name.is_empty() {
2146            // A bare `$`, or `${}` — leave it as written rather than guessing.
2147            expanded.push('$');
2148        } else if let Ok(value) = std::env::var(&name) {
2149            expanded.push_str(&value);
2150        }
2151    }
2152
2153    // `~` expands only at the start of the path, as in a shell.
2154    home_path(&expanded).unwrap_or_else(|| PathBuf::from(expanded))
2155}
2156
2157/// One config file's settings as written. Partial, so a later file can set a value
2158/// back to its default and an omitted key changes nothing;
2159/// [`AppConfig::from_layers`] fills defaults once after merging.
2160#[derive(Debug, Clone, Default, PartialEq)]
2161pub struct ConfigLayer {
2162    table: toml::Table,
2163    /// The files this one imports, as written. Never merged: a load-time directive.
2164    imports: Vec<String>,
2165}
2166
2167/// Where a layer of the configuration came from.
2168#[derive(Debug, Clone, PartialEq, Eq)]
2169pub enum LayerSource {
2170    /// A config file: the user's, or one it imports.
2171    File(PathBuf),
2172    /// `-c KEY=VALUE` on the command line.
2173    Override,
2174}
2175
2176impl std::fmt::Display for LayerSource {
2177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2178        match self {
2179            Self::File(path) => write!(f, "{}", path.display()),
2180            Self::Override => f.write_str("-c"),
2181        }
2182    }
2183}
2184
2185/// How a key combines across layers when a later layer does not simply replace it.
2186#[derive(Debug, Clone, Copy)]
2187enum Combine {
2188    /// An array of tables matched by `name`: an entry replaces the earlier one of its
2189    /// name, a new name appends; duplicates within one file are kept for validation.
2190    ByName,
2191    /// A list that adds up across files, without repeats.
2192    Union,
2193}
2194
2195/// Keys that do not simply replace: tables merge key by key, these combine; all else
2196/// is replaced whole.
2197const COMBINED_KEYS: &[(&str, Combine)] = &[
2198    ("formats.path", Combine::Union),
2199    ("catalogs", Combine::Union),
2200    ("cloud.connections", Combine::ByName),
2201    ("cloud.hide", Combine::Union),
2202    ("cloud.env_files", Combine::Union),
2203    ("home.hide", Combine::Union),
2204];
2205
2206/// The line after a config mistake saying how to proceed: a missing import is
2207/// skipped, and `datui config init` writes a root file only where there is none.
2208pub fn way_out(imported: bool, what: &str) -> String {
2209    if imported {
2210        format!("Fix that {what}, or move the file aside: a missing import is skipped.")
2211    } else {
2212        format!(
2213            "Fix that {what}, or move the file aside to start from the defaults; \
2214             `datui config init` then writes a fresh one."
2215        )
2216    }
2217}
2218
2219impl ConfigLayer {
2220    /// A layer from TOML text, type-checked here so a mistake names its file.
2221    pub fn parse(text: &str) -> Result<Self> {
2222        let typed: AppConfig = toml::from_str(text)?;
2223        let table: toml::Table = toml::from_str(text)?;
2224        Ok(Self::from_table(table, typed.import))
2225    }
2226
2227    /// The layer `-c KEY=VALUE` makes, the last of one key winning. Keys and shapes were
2228    /// checked on the command line; types are checked here.
2229    pub fn from_overrides(overrides: &[datui_cli::settings::Override]) -> Result<Self> {
2230        let mut table = toml::Table::new();
2231        for o in overrides {
2232            let mut at = &mut table;
2233            let mut parts: Vec<&str> = o.key.split('.').collect();
2234            let last = parts.pop().unwrap_or_default();
2235            for part in parts {
2236                let entry = at
2237                    .entry(part.to_string())
2238                    .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2239                if !entry.is_table() {
2240                    *entry = toml::Value::Table(toml::Table::new());
2241                }
2242                at = entry.as_table_mut().expect("just made a table");
2243            }
2244            at.insert(last.to_string(), o.value.clone());
2245        }
2246        toml::Value::Table(table.clone())
2247            .try_into::<AppConfig>()
2248            .map_err(|e| eyre!("-c: {}", e.message().trim_end()))?;
2249        Ok(Self::from_table(table, Vec::new()))
2250    }
2251
2252    fn from_table(mut table: toml::Table, imports: Vec<String>) -> Self {
2253        table.remove("import");
2254        Self { table, imports }
2255    }
2256
2257    /// The layer in `path`, or `None` without the file. An unreadable or unparsable file
2258    /// is an error naming it and its `importer`, if any.
2259    fn read(path: &Path, importer: Option<&Path>) -> Result<Option<Self>> {
2260        // On the first line, ahead of a parse error's excerpt of the file.
2261        let named = match importer {
2262            Some(importer) => format!("{} (imported by {})", path.display(), importer.display()),
2263            None => path.display().to_string(),
2264        };
2265        let content = match std::fs::read_to_string(path) {
2266            Ok(content) => content,
2267            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2268            Err(e) => return Err(eyre!("Failed to read config file at {named}: {e}")),
2269        };
2270        let mut layer = Self::parse(&content).map_err(|e| {
2271            eyre!(
2272                "Failed to parse config file at {named}: {}\n{}",
2273                parse_reason(&e),
2274                way_out(importer.is_some(), "line")
2275            )
2276        })?;
2277        // Serde skips unknown keys; warn, or a typo changes nothing silently.
2278        for unknown in unknown_keys_in(&layer.table) {
2279            eprintln!("datui: warning: {}: {unknown}", path.display());
2280        }
2281        layer.anchor_paths(path.parent().unwrap_or_else(|| Path::new(".")));
2282        Ok(Some(layer))
2283    }
2284
2285    /// Resolve relative format and catalog paths against `dir` (the naming file's
2286    /// directory) before merging with layers from elsewhere.
2287    fn anchor_paths(&mut self, dir: &Path) {
2288        if let Some(toml::Value::Array(entries)) = self
2289            .table
2290            .get_mut("formats")
2291            .and_then(|f| f.get_mut("path"))
2292        {
2293            for entry in entries {
2294                if let toml::Value::String(path) = entry
2295                    && !path.trim().is_empty()
2296                    && expand_path(path).is_relative()
2297                {
2298                    *path = dir.join(expand_path(path)).to_string_lossy().into_owned();
2299                }
2300            }
2301        }
2302        if let Some(toml::Value::Array(files)) = self.table.get_mut("catalogs") {
2303            for entry in files {
2304                // A path, or a table's `path`.
2305                let path = match entry {
2306                    toml::Value::Table(table) => table.get_mut("path"),
2307                    other => Some(other),
2308                };
2309                if let Some(toml::Value::String(path)) = path
2310                    && !path.trim().is_empty()
2311                    && expand_path(path).is_relative()
2312                {
2313                    *path = dir.join(expand_path(path)).to_string_lossy().into_owned();
2314                }
2315            }
2316        }
2317    }
2318
2319    /// The value this layer writes at the dotted `key`, if it writes one.
2320    pub fn get(&self, key: &str) -> Option<&toml::Value> {
2321        let mut parts = key.split('.');
2322        let mut value = self.table.get(parts.next()?)?;
2323        for part in parts {
2324            value = value.as_table()?.get(part)?;
2325        }
2326        Some(value)
2327    }
2328
2329    /// Lay `upper` over this layer: its keys win (except `COMBINED_KEYS`); keys it
2330    /// omits keep this layer's values. `upper`'s imports are not carried over.
2331    pub fn merge(&mut self, upper: ConfigLayer) {
2332        merge_tables(&mut self.table, upper.table, "");
2333    }
2334}
2335
2336/// Keys 0.4.0 retired, and where what they said goes now.
2337const RETIRED_KEYS: &[(&str, &str)] = &[
2338    (
2339        "sources",
2340        "a collection is a catalog file now; put its datasets in catalog.toml as [id] \
2341         tables, or list the file in catalogs = [...] (datui catalog check FILE)",
2342    ),
2343    (
2344        "home.directories",
2345        "a directory is a catalog entry now; Ctrl+D on its row adds it to catalog.toml",
2346    ),
2347    (
2348        "home.builtin_catalog",
2349        "home.hide = [\"examples\"] hides the example datasets",
2350    ),
2351];
2352
2353/// The keys `table` writes that the option registry does not know, each with its
2354/// nearest known keys, sorted. Registered keys' values (tables like
2355/// `[[cloud.connections]]`) are not inspected.
2356fn unknown_keys_in(table: &toml::Table) -> Vec<String> {
2357    fn walk(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
2358        for (key, value) in table {
2359            let path = if prefix.is_empty() {
2360                key.clone()
2361            } else {
2362                format!("{prefix}.{key}")
2363            };
2364            if datui_cli::settings::find(&path).is_some() {
2365                continue;
2366            }
2367            if let Some((_, moved)) = RETIRED_KEYS.iter().find(|(key, _)| *key == path) {
2368                out.push(format!("{path} is not read any more: {moved}"));
2369                continue;
2370            }
2371            match value {
2372                // A section, known or not: its keys are named individually, so a renamed section's
2373                // keys each find their place.
2374                toml::Value::Table(inner) => walk(inner, &path, out),
2375                _ => {
2376                    let near = datui_cli::settings::suggestions(&path);
2377                    let mut said = format!("{path} is not a config key, and is not read");
2378                    if !near.is_empty() {
2379                        said.push_str(&format!("; did you mean {}?", near.join(" or ")));
2380                    }
2381                    out.push(said);
2382                }
2383            }
2384        }
2385    }
2386    let mut out = Vec::new();
2387    walk(table, "", &mut out);
2388    out.sort();
2389    out
2390}
2391
2392/// A TOML error with reason and place on the first line, then the excerpt (some
2393/// callers, like the Python binding, show only the first line).
2394fn parse_reason(error: &color_eyre::eyre::Report) -> String {
2395    let Some(toml_error) = error.downcast_ref::<toml::de::Error>() else {
2396        return error.to_string();
2397    };
2398    let message = toml_error.message().trim_end();
2399    let full = toml_error.to_string();
2400    let body = full.trim_end().strip_suffix(message).unwrap_or(&full);
2401    match body.split_once('\n') {
2402        Some((head, excerpt)) if head.starts_with("TOML parse error at ") => format!(
2403            "{message} ({})\n{}",
2404            head.trim_start_matches("TOML parse error at "),
2405            excerpt.trim_end()
2406        ),
2407        _ => message.to_string(),
2408    }
2409}
2410
2411fn merge_tables(lower: &mut toml::Table, upper: toml::Table, prefix: &str) {
2412    for (key, value) in upper {
2413        let path = if prefix.is_empty() {
2414            key.clone()
2415        } else {
2416            format!("{prefix}.{key}")
2417        };
2418        let combine = COMBINED_KEYS
2419            .iter()
2420            .find(|(p, _)| *p == path)
2421            .map(|(_, c)| *c);
2422        match (combine, value) {
2423            (Some(combine), toml::Value::Array(upper)) => {
2424                let mut combined = match lower.remove(&key) {
2425                    Some(toml::Value::Array(lower)) => lower,
2426                    _ => Vec::new(),
2427                };
2428                match combine {
2429                    Combine::ByName => merge_by_name(&mut combined, upper),
2430                    Combine::Union => {
2431                        for item in upper {
2432                            if !combined.contains(&item) {
2433                                combined.push(item);
2434                            }
2435                        }
2436                    }
2437                }
2438                lower.insert(key, toml::Value::Array(combined));
2439            }
2440            (None, toml::Value::Table(upper)) => match lower.get_mut(&key) {
2441                Some(toml::Value::Table(lower)) => merge_tables(lower, upper, &path),
2442                _ => {
2443                    lower.insert(key, toml::Value::Table(upper));
2444                }
2445            },
2446            (_, value) => {
2447                lower.insert(key, value);
2448            }
2449        }
2450    }
2451}
2452
2453fn merge_by_name(lower: &mut Vec<toml::Value>, upper: Vec<toml::Value>) {
2454    let name_of = |entry: &toml::Value| {
2455        entry
2456            .get("name")
2457            .and_then(toml::Value::as_str)
2458            .map(str::to_owned)
2459    };
2460    let mut seen = std::collections::HashSet::new();
2461    for entry in upper {
2462        let name = name_of(&entry);
2463        let first = name.clone().is_some_and(|n| seen.insert(n));
2464        match lower
2465            .iter()
2466            .position(|e| name.is_some() && name_of(e) == name)
2467        {
2468            Some(i) if first => lower[i] = entry,
2469            _ => lower.push(entry),
2470        }
2471    }
2472}
2473
2474// Configuration loading and layering
2475impl AppConfig {
2476    /// Load configuration from all layers (default, imports, user config), with
2477    /// `-c KEY=VALUE` over the files.
2478    pub fn load_with(app_name: &str, overrides: &[datui_cli::settings::Override]) -> Result<Self> {
2479        match ConfigManager::new(app_name) {
2480            Ok(manager) => {
2481                Self::load_from_file_with(&manager.config_path("config.toml"), overrides)
2482            }
2483            // No config directory on this platform: defaults are all there is.
2484            Err(_) => {
2485                let layers = vec![ConfigLayer::from_overrides(overrides)?];
2486                let mut config =
2487                    Self::from_layers(layers).map_err(|e| eyre!("Invalid configuration: {}", e))?;
2488                config.read_theme_files(None)?;
2489                config
2490                    .validate()
2491                    .map_err(|e| eyre!("Invalid configuration: {}", e))?;
2492                Ok(config)
2493            }
2494        }
2495    }
2496
2497    /// Load configuration rooted at `config_path` with its `import` chain. Layers, lowest
2498    /// first: defaults, each import in order (depth-first), then the file; each changes
2499    /// only the keys it writes. A missing root means defaults; an unreadable root or
2500    /// existing import is an error; a missing import is skipped with a warning (a theme
2501    /// system may not have generated it yet).
2502    pub fn load_from_file(config_path: &Path) -> Result<Self> {
2503        Self::load_from_file_with(config_path, &[])
2504    }
2505
2506    /// [`Self::load_from_file`], with `-c KEY=VALUE` laid over every file.
2507    pub fn load_from_file_with(
2508        config_path: &Path,
2509        overrides: &[datui_cli::settings::Override],
2510    ) -> Result<Self> {
2511        let layers = Self::read_layers(config_path, overrides)?;
2512        Self::from_read_layers(config_path, overrides, &layers)
2513    }
2514
2515    /// Every layer of the configuration at `config_path`, lowest first: imports
2516    /// depth-first, the file, then `-c`, each named by its origin. A missing root adds
2517    /// nothing.
2518    pub fn read_layers(
2519        config_path: &Path,
2520        overrides: &[datui_cli::settings::Override],
2521    ) -> Result<Vec<(LayerSource, ConfigLayer)>> {
2522        let mut layers = Vec::new();
2523        if let Some(root) = ConfigLayer::read(config_path, None)? {
2524            let canonical = crate::canonical::canonicalize(config_path)
2525                .unwrap_or_else(|_| config_path.to_path_buf());
2526            let mut stack = vec![canonical];
2527            Self::collect_imports(&root.imports, config_path, &mut stack, &mut layers)?;
2528            layers.push((LayerSource::File(config_path.to_path_buf()), root));
2529        }
2530        if !overrides.is_empty() {
2531            layers.push((
2532                LayerSource::Override,
2533                ConfigLayer::from_overrides(overrides)?,
2534            ));
2535        }
2536        Ok(layers)
2537    }
2538
2539    /// The configuration `layers` (from [`Self::read_layers`]) describe, merged over
2540    /// defaults and validated.
2541    pub fn from_read_layers(
2542        config_path: &Path,
2543        overrides: &[datui_cli::settings::Override],
2544        layers: &[(LayerSource, ConfigLayer)],
2545    ) -> Result<Self> {
2546        // A bad value may be the file's or a `-c`'s.
2547        let place = if overrides.is_empty() {
2548            config_path.display().to_string()
2549        } else {
2550            format!("{} with -c", config_path.display())
2551        };
2552        let imports = layers
2553            .iter()
2554            .find(|(source, _)| *source == LayerSource::File(config_path.to_path_buf()))
2555            .map(|(_, root)| root.imports.clone())
2556            .unwrap_or_default();
2557
2558        let mut config = Self::from_layers(layers.iter().map(|(_, layer)| layer.clone()))
2559            .map_err(|e| eyre!("Invalid configuration in {place}: {e}"))?;
2560        // `import` is a load-time directive, never merged; report what the root declared.
2561        config.import = imports;
2562        config.read_theme_files(config_path.parent())?;
2563        // A catalog's mistake names its own file and line.
2564        config.read_catalog_files(config_path.parent())?;
2565        for broken in &config.broken_catalogs {
2566            eprintln!("datui: warning: catalog left out: {}", broken.full());
2567            log::warn!(target: "datui", "catalog left out: {}", broken.full());
2568        }
2569        // A name that hides nothing is likely a typo, but not worth refusing to start.
2570        for name in config.unknown_hidden() {
2571            eprintln!("datui: warning: home.hide: {}", Self::hides_nothing(&name));
2572        }
2573
2574        config.validate().map_err(|e| {
2575            eyre!(
2576                "Invalid configuration in {place}: {e}\n{}",
2577                way_out(false, "setting")
2578            )
2579        })?;
2580
2581        Ok(config)
2582    }
2583
2584    /// Append every file `imports` names to `out`, depth-first, relative to `origin`'s
2585    /// directory. `stack` holds the canonical paths being loaded, to report cycles.
2586    fn collect_imports(
2587        imports: &[String],
2588        origin: &Path,
2589        stack: &mut Vec<PathBuf>,
2590        out: &mut Vec<(LayerSource, ConfigLayer)>,
2591    ) -> Result<()> {
2592        if imports.is_empty() {
2593            return Ok(());
2594        }
2595
2596        if stack.len() >= MAX_IMPORT_DEPTH {
2597            return Err(eyre!(
2598                "config import chain is more than {} files deep (at {}); \
2599                 flatten the chain or remove the extra levels",
2600                MAX_IMPORT_DEPTH,
2601                origin.display()
2602            ));
2603        }
2604
2605        let origin_dir = origin.parent().unwrap_or_else(|| Path::new("."));
2606
2607        for entry in imports {
2608            let expanded = expand_path(entry);
2609            let path = if expanded.is_absolute() {
2610                expanded
2611            } else {
2612                origin_dir.join(expanded)
2613            };
2614
2615            let canonical = crate::canonical::canonicalize(&path).unwrap_or_else(|_| path.clone());
2616            if stack.contains(&canonical) {
2617                return Err(eyre!(
2618                    "circular config import: {} is already being loaded (imported by {})",
2619                    canonical.display(),
2620                    origin.display()
2621                ));
2622            }
2623
2624            let Some(layer) = ConfigLayer::read(&path, Some(origin))? else {
2625                eprintln!(
2626                    "datui: warning: config import not found, skipping: {} (imported by {})",
2627                    path.display(),
2628                    origin.display()
2629                );
2630                continue;
2631            };
2632
2633            stack.push(canonical);
2634            Self::collect_imports(&layer.imports, &path, stack, out)?;
2635            stack.pop();
2636
2637            out.push((LayerSource::File(path), layer));
2638        }
2639
2640        Ok(())
2641    }
2642
2643    /// The configuration `layers` describe, lowest first, over the defaults (resolved
2644    /// once here). Colors start from the built-in theme `theme.dark`/`theme.light` names
2645    /// for the declared mode; `from_read_layers` adds theme files. `import` is left
2646    /// empty. Not validated.
2647    pub fn from_layers(layers: impl IntoIterator<Item = ConfigLayer>) -> Result<Self> {
2648        let mut merged = ConfigLayer::default();
2649        for layer in layers {
2650            merged.merge(layer);
2651        }
2652        let mut table = merged.table;
2653
2654        let theme = table.get_mut("theme").and_then(toml::Value::as_table_mut);
2655        let mode: ThemeMode = match theme.as_ref().and_then(|t| t.get("mode")) {
2656            Some(mode) => mode.clone().try_into()?,
2657            None => ThemeMode::default(),
2658        };
2659        let resolved = mode.resolve();
2660        let colors = theme.and_then(|t| t.remove("colors"));
2661
2662        let mut config: AppConfig = toml::Value::Table(table).try_into()?;
2663
2664        if let Some(toml::Value::Table(colors)) = colors {
2665            config.theme.overrides = colors;
2666        }
2667        config.theme.follow = mode == ThemeMode::Auto;
2668        config.theme.mode = Some(resolved);
2669        // The built-ins only; `from_read_layers` reads the theme files and resolves again.
2670        config
2671            .theme
2672            .use_library(crate::config::themes::Library::default(), resolved);
2673        config.theme.colors = config.theme.palette_for(resolved)?;
2674        config.sync_dataset_access();
2675        Ok(config)
2676    }
2677
2678    /// Resolve `theme.dark` and `theme.light` with `config_dir`'s `themes/` files as well
2679    /// as built-ins. A broken file or unusable name is reported and falls back to the
2680    /// built-in; it never stops startup.
2681    pub fn read_theme_files(&mut self, config_dir: Option<&Path>) -> Result<()> {
2682        let library = crate::config::themes::Library::read(config_dir);
2683        let active = self.theme.mode.unwrap_or_default().resolve();
2684        self.theme.use_library(library, active);
2685        for warning in self.theme.warnings() {
2686            eprintln!("datui: {warning}");
2687            log::warn!(target: "datui", "{warning}");
2688        }
2689        self.theme.colors = self.theme.palette_for(active)?;
2690        Ok(())
2691    }
2692
2693    /// Every catalog, hidden ones included: `catalog.toml`, the listed files, then the
2694    /// bundled `examples` (unless a listed `examples.toml` replaces it).
2695    pub fn catalogs(&self) -> Vec<crate::home::catalog::Catalog> {
2696        let mut all = self.read_catalogs.clone();
2697        if !all.iter().any(|c| c.id == crate::home::catalog::EXAMPLES) {
2698            all.push(crate::home::catalog::bundled());
2699        }
2700        all
2701    }
2702
2703    /// The catalogs home shows: [`Self::catalogs`] less `[home] hide` (a catalog id, or
2704    /// `catalog/id` for one entry).
2705    pub fn shown_catalogs(&self) -> Vec<crate::home::catalog::Catalog> {
2706        self.catalogs()
2707            .into_iter()
2708            .filter(|c| !self.home.hide.contains(&c.id))
2709            .map(|mut c| {
2710                c.datasets.retain(|d| {
2711                    !self
2712                        .home
2713                        .hide
2714                        .iter()
2715                        .any(|h| h.split_once('/') == Some((c.id.as_str(), d.id.as_str())))
2716                });
2717                c
2718            })
2719            .collect()
2720    }
2721
2722    /// Why `name` in `home.hide` hides nothing, with the fix when it is the bundled
2723    /// catalog's old id (`public`, now `examples`).
2724    pub fn hides_nothing(name: &str) -> String {
2725        let old = crate::home::catalog::OLD_EXAMPLES_ID;
2726        let renamed = match name.split_once('/') {
2727            None if name == old => Some(crate::home::catalog::EXAMPLES.to_string()),
2728            Some((catalog, id)) if catalog == old => {
2729                Some(format!("{}/{id}", crate::home::catalog::EXAMPLES))
2730            }
2731            _ => None,
2732        };
2733        match renamed {
2734            Some(new) => format!("`{name}` is now `{new}`: hide = [\"{new}\"]"),
2735            None => format!("no catalog or entry is named {name}"),
2736        }
2737    }
2738
2739    /// The `[home] hide` names that match no catalog or entry, each once.
2740    pub fn unknown_hidden(&self) -> Vec<String> {
2741        let catalogs = self.catalogs();
2742        let mut out: Vec<String> = Vec::new();
2743        for name in &self.home.hide {
2744            let known = match name.split_once('/') {
2745                None => {
2746                    catalogs.iter().any(|c| c.id == *name)
2747                        || self.broken_catalogs.iter().any(|b| b.id == *name)
2748                }
2749                Some((catalog, _)) if self.broken_catalogs.iter().any(|b| b.id == catalog) => true,
2750                Some((catalog, id)) => catalogs
2751                    .iter()
2752                    .any(|c| c.id == catalog && c.datasets.iter().any(|d| d.id == id)),
2753            };
2754            if !known && !out.contains(name) {
2755                out.push(name.clone());
2756            }
2757        }
2758        out
2759    }
2760
2761    /// Read `catalog.toml` from `config_dir`, its `catalogs/*.toml` by name, and every
2762    /// file `catalogs` lists. A missing listed file is skipped with a warning (it may be
2763    /// on an unmounted share).
2764    pub fn read_catalog_files(&mut self, config_dir: Option<&Path>) -> Result<()> {
2765        use crate::home::catalog::{self, Origin};
2766        let mut read: Vec<catalog::Catalog> = Vec::new();
2767        let mut broken: Vec<catalog::Broken> = Vec::new();
2768        let connections = self.cloud.connections.clone();
2769        // A broken file is left out and reported; it must not stop startup.
2770        let take = |found: std::result::Result<Option<catalog::Catalog>, catalog::Broken>,
2771                    read: &mut Vec<catalog::Catalog>,
2772                    broken: &mut Vec<catalog::Broken>|
2773         -> bool {
2774            match found {
2775                Ok(Some(c)) => match c.check_connections(&connections) {
2776                    Ok(()) => {
2777                        read.push(c);
2778                        true
2779                    }
2780                    Err(e) => {
2781                        broken.push(catalog::Broken {
2782                            id: c.id.clone(),
2783                            origin: c.origin,
2784                            file: c.file.clone().unwrap_or_default(),
2785                            line: e.line,
2786                            message: e.message,
2787                        });
2788                        true
2789                    }
2790                },
2791                Ok(None) => false,
2792                Err(b) => {
2793                    broken.push(b);
2794                    true
2795                }
2796            }
2797        };
2798        // Each file, where it was found, and the id and label a `catalogs` table gives it.
2799        let mut files: Vec<(PathBuf, Origin, Option<String>, Option<String>)> = Vec::new();
2800        if let Some(dir) = config_dir {
2801            take(
2802                catalog::load(&dir.join(catalog::MINE_FILE), catalog::MINE, Origin::Mine),
2803                &mut read,
2804                &mut broken,
2805            );
2806            let folder = dir.join(catalog::FOLDER);
2807            let mut found: Vec<PathBuf> = match std::fs::read_dir(&folder) {
2808                Ok(entries) => entries
2809                    .filter_map(|e| e.ok().map(|e| e.path()))
2810                    .filter(|p| {
2811                        p.extension().is_some_and(|x| x == "toml")
2812                            && std::fs::metadata(p).is_ok_and(|m| m.is_file())
2813                    })
2814                    .collect(),
2815                Err(_) => Vec::new(),
2816            };
2817            found.sort();
2818            files.extend(found.into_iter().map(|p| (p, Origin::Folder, None, None)));
2819        }
2820        files.extend(self.catalogs.iter().map(|entry| {
2821            (
2822                expand_path(entry.path()),
2823                Origin::Listed,
2824                entry.id().map(str::to_string),
2825                entry.label().map(str::to_string),
2826            )
2827        }));
2828        for (path, origin, given_id, label) in files {
2829            let id = given_id
2830                .clone()
2831                .unwrap_or_else(|| catalog::id_of_file(&path));
2832            let refuse = |message: String| catalog::Broken {
2833                id: id.clone(),
2834                origin,
2835                file: path.clone(),
2836                line: None,
2837                message,
2838            };
2839            if !is_valid_source_id(&id) || id == catalog::MINE {
2840                broken.push(refuse(format!(
2841                    "\"{id}\" cannot be a catalog's id: lowercase letters, digits and '-', \
2842                     and not \"{}\", which is catalog.toml's. {}",
2843                    catalog::MINE,
2844                    if given_id.is_some() {
2845                        "Give another id = \"...\""
2846                    } else {
2847                        "Rename the file, or list it as { path = \"...\", id = \"...\" }"
2848                    }
2849                )));
2850                continue;
2851            }
2852            if let Some(first) = read.iter().find(|c| c.id == id) {
2853                let first = first.file_name();
2854                broken.push(refuse(format!(
2855                    "{first} and this file are both the catalog \"{id}\". Rename one, or \
2856                     list one as {{ path = \"...\", id = \"...\" }}"
2857                )));
2858                continue;
2859            }
2860            let found = catalog::load(&path, &id, origin).map(|found| {
2861                found.map(|mut listed| {
2862                    if let Some(label) = &label {
2863                        listed.label = label.clone();
2864                    }
2865                    listed
2866                })
2867            });
2868            if !take(found, &mut read, &mut broken) {
2869                eprintln!(
2870                    "datui: warning: catalog not found, skipping: {}",
2871                    path.display()
2872                );
2873            }
2874        }
2875        self.read_catalogs = read;
2876        self.broken_catalogs = broken;
2877        self.catalog_dir = config_dir.map(Path::to_path_buf);
2878        self.sync_dataset_access();
2879        Ok(())
2880    }
2881
2882    /// Derive `[cloud]`'s view of how catalog URLs are read. Called by `from_layers`,
2883    /// `read_catalog_files` and `default`; call it after changing the catalogs by hand.
2884    pub fn sync_dataset_access(&mut self) {
2885        self.cloud.dataset_access = self
2886            .catalogs()
2887            .iter()
2888            .flat_map(|catalog| {
2889                catalog.datasets.iter().filter_map(|dataset| {
2890                    Some(DatasetAccess {
2891                        url: dataset.url.clone()?,
2892                        catalog: catalog.id.clone(),
2893                        auth: dataset.object_store_auth()?,
2894                    })
2895                })
2896            })
2897            .collect();
2898    }
2899
2900    /// Validate configuration values
2901    pub fn validate(&self) -> Result<()> {
2902        let rows = self.analysis.chart_rows;
2903        if rows == 0 || rows > MAX_CHART_ROW_LIMIT {
2904            return Err(eyre!(
2905                "analysis.chart_rows must be between 1 and {MAX_CHART_ROW_LIMIT}, got {rows}"
2906            ));
2907        }
2908
2909        // Resolve number formatting now so bad presets and separator clashes fail at load.
2910        self.display
2911            .number_format
2912            .resolve(self.display.right_align_numbers)?;
2913
2914        let interval = self.read.follow_interval;
2915        if !FOLLOW_INTERVAL.contains(&interval.duration()) {
2916            return Err(eyre!(
2917                "read.follow_interval must be between 10ms and 1m, got {interval}"
2918            ));
2919        }
2920
2921        if let Some(c) = &self.csv.comment {
2922            crate::formats::csv_dialect::check_comment_char(c)
2923                .map_err(|e| eyre!("csv.comment: {e}"))?;
2924        }
2925
2926        if let Some(level) = &self.log.level
2927            && !datui_cli::LOG_LEVELS.contains(&level.as_str())
2928        {
2929            return Err(eyre!(
2930                "log.level must be one of {}, got {level:?}",
2931                datui_cli::LOG_LEVELS.join(", ")
2932            ));
2933        }
2934
2935        self.cloud.validate()?;
2936        for catalog in &self.read_catalogs {
2937            catalog
2938                .check_connections(&self.cloud.connections)
2939                .map_err(|e| eyre!("{}", e.in_file(&catalog.file_name())))?;
2940        }
2941        let hide_name = |name: &str| match name.split_once('/') {
2942            Some((catalog, id)) => is_valid_source_id(catalog) && is_valid_source_id(id),
2943            None => is_valid_source_id(name),
2944        };
2945        if let Some(name) = self.home.hide.iter().find(|name| !hide_name(name)) {
2946            return Err(eyre!(
2947                "home.hide: \"{name}\" is not a catalog id or catalog/id. Use the ids (mine, \
2948                 examples, a listed file's name; examples/nyc-taxis for one entry), not the labels"
2949            ));
2950        }
2951
2952        let parser = ColorParser::new();
2953        self.theme.colors.validate(&parser)?;
2954
2955        crate::glyphs::validate_overrides(&self.glyphs.overrides)
2956            .map_err(|e| eyre!("[glyphs]: {e}"))?;
2957
2958        if crate::clipboard::BackendChoice::parse(&self.clipboard.backend).is_none() {
2959            return Err(eyre!(
2960                "[clipboard] backend must be auto, native or osc52, got {:?}",
2961                self.clipboard.backend
2962            ));
2963        }
2964        if self.clipboard.osc52_limit.bytes() == 0 {
2965            return Err(eyre!("[clipboard] osc52_limit must be greater than 0"));
2966        }
2967        if !crate::cloud::user_agent::is_valid(&self.http.user_agent) {
2968            return Err(eyre!(
2969                "[http] user_agent must be printable ASCII, got {:?}",
2970                self.http.user_agent
2971            ));
2972        }
2973
2974        Ok(())
2975    }
2976}
2977
2978impl ColorConfig {
2979    /// Every slot by name, as the config writes it.
2980    pub(crate) fn slots(&self) -> Vec<(String, String)> {
2981        match toml::Value::try_from(self) {
2982            Ok(toml::Value::Table(table)) => table
2983                .into_iter()
2984                .map(|(name, value)| (name, value.as_str().unwrap_or_default().to_string()))
2985                .collect(),
2986            _ => Vec::new(),
2987        }
2988    }
2989
2990    /// Validate all color strings can be parsed
2991    fn validate(&self, parser: &ColorParser) -> Result<()> {
2992        for (name, value) in self.slots() {
2993            // "default" is no stripe, not a color.
2994            if name == "table_alternate_row" && value == "default" {
2995                continue;
2996            }
2997            parser.parse(&value).map_err(|e| {
2998                eyre!(
2999                    "theme.colors.{name}: {e}. Use a valid color name (e.g. red, cyan, \
3000                     bright_red), hex (#rrggbb), or indexed(0-255)"
3001                )
3002            })?;
3003        }
3004        Ok(())
3005    }
3006}
3007
3008/// Color parser with terminal capability detection
3009pub struct ColorParser {
3010    supports_true_color: bool,
3011    supports_256: bool,
3012    no_color: bool,
3013}
3014
3015impl ColorParser {
3016    /// Create a new ColorParser with automatic terminal capability detection
3017    pub fn new() -> Self {
3018        let no_color = std::env::var("NO_COLOR").is_ok();
3019        let support = supports_color::on(Stream::Stdout);
3020        #[cfg(windows)]
3021        let console = windows_console_true_color(
3022            // `FORCE_COLOR` names a level for `supports_color` to answer with.
3023            std::env::var_os("TERM").is_some() || std::env::var_os("FORCE_COLOR").is_some(),
3024            std::io::IsTerminal::is_terminal(&std::io::stdout()),
3025            crossterm::ansi_support::supports_ansi,
3026        );
3027        #[cfg(not(windows))]
3028        let console = false;
3029
3030        Self {
3031            supports_true_color: console || support.as_ref().is_some_and(|s| s.has_16m),
3032            supports_256: console || support.as_ref().is_some_and(|s| s.has_256),
3033            no_color,
3034        }
3035    }
3036
3037    /// Parse a color string (hex or named) and convert to appropriate terminal color
3038    pub fn parse(&self, s: &str) -> Result<Color> {
3039        if self.no_color {
3040            return Ok(Color::Reset);
3041        }
3042
3043        let trimmed = s.trim();
3044
3045        // Hex format: "#ff0000" or "#FF0000" (6-character hex)
3046        if trimmed.starts_with('#') && trimmed.len() == 7 {
3047            let (r, g, b) = parse_hex(trimmed)?;
3048            return Ok(self.convert_rgb_to_terminal_color(r, g, b));
3049        }
3050
3051        // Indexed colors: "indexed(236)" for explicit 256-color palette
3052        if trimmed.to_lowercase().starts_with("indexed(") && trimmed.ends_with(')') {
3053            let num_str = &trimmed[8..trimmed.len() - 1]; // Extract number between parentheses
3054            let num = num_str.parse::<u8>().map_err(|_| {
3055                eyre!(
3056                    "Invalid indexed color: '{}'. Expected format: indexed(0-255)",
3057                    trimmed
3058                )
3059            })?;
3060            return Ok(Color::Indexed(num));
3061        }
3062
3063        // Named colors (case-insensitive)
3064        let lower = trimmed.to_lowercase();
3065        match lower.as_str() {
3066            // Basic ANSI colors
3067            "black" => Ok(Color::Black),
3068            "red" => Ok(Color::Red),
3069            "green" => Ok(Color::Green),
3070            "yellow" => Ok(Color::Yellow),
3071            "blue" => Ok(Color::Blue),
3072            "magenta" => Ok(Color::Magenta),
3073            "cyan" => Ok(Color::Cyan),
3074            "white" => Ok(Color::White),
3075
3076            // Bright variants (256-color palette)
3077            "bright_black" | "bright black" => Ok(Color::Indexed(8)),
3078            "bright_red" | "bright red" => Ok(Color::Indexed(9)),
3079            "bright_green" | "bright green" => Ok(Color::Indexed(10)),
3080            "bright_yellow" | "bright yellow" => Ok(Color::Indexed(11)),
3081            "bright_blue" | "bright blue" => Ok(Color::Indexed(12)),
3082            "bright_magenta" | "bright magenta" => Ok(Color::Indexed(13)),
3083            "bright_cyan" | "bright cyan" => Ok(Color::Indexed(14)),
3084            "bright_white" | "bright white" => Ok(Color::Indexed(15)),
3085
3086            // Gray aliases
3087            "gray" | "grey" => Ok(Color::Indexed(8)),
3088            "dark_gray" | "dark gray" | "dark_grey" | "dark grey" => Ok(Color::Indexed(8)),
3089            "light_gray" | "light gray" | "light_grey" | "light grey" => Ok(Color::Indexed(7)),
3090
3091            // Special modifiers (pass through as Reset - handled specially in rendering)
3092            "reset" | "default" | "none" | "reversed" => Ok(Color::Reset),
3093
3094            _ => Err(eyre!(
3095                "Unknown color name: '{}'. Supported: basic ANSI colors (red, blue, etc.), \
3096                 bright variants (bright_red, etc.), or hex colors (#ff0000)",
3097                trimmed
3098            )),
3099        }
3100    }
3101
3102    /// Convert RGB values to appropriate terminal color based on capabilities
3103    fn convert_rgb_to_terminal_color(&self, r: u8, g: u8, b: u8) -> Color {
3104        if self.supports_true_color {
3105            Color::Rgb(r, g, b)
3106        } else if self.supports_256 {
3107            Color::Indexed(rgb_to_256_color(r, g, b))
3108        } else {
3109            rgb_to_basic_ansi(r, g, b)
3110        }
3111    }
3112}
3113
3114/// Whether a Windows console draws 24-bit color where `supports_color` cannot tell
3115/// (it reads `TERM`/`COLORTERM`, unset by Windows Terminal and conhost). Both do once
3116/// virtual terminal processing is on (`vt`); legacy consoles refuse it. With `TERM`
3117/// set (mintty, MSYS2) or `FORCE_COLOR`, its answer stands.
3118#[cfg(windows)]
3119fn windows_console_true_color(env_says: bool, terminal: bool, vt: impl FnOnce() -> bool) -> bool {
3120    !env_says && terminal && vt()
3121}
3122
3123impl Default for ColorParser {
3124    fn default() -> Self {
3125        Self::new()
3126    }
3127}
3128
3129/// Parse hex color string (#ff0000) to RGB components
3130fn parse_hex(s: &str) -> Result<(u8, u8, u8)> {
3131    // `len()` counts bytes: slicing at fixed offsets is safe only once the rest is ASCII
3132    // ("#\u{1f600}xy" is also seven bytes).
3133    let hex = s
3134        .strip_prefix('#')
3135        .filter(|hex| hex.len() == 6 && hex.is_ascii())
3136        .ok_or_else(|| {
3137            eyre!(
3138                "Invalid hex color format: '{}'. Expected format: #rrggbb",
3139                s
3140            )
3141        })?;
3142
3143    let r = u8::from_str_radix(&hex[0..2], 16)
3144        .map_err(|_| eyre!("Invalid red component in hex color: {}", s))?;
3145    let g = u8::from_str_radix(&hex[2..4], 16)
3146        .map_err(|_| eyre!("Invalid green component in hex color: {}", s))?;
3147    let b = u8::from_str_radix(&hex[4..6], 16)
3148        .map_err(|_| eyre!("Invalid blue component in hex color: {}", s))?;
3149
3150    Ok((r, g, b))
3151}
3152
3153/// The nearest xterm 256-color palette index to an RGB color.
3154pub fn rgb_to_256_color(r: u8, g: u8, b: u8) -> u8 {
3155    // Nearest palette entry by RGB distance: the cube's levels are far apart, so dark
3156    // tints are often nearer a ramp grey than any cube color.
3157    let dist = |cr: i32, cg: i32, cb: i32| -> i32 {
3158        let (dr, dg, db) = (cr - r as i32, cg - g as i32, cb - b as i32);
3159        dr * dr + dg * dg + db * db
3160    };
3161    const LEVELS: [i32; 6] = [0, 95, 135, 175, 215, 255];
3162    let mut best = (i32::MAX, 16u8);
3163    for (ri, &cr) in LEVELS.iter().enumerate() {
3164        for (gi, &cg) in LEVELS.iter().enumerate() {
3165            for (bi, &cb) in LEVELS.iter().enumerate() {
3166                let d = dist(cr, cg, cb);
3167                if d < best.0 {
3168                    best = (d, 16 + 36 * ri as u8 + 6 * gi as u8 + bi as u8);
3169                }
3170            }
3171        }
3172    }
3173    for i in 0..24u8 {
3174        let v = 8 + 10 * i as i32;
3175        let d = dist(v, v, v);
3176        if d < best.0 {
3177            best = (d, 232 + i);
3178        }
3179    }
3180    best.1
3181}
3182
3183/// Convert RGB to nearest basic ANSI color (8 colors)
3184pub fn rgb_to_basic_ansi(r: u8, g: u8, b: u8) -> Color {
3185    // Simple threshold-based conversion
3186    let r_bright = r > 128;
3187    let g_bright = g > 128;
3188    let b_bright = b > 128;
3189
3190    // Check for grayscale
3191    let max_diff = r.max(g).max(b) as i16 - r.min(g).min(b) as i16;
3192    if max_diff < 30 {
3193        let avg = (r as u16 + g as u16 + b as u16) / 3;
3194        return if avg < 64 { Color::Black } else { Color::White };
3195    }
3196
3197    match (r_bright, g_bright, b_bright) {
3198        (false, false, false) => Color::Black,
3199        (true, false, false) => Color::Red,
3200        (false, true, false) => Color::Green,
3201        (true, true, false) => Color::Yellow,
3202        (false, false, true) => Color::Blue,
3203        (true, false, true) => Color::Magenta,
3204        (false, true, true) => Color::Cyan,
3205        (true, true, true) => Color::White,
3206    }
3207}
3208
3209/// Theme containing parsed colors ready for use
3210#[derive(Debug, Clone)]
3211pub struct Theme {
3212    pub colors: HashMap<String, Color>,
3213}
3214
3215/// The theme's chart series slots, `chart_1` to `chart_10`.
3216pub const CHART_SERIES_SLOTS: usize = 10;
3217
3218/// Typed accessors for the theme's color slots, so a misspelled slot does not compile
3219/// rather than drawing in `Color::Reset`.
3220macro_rules! color_slots {
3221    ($($slot:ident),* $(,)?) => {
3222        impl Theme {
3223            $(
3224                pub fn $slot(&self) -> Color {
3225                    self.get(stringify!($slot))
3226                }
3227            )*
3228        }
3229
3230        /// Every slot with an accessor, for the test that the theme defines each.
3231        #[cfg(test)]
3232        pub(crate) const COLOR_SLOTS: &[&str] = &[$(stringify!($slot)),*];
3233    };
3234}
3235
3236color_slots!(
3237    accent,
3238    accent_bright,
3239    background,
3240    chart_1,
3241    chart_grid,
3242    chip_key,
3243    chip_label,
3244    controls_bg,
3245    dimmed,
3246    distribution_normal,
3247    distribution_skewed,
3248    error,
3249    find_match,
3250    gradient_end,
3251    gradient_start,
3252    hex_control,
3253    hex_ff,
3254    hex_high,
3255    hex_null,
3256    hex_printable,
3257    hex_whitespace,
3258    input_cursor,
3259    label,
3260    modal_border,
3261    modal_border_active,
3262    modal_border_error,
3263    outlier_marker,
3264    sidebar_border,
3265    success,
3266    surface,
3267    table_column_separator,
3268    table_header,
3269    table_header_bg,
3270    table_row_numbers,
3271    text_inverse,
3272    text_primary,
3273    text_secondary,
3274    throbber,
3275    type_binary,
3276    type_bool,
3277    type_float,
3278    type_int,
3279    type_str,
3280    type_temporal,
3281    warning,
3282);
3283
3284impl Theme {
3285    /// Create a Theme from a ThemeConfig by parsing all color strings
3286    pub fn from_config(config: &ThemeConfig) -> Result<Self> {
3287        let parser = ColorParser::new();
3288        let mut colors = HashMap::new();
3289        for (name, value) in config.colors.slots() {
3290            // Left out of the map so widgets can tell them apart via `get_optional`: "reversed"
3291            // swaps the current row's colors, "default" is no stripe.
3292            let absent = match name.as_str() {
3293                "table_selected" => value.trim().eq_ignore_ascii_case("reversed"),
3294                "table_alternate_row" => value == "default",
3295                _ => false,
3296            };
3297            if !absent {
3298                colors.insert(name, parser.parse(&value)?);
3299            }
3300        }
3301        // Sidebars and the input strip draw their resting border from `modal_border` (the
3302        // config's `sidebar_border`); labels are secondary text.
3303        colors.insert("modal_border".to_string(), colors["sidebar_border"]);
3304        colors.insert("label".to_string(), colors["text_secondary"]);
3305        Ok(Self { colors })
3306    }
3307
3308    /// The color in slot `name`, `Reset` where there is none. Read through the typed
3309    /// accessors ([`color_slots`]).
3310    fn get(&self, name: &str) -> Color {
3311        self.colors.get(name).copied().unwrap_or(Color::Reset)
3312    }
3313
3314    /// Get a color by name, returns None if not found
3315    pub fn get_optional(&self, name: &str) -> Option<Color> {
3316        self.colors.get(name).copied()
3317    }
3318
3319    /// The colors series are drawn in: `chart_1`..`chart_10` as this terminal shows them,
3320    /// deduplicated so two series never share a color; a chart draws at most this many.
3321    pub fn series_colors(&self) -> Vec<Color> {
3322        let mut colors: Vec<Color> = Vec::with_capacity(CHART_SERIES_SLOTS);
3323        for i in 1..=CHART_SERIES_SLOTS {
3324            let color = self.get(&format!("chart_{i}"));
3325            if !colors.contains(&color) {
3326                colors.push(color);
3327            }
3328        }
3329        colors
3330    }
3331
3332    /// Style of the row or item the cursor is on: the theme's tint, or reversed video
3333    /// when `table_selected = "reversed"`.
3334    pub fn highlight_style(&self) -> ratatui::style::Style {
3335        match self.get_optional("table_selected") {
3336            Some(bg) => ratatui::style::Style::default().bg(bg),
3337            None => {
3338                ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
3339            }
3340        }
3341    }
3342
3343    /// Style of the column cursor's cells; see [`column_cursor_style`].
3344    pub fn column_cursor_style(&self) -> ratatui::style::Style {
3345        column_cursor_style(self.get_optional("table_column_cursor"))
3346    }
3347
3348    /// Style of the column cursor's header and the current cell; see
3349    /// [`cell_cursor_style`].
3350    pub fn cell_cursor_style(&self) -> ratatui::style::Style {
3351        cell_cursor_style(self.get_optional("table_cell_cursor"))
3352    }
3353
3354    /// Selected text in a field: the highlight tint, or reversed video where the tint
3355    /// may match the background (16 colors, `NO_COLOR`); a field has no rail instead.
3356    pub fn text_selection_style(&self) -> ratatui::style::Style {
3357        match self.get_optional("table_selected") {
3358            Some(Color::Reset | Color::Black | Color::White) => {
3359                ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
3360            }
3361            _ => self.highlight_style(),
3362        }
3363    }
3364
3365    /// The cell a find landed on: the `find_match` tint under black or white text,
3366    /// whichever reads on it, or reversed bold video where there is no color.
3367    pub fn find_match_style(&self) -> ratatui::style::Style {
3368        use ratatui::style::{Modifier, Style};
3369        match self.get("find_match") {
3370            Color::Reset => Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
3371            bg => Style::default().bg(bg).fg(contrasting_text(bg)),
3372        }
3373    }
3374
3375    /// Text color for the solid cursor block: `cursor_text`, or black/white by the
3376    /// cursor's luminance when "default"; here so widgets never pick colors.
3377    pub fn cursor_text_for(&self, cursor: Color) -> Color {
3378        match self.get("input_cursor_text") {
3379            Color::Reset => contrasting_text(cursor),
3380            configured => configured,
3381        }
3382    }
3383}
3384
3385/// Whether a tint can be told from the terminal's own background: a 16-color terminal
3386/// turns the default tints into black or white, and `NO_COLOR` into none.
3387pub fn tint_shows(tint: Option<Color>) -> Option<Color> {
3388    tint.filter(|c| !matches!(c, Color::Reset | Color::Black | Color::White))
3389}
3390
3391/// Style of the column cursor's cells: the `column_cursor` tint, or nothing where the
3392/// tint would not show; the header and the current cell still mark the column there.
3393pub fn column_cursor_style(tint: Option<Color>) -> ratatui::style::Style {
3394    match tint_shows(tint) {
3395        Some(bg) => ratatui::style::Style::default().bg(bg),
3396        None => ratatui::style::Style::default(),
3397    }
3398}
3399
3400/// Style of the column cursor's header and of the current cell: the `cell_cursor`
3401/// tint in bold, or reversed video where the tint would not show, so the cell is
3402/// marked on any terminal.
3403pub fn cell_cursor_style(tint: Option<Color>) -> ratatui::style::Style {
3404    use ratatui::style::{Modifier, Style};
3405    match tint_shows(tint) {
3406        Some(bg) => Style::default().bg(bg).add_modifier(Modifier::BOLD),
3407        None => Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
3408    }
3409}
3410
3411/// Black or white, whichever reads on a solid block of `bg` (Rec. 601 luma).
3412fn contrasting_text(bg: Color) -> Color {
3413    let (r, g, b) = approx_rgb(bg);
3414    let luma = 299 * r as u32 + 587 * g as u32 + 114 * b as u32;
3415    if luma >= 128_000 {
3416        Color::Black
3417    } else {
3418        Color::White
3419    }
3420}
3421
3422/// An approximate RGB for any terminal color (xterm defaults), good enough to pick
3423/// black or white text.
3424fn approx_rgb(color: Color) -> (u8, u8, u8) {
3425    match color {
3426        Color::Rgb(r, g, b) => (r, g, b),
3427        Color::Indexed(i) => xterm_rgb(i),
3428        Color::Black => (0, 0, 0),
3429        Color::Red => (205, 0, 0),
3430        Color::Green => (0, 205, 0),
3431        Color::Yellow => (205, 205, 0),
3432        Color::Blue => (0, 0, 238),
3433        Color::Magenta => (205, 0, 205),
3434        Color::Cyan => (0, 205, 205),
3435        Color::Gray => (229, 229, 229),
3436        Color::DarkGray => (127, 127, 127),
3437        Color::LightRed => (255, 0, 0),
3438        Color::LightGreen => (0, 255, 0),
3439        Color::LightYellow => (255, 255, 0),
3440        Color::LightBlue => (92, 92, 255),
3441        Color::LightMagenta => (255, 0, 255),
3442        Color::LightCyan => (0, 255, 255),
3443        Color::White => (255, 255, 255),
3444        Color::Reset => (0, 0, 0),
3445    }
3446}
3447
3448/// The standard xterm 256-color palette entry, as RGB.
3449fn xterm_rgb(i: u8) -> (u8, u8, u8) {
3450    match i {
3451        0..=15 => approx_rgb(match i {
3452            0 => Color::Black,
3453            1 => Color::Red,
3454            2 => Color::Green,
3455            3 => Color::Yellow,
3456            4 => Color::Blue,
3457            5 => Color::Magenta,
3458            6 => Color::Cyan,
3459            7 => Color::Gray,
3460            8 => Color::DarkGray,
3461            9 => Color::LightRed,
3462            10 => Color::LightGreen,
3463            11 => Color::LightYellow,
3464            12 => Color::LightBlue,
3465            13 => Color::LightMagenta,
3466            14 => Color::LightCyan,
3467            _ => Color::White,
3468        }),
3469        16..=231 => {
3470            let level = |n: u8| if n == 0 { 0 } else { 55 + 40 * n };
3471            let c = i - 16;
3472            (level(c / 36), level(c / 6 % 6), level(c % 6))
3473        }
3474        232..=255 => {
3475            let v = 8 + 10 * (i - 232);
3476            (v, v, v)
3477        }
3478    }
3479}
3480
3481/// `text` in lines of at most `width` characters, broken between words.
3482fn wrap(text: &str, width: usize) -> Vec<String> {
3483    let mut lines: Vec<String> = Vec::new();
3484    for word in text.split_whitespace() {
3485        match lines.last_mut() {
3486            Some(line) if line.len() + 1 + word.len() <= width => {
3487                line.push(' ');
3488                line.push_str(word);
3489            }
3490            _ => lines.push(word.to_string()),
3491        }
3492    }
3493    lines
3494}
3495
3496#[cfg(test)]
3497mod tests {
3498    use std::path::{Path, PathBuf};
3499
3500    /// A path from the command line has been through the shell: only a leading `~`
3501    /// is left for datui to expand.
3502    #[test]
3503    fn a_command_line_path_expands_only_a_leading_tilde() {
3504        let home = dirs::home_dir().expect("a home directory");
3505        let expand = |p: &str| super::expand_home(Path::new(p));
3506        assert_eq!(expand("~"), home);
3507        assert_eq!(expand("~/data/a.csv"), home.join("data/a.csv"));
3508        for kept in [
3509            "a/~/b.csv",
3510            "~user/a.csv",
3511            "$HOME/a.csv",
3512            "-",
3513            "s3://b/~/a.csv",
3514        ] {
3515            assert_eq!(expand(kept), PathBuf::from(kept), "{kept}");
3516        }
3517        #[cfg(windows)]
3518        assert_eq!(expand(r"~\data\a.csv"), home.join(r"data\a.csv"));
3519        // A backslash is part of a name off Windows.
3520        #[cfg(not(windows))]
3521        assert_eq!(expand(r"~\a.csv"), PathBuf::from(r"~\a.csv"));
3522        // A file named `~`, or under a directory named `~`, is that file.
3523        for there in ["~", "~/a.csv"] {
3524            let kept = super::expand_home_unless(Path::new(there), |_| true);
3525            assert_eq!(kept, PathBuf::from(there), "{there}");
3526        }
3527    }
3528
3529    /// Under `NO_COLOR` every color, named or hex, is no color. Set on the parser
3530    /// rather than in the environment, which other tests read.
3531    #[test]
3532    fn no_color_parses_every_color_as_reset() {
3533        use ratatui::style::Color;
3534        let parser = super::ColorParser {
3535            supports_true_color: true,
3536            supports_256: true,
3537            no_color: true,
3538        };
3539        for name in ["red", "#ff0000", "cyan", "indexed(240)"] {
3540            assert_eq!(parser.parse(name).unwrap(), Color::Reset, "{name}");
3541        }
3542    }
3543
3544    /// Windows Terminal and conhost set no `TERM`; with virtual terminal processing
3545    /// on, they take 24-bit color. A legacy console, or a terminal that sets `TERM`
3546    /// for `supports_color` to read, is left to it.
3547    #[cfg(windows)]
3548    #[test]
3549    fn a_windows_console_with_vt_takes_true_color() {
3550        use super::windows_console_true_color as rule;
3551        assert!(rule(false, true, || true));
3552        assert!(!rule(false, true, || false), "a legacy console");
3553        assert!(
3554            !rule(true, true, || true),
3555            "TERM or FORCE_COLOR set: supports_color decides"
3556        );
3557        assert!(!rule(false, false, || true), "not a terminal");
3558    }
3559
3560    #[test]
3561    fn a_path_place_ignores_spelling_but_not_meaning() {
3562        let place = |p: &str| super::path_place(std::path::Path::new(p));
3563        for (a, b) in [
3564            ("/d/a.csv", "/d//a.csv"),
3565            ("/d/a.csv", "/d/./a.csv"),
3566            ("/d/sub", "/d/sub/"),
3567            ("a.csv", "./a.csv"),
3568        ] {
3569            assert_eq!(place(a), place(b), "{a} and {b}");
3570        }
3571        for (a, b) in [
3572            ("/d/../a.csv", "/a.csv"),
3573            ("/d/a.csv", "/d/A.csv"),
3574            ("/d/a.csv", "d/a.csv"),
3575            ("/d/a.csv", "/d/a.csv.gz"),
3576        ] {
3577            assert_ne!(place(a), place(b), "{a} and {b}");
3578        }
3579        #[cfg(windows)]
3580        {
3581            for (a, b) in [
3582                (r"C:\d\a.csv", r"c:\d\a.csv"),
3583                (r"C:\d\a.csv", "C:/d/a.csv"),
3584                (r"\\srv\share\a.csv", "//srv/share/a.csv"),
3585            ] {
3586                assert_eq!(place(a), place(b), "{a} and {b}");
3587            }
3588            for (a, b) in [
3589                (r"C:\d\a.csv", r"D:\d\a.csv"),
3590                (r"C:\a.csv", "C:a.csv"),
3591                (r"\\srv\share\a.csv", r"\\srv\other\a.csv"),
3592            ] {
3593                assert_ne!(place(a), place(b), "{a} and {b}");
3594            }
3595        }
3596    }
3597
3598    use super::*;
3599
3600    /// Each typed accessor names a slot the theme has: a slot it lacks would draw in
3601    /// `Color::Reset`.
3602    #[test]
3603    fn every_color_accessor_names_a_slot() {
3604        let theme = Theme::from_config(&AppConfig::default().theme).unwrap();
3605        for slot in COLOR_SLOTS {
3606            assert!(theme.colors.contains_key(*slot), "no {slot} slot");
3607        }
3608    }
3609
3610    #[test]
3611    fn a_key_the_registry_does_not_know_is_named_with_the_nearest() {
3612        let found = |text: &str| unknown_keys_in(&toml::from_str(text).unwrap());
3613        let unknown = found(
3614            "[file_loading]\ncomment_char = \"#\"\n[display]\nmouse = false\nrow_numbr = true\n\
3615             number_format = { grouping = \"thousands\" }\n[glyphs]\nspinner = [\"a\"]\n\
3616             [theme.colors]\naccent = \"red\"\n[[cloud.connections]]\nname = \"x\"\n\
3617             [[sources]]\nname = \"x\"\n[home]\ndirectories = [\"/d\"]\n",
3618        );
3619        assert_eq!(unknown.len(), 4, "{unknown:?}");
3620        // A retired key says where what it said goes now.
3621        assert!(
3622            unknown[2].starts_with("home.directories is not read any more")
3623                && unknown[2].contains("Ctrl+D"),
3624            "{unknown:?}"
3625        );
3626        assert!(
3627            unknown[3].starts_with("sources is not read any more")
3628                && unknown[3].contains("catalog.toml"),
3629            "{unknown:?}"
3630        );
3631        assert!(
3632            unknown[0].starts_with("display.row_numbr")
3633                && unknown[0].contains("display.row_numbers")
3634        );
3635        assert!(
3636            unknown[1].starts_with("file_loading.comment_char")
3637                && unknown[1].contains("csv.comment")
3638        );
3639    }
3640
3641    #[test]
3642    fn a_field_selection_stays_visible_when_the_tint_degrades() {
3643        use ratatui::style::{Modifier, Style};
3644        let theme_with = |tint: Option<Color>| Theme {
3645            colors: tint
3646                .map(|c| HashMap::from([("table_selected".to_string(), c)]))
3647                .unwrap_or_default(),
3648        };
3649        let reversed = Style::default().add_modifier(Modifier::REVERSED);
3650        // The default tints on a 16-color terminal, and NO_COLOR.
3651        for tint in [Color::Black, Color::White, Color::Reset] {
3652            assert_eq!(
3653                theme_with(Some(tint)).text_selection_style(),
3654                reversed,
3655                "{tint:?}"
3656            );
3657        }
3658        assert_eq!(theme_with(None).text_selection_style(), reversed);
3659        for tint in [
3660            Color::Rgb(0x28, 0x34, 0x57),
3661            Color::Indexed(237),
3662            Color::Blue,
3663        ] {
3664            assert_eq!(
3665                theme_with(Some(tint)).text_selection_style(),
3666                Style::default().bg(tint)
3667            );
3668        }
3669    }
3670
3671    /// Every setting the defaults serialize, plus the ones unset by default, as dotted
3672    /// paths with their values. Arrays of tables (`[[sources]]`) are not settings.
3673    fn leaf_settings() -> Vec<(String, toml::Value)> {
3674        fn walk(table: &toml::Table, prefix: &str, out: &mut Vec<(String, toml::Value)>) {
3675            for (key, value) in table {
3676                let path = if prefix.is_empty() {
3677                    key.clone()
3678                } else {
3679                    format!("{prefix}.{key}")
3680                };
3681                match value {
3682                    toml::Value::Table(inner) => walk(inner, &path, out),
3683                    toml::Value::Array(items) if items.iter().any(toml::Value::is_table) => {}
3684                    _ => out.push((path, value.clone())),
3685                }
3686            }
3687        }
3688        let toml::Value::Table(defaults) = toml::Value::try_from(AppConfig::default()).unwrap()
3689        else {
3690            unreachable!("a struct serializes to a table")
3691        };
3692        let mut out = Vec::new();
3693        walk(&defaults, "", &mut out);
3694        for setting in datui_cli::settings::SETTINGS {
3695            if let datui_cli::settings::DefaultValue::Unset(example) = setting.default
3696                && !setting.key.ends_with(".*")
3697                && setting.kind != datui_cli::settings::Kind::Tables
3698            {
3699                let value: toml::Table = toml::from_str(&format!("v = {example}")).unwrap();
3700                out.push((setting.key.to_string(), value["v"].clone()));
3701            }
3702        }
3703        out
3704    }
3705
3706    /// A layer that writes `value` at the dotted `path` and nothing else.
3707    fn layer_at(path: &str, value: toml::Value) -> Result<ConfigLayer> {
3708        let table = path.rsplit('.').fold(value, |inner, key| {
3709            toml::Value::Table(toml::Table::from_iter([(key.to_string(), inner)]))
3710        });
3711        ConfigLayer::parse(&toml::to_string(&table)?)
3712    }
3713
3714    fn value_at<'a>(table: &'a toml::Table, path: &str) -> Option<&'a toml::Value> {
3715        let (parents, key) = path.rsplit_once('.').unwrap_or(("", path));
3716        let mut table = table;
3717        for part in parents.split('.').filter(|p| !p.is_empty()) {
3718            table = table.get(part)?.as_table()?;
3719        }
3720        table.get(key)
3721    }
3722
3723    /// The registry and the config structs describe the same keys with the same
3724    /// defaults: every key the defaults serialize is registered with that value, and
3725    /// every registered key is one the structs read.
3726    #[test]
3727    fn the_registry_and_the_config_structs_agree() {
3728        use datui_cli::settings::{DefaultValue, Kind, SETTINGS, find};
3729        let toml::Value::Table(defaults) = toml::Value::try_from(AppConfig::default()).unwrap()
3730        else {
3731            unreachable!("a struct serializes to a table")
3732        };
3733        let light = toml::Value::try_from(ColorConfig::light()).unwrap();
3734        for (path, value) in leaf_settings() {
3735            let setting = find(&path).unwrap_or_else(|| panic!("{path} is not registered"));
3736            let registered = match setting.default {
3737                DefaultValue::Value(v) | DefaultValue::Unset(v) => {
3738                    toml::from_str::<toml::Table>(&format!("v = {v}")).unwrap()["v"].clone()
3739                }
3740                DefaultValue::Color { dark, light: lit } => {
3741                    let name = setting.name();
3742                    assert_eq!(
3743                        light.get(name).and_then(|v| v.as_str()),
3744                        Some(lit),
3745                        "{path} (light)"
3746                    );
3747                    toml::Value::String(dark.to_string())
3748                }
3749            };
3750            assert_eq!(value, registered, "{path}: the registry's default differs");
3751        }
3752        for setting in SETTINGS {
3753            if setting.key.ends_with(".*") || setting.kind == Kind::Tables {
3754                continue;
3755            }
3756            let serialized = value_at(&defaults, setting.key).is_some();
3757            let unset = matches!(setting.default, DefaultValue::Unset(_));
3758            assert_eq!(
3759                serialized,
3760                !unset,
3761                "{}: registered as {}set by default",
3762                setting.key,
3763                if unset { "un" } else { "" }
3764            );
3765            // Each value it shows is one the config reads.
3766            let example = match setting.default {
3767                DefaultValue::Value(v) | DefaultValue::Unset(v) => v.to_string(),
3768                DefaultValue::Color { dark, .. } => format!("\"{dark}\""),
3769            };
3770            let value =
3771                toml::from_str::<toml::Table>(&format!("v = {example}")).unwrap()["v"].clone();
3772            layer_at(setting.key, value).unwrap_or_else(|e| panic!("{}: {e}", setting.key));
3773        }
3774    }
3775
3776    #[test]
3777    fn the_generated_config_shows_every_key_and_parses_uncommented() {
3778        let generated = ConfigManager::with_dir(PathBuf::new()).generate_default_config();
3779        let mut shown = std::collections::HashSet::new();
3780        let mut section = String::new();
3781        let mut uncommented = String::new();
3782        for line in generated.lines() {
3783            let Some(line) = line.strip_prefix("# ") else {
3784                uncommented.push_str(line);
3785                uncommented.push('\n');
3786                continue;
3787            };
3788            if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
3789                section = name.to_string();
3790                uncommented.push_str(line);
3791                uncommented.push('\n');
3792            } else if let Some((key, _)) = line.split_once(" = ")
3793                && !key.contains(' ')
3794            {
3795                shown.insert(match section.as_str() {
3796                    "" => key.to_string(),
3797                    s => format!("{s}.{key}"),
3798                });
3799                uncommented.push_str(line);
3800                uncommented.push('\n');
3801            }
3802        }
3803        for (path, _) in leaf_settings() {
3804            assert!(
3805                shown.contains(&path),
3806                "{path} is not in the generated config"
3807            );
3808        }
3809        // Every value shown, uncommented, is a config datui reads.
3810        ConfigLayer::parse(&uncommented).unwrap();
3811    }
3812
3813    #[test]
3814    fn every_setting_written_as_its_default_overrides_an_import() {
3815        // Layers are generic, so no option has merge code of its own; this holds every
3816        // one of them to it. The import moves each setting it can off its default, and
3817        // the user's file then writes every default back.
3818        let defaults = leaf_settings();
3819        let mut import = ConfigLayer::default();
3820        let mut moved = Vec::new();
3821        for (path, value) in &defaults {
3822            // These follow their own rules, tested on their own.
3823            if path == "import" || COMBINED_KEYS.iter().any(|(p, _)| p == path) {
3824                continue;
3825            }
3826            let other = match value {
3827                toml::Value::Boolean(b) => toml::Value::Boolean(!b),
3828                toml::Value::Integer(n) => toml::Value::Integer(n + 1),
3829                toml::Value::Array(items) if items.is_empty() => vec!["x"].into(),
3830                toml::Value::Array(_) => toml::Value::Array(Vec::new()),
3831                // An `auto` that also takes a bool.
3832                toml::Value::String(text) if text == "auto" && path == "display.row_numbers" => {
3833                    true.into()
3834                }
3835                toml::Value::String(text) => format!("{text}0").into(),
3836                other => panic!("{path}: no rule to change {other}"),
3837            };
3838            // A string naming a choice, such as `unicode`, has no generic other value.
3839            if let Ok(layer) = layer_at(path, other.clone()) {
3840                import.merge(layer);
3841                moved.push((path.clone(), other));
3842            }
3843        }
3844        assert!(moved.len() > 100, "only {} settings moved", moved.len());
3845
3846        let serialized = |config: AppConfig| match toml::Value::try_from(config).unwrap() {
3847            toml::Value::Table(table) => table,
3848            _ => unreachable!("a struct serializes to a table"),
3849        };
3850        let kept = serialized(AppConfig::from_layers([import.clone()]).unwrap());
3851        for (path, other) in &moved {
3852            assert_eq!(value_at(&kept, path), Some(other), "{path} was not read");
3853        }
3854
3855        let mut own = ConfigLayer::default();
3856        for (path, value) in &defaults {
3857            own.merge(layer_at(path, value.clone()).unwrap());
3858        }
3859        let restored = serialized(AppConfig::from_layers([import, own]).unwrap());
3860        for (path, _) in &moved {
3861            let default = defaults.iter().find(|(p, _)| p == path).map(|(_, v)| v);
3862            assert_eq!(value_at(&restored, path), default, "{path} kept the import");
3863        }
3864    }
3865}