1pub use crate::catalog::is_object_store_dataset;
2use crate::numfmt::{self, Glob, Grouping, NumberFormat, NumberFormatSettings};
3use color_eyre::Result;
4use color_eyre::eyre::eyre;
5pub use datui_cli::units::{ByteSize, Interval};
6use ratatui::style::Color;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use supports_color::Stream;
11
12#[derive(Clone)]
14pub struct ConfigManager {
15 pub(crate) config_dir: PathBuf,
16}
17
18impl ConfigManager {
19 pub fn with_dir(config_dir: PathBuf) -> Self {
21 Self { config_dir }
22 }
23
24 pub fn new(app_name: &str) -> Result<Self> {
32 #[cfg(test)]
33 crate::cache::isolate_cache();
34 if let Some(dir) = std::env::var_os("DATUI_CONFIG_DIR") {
35 return Ok(Self {
36 config_dir: PathBuf::from(dir),
37 });
38 }
39 if crate::cache::running_as_a_cargo_test() {
40 panic!(
41 "DATUI_CONFIG_DIR is not set: a test would read and write the real \
42 config (saved views included). Call common::isolate_cache() before \
43 building an App or a ConfigManager."
44 );
45 }
46
47 let config_dir = dirs::config_dir()
48 .ok_or_else(|| eyre!("Could not determine config directory"))?
49 .join(app_name);
50
51 Ok(Self { config_dir })
52 }
53
54 pub fn config_dir(&self) -> &Path {
56 &self.config_dir
57 }
58
59 pub fn config_path(&self, path: &str) -> PathBuf {
61 self.config_dir.join(path)
62 }
63
64 pub fn ensure_config_dir(&self) -> Result<()> {
66 if !self.config_dir.exists() {
67 std::fs::create_dir_all(&self.config_dir)?;
68 }
69 Ok(())
70 }
71
72 pub fn ensure_subdir(&self, subdir: &str) -> Result<PathBuf> {
74 let subdir_path = self.config_dir.join(subdir);
75 if !subdir_path.exists() {
76 std::fs::create_dir_all(&subdir_path)?;
77 }
78 Ok(subdir_path)
79 }
80
81 pub fn generate_default_config(&self) -> String {
85 use datui_cli::settings::{DefaultValue, Kind, SECTIONS, in_section};
86 let mut out = String::from(
87 "# datui configuration file (TOML: https://toml.io).\n\
88 # Every setting is commented out at its default; remove the # to change one.\n\
89 # `datui config keys` lists them with the values in effect.\n",
90 );
91 for section in SECTIONS {
92 let settings: Vec<_> = in_section(section.name)
93 .filter(|s| s.kind != Kind::Tables)
94 .collect();
95 if settings.is_empty() {
96 continue;
97 }
98 out.push('\n');
99 if !section.name.is_empty() {
100 out.push_str(&format!(
101 "# {rule}\n# {}\n# {rule}\n# [{}]\n",
102 section.title,
103 section.name,
104 rule = "=".repeat(76)
105 ));
106 }
107 for setting in settings {
108 for line in wrap(setting.doc, 86) {
109 out.push_str(&format!("# {line}\n"));
110 }
111 let value = match setting.default {
112 DefaultValue::Value(v) | DefaultValue::Unset(v) => v.to_string(),
113 DefaultValue::Color { dark, .. } => format!("\"{dark}\""),
114 };
115 if setting.key.ends_with(".*") {
116 out.push_str(&format!("# {value}\n"));
117 } else {
118 out.push_str(&format!("# {} = {value}\n", setting.name()));
119 }
120 }
121 }
122 out
123 }
124
125 pub fn write_default_config(&self, force: bool) -> Result<PathBuf> {
127 let config_path = self.config_path("config.toml");
128
129 if config_path.exists() && !force {
130 return Err(eyre!(
131 "Config file already exists at {}. Use --force to overwrite.",
132 config_path.display()
133 ));
134 }
135
136 self.ensure_config_dir()?;
138
139 let template = self.generate_default_config();
141 write_private(&config_path, &template)?;
142
143 let catalog = self.config_path(crate::catalog::MINE_FILE);
146 if !catalog.exists() {
147 std::fs::write(&catalog, crate::catalog::MINE_TEMPLATE)?;
148 }
149 self.ensure_subdir(crate::catalog::FOLDER)?;
151 self.ensure_subdir(crate::themes::FOLDER)?;
153
154 Ok(config_path)
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Serialize)]
161#[serde(untagged)]
162pub enum CatalogRef {
163 Path(String),
164 Table {
165 path: String,
166 #[serde(skip_serializing_if = "Option::is_none")]
167 id: Option<String>,
168 #[serde(skip_serializing_if = "Option::is_none")]
169 label: Option<String>,
170 },
171}
172
173impl CatalogRef {
174 pub fn path(&self) -> &str {
176 match self {
177 Self::Path(path) | Self::Table { path, .. } => path,
178 }
179 }
180
181 pub fn id(&self) -> Option<&str> {
183 match self {
184 Self::Table { id: Some(id), .. } => Some(id),
185 _ => None,
186 }
187 }
188
189 pub fn label(&self) -> Option<&str> {
191 match self {
192 Self::Table {
193 label: Some(label), ..
194 } => Some(label),
195 _ => None,
196 }
197 }
198}
199
200impl<'de> Deserialize<'de> for CatalogRef {
201 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
202 use serde::de::Error;
203 match toml::Value::deserialize(deserializer)? {
204 toml::Value::String(path) => Ok(Self::Path(path)),
205 toml::Value::Table(table) => {
206 let text = |key: &str| -> Result<Option<String>, D::Error> {
207 match table.get(key) {
208 None => Ok(None),
209 Some(toml::Value::String(s)) => Ok(Some(s.clone())),
210 Some(_) => Err(D::Error::custom(format!(
211 "catalogs: {key} must be a string"
212 ))),
213 }
214 };
215 if let Some(key) = table
216 .keys()
217 .find(|k| !matches!(k.as_str(), "path" | "id" | "label"))
218 {
219 return Err(D::Error::custom(format!(
220 "catalogs: unknown key '{key}'. Expected one of: path, id, label"
221 )));
222 }
223 let path = text("path")?
224 .ok_or_else(|| D::Error::custom("catalogs: a table needs path = \"...\""))?;
225 Ok(Self::Table {
226 path,
227 id: text("id")?,
228 label: text("label")?,
229 })
230 }
231 _ => Err(D::Error::custom(
232 "catalogs: each entry is a path, or { path, id, label }",
233 )),
234 }
235 }
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(default)]
241pub struct AppConfig {
242 pub import: Vec<String>,
244 pub catalogs: Vec<CatalogRef>,
247 #[serde(skip)]
250 pub read_catalogs: Vec<crate::catalog::Catalog>,
251 #[serde(skip)]
253 pub catalog_dir: Option<PathBuf>,
254 #[serde(skip)]
256 pub broken_catalogs: Vec<crate::catalog::Broken>,
257 pub read: ReadConfig,
258 pub csv: CsvConfig,
259 pub display: DisplayConfig,
260 pub performance: PerformanceConfig,
261 pub analysis: AnalysisConfig,
262 pub chart: ChartConfig,
263 pub home: HomeConfig,
264 pub cloud: CloudConfig,
265 pub http: HttpConfig,
266 pub query: QueryConfig,
267 pub views: ViewsConfig,
268 pub clipboard: ClipboardConfig,
269 pub formats: FormatsConfig,
270 pub log: LogConfig,
271 pub theme: ThemeConfig,
272 pub glyphs: GlyphsConfig,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
276#[serde(default)]
277pub struct CloudConfig {
278 #[serde(skip)]
282 pub s3_endpoint_url: Option<String>,
283 #[serde(skip)]
284 pub s3_access_key_id: Option<String>,
285 #[serde(skip)]
286 pub s3_secret_access_key: Option<String>,
287 #[serde(skip)]
288 pub s3_region: Option<String>,
289 #[serde(skip_serializing_if = "Vec::is_empty")]
291 pub connections: Vec<CloudConnectionConfig>,
292 pub hide: Vec<String>,
294 pub use_azure_account_keys: bool,
297 pub env_files: Vec<String>,
300 pub instance_identity: bool,
303 #[serde(skip_serializing_if = "Option::is_none")]
305 pub discover: Option<CloudDiscover>,
306 pub list_on_start: bool,
309 #[serde(skip)]
312 pub dataset_access: Vec<DatasetAccess>,
313}
314
315impl Default for CloudConfig {
316 fn default() -> Self {
317 Self {
318 s3_endpoint_url: None,
319 s3_access_key_id: None,
320 s3_secret_access_key: None,
321 s3_region: None,
322 connections: Vec::new(),
323 hide: Vec::new(),
324 use_azure_account_keys: true,
325 env_files: Vec::new(),
326 instance_identity: false,
327 discover: None,
328 list_on_start: false,
329 dataset_access: Vec::new(),
330 }
331 }
332}
333
334#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct DatasetAccess {
337 pub url: String,
339 pub catalog: String,
341 pub auth: DatasetAuth,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub enum DatasetAuth {
346 Auto,
348 Anonymous,
350 Connection(String),
352}
353
354pub const CLOUD_DISCOVER_KINDS: [&str; 3] = ["s3", "gcs", "azure"];
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362#[serde(try_from = "CloudDiscoverValue", into = "CloudDiscoverValue")]
363pub enum CloudDiscover {
364 All,
365 None,
366 Kinds(Vec<String>),
367}
368
369impl CloudDiscover {
370 pub fn allows(&self, kind: &str) -> bool {
372 match self {
373 CloudDiscover::All => true,
374 CloudDiscover::None => false,
375 CloudDiscover::Kinds(kinds) => kinds.iter().any(|k| k == kind),
376 }
377 }
378
379 fn from_kinds<S: AsRef<str>>(kinds: &[S]) -> std::result::Result<Self, String> {
380 let mut out: Vec<String> = Vec::new();
381 for kind in kinds {
382 let kind = kind.as_ref().trim().to_ascii_lowercase();
383 if !CLOUD_DISCOVER_KINDS.contains(&kind.as_str()) {
384 return Err(format!(
385 "cloud.discover: unknown kind \"{kind}\"; use {}",
386 CLOUD_DISCOVER_KINDS.join(", ")
387 ));
388 }
389 if !out.contains(&kind) {
390 out.push(kind);
391 }
392 }
393 Ok(CloudDiscover::Kinds(out))
394 }
395}
396
397impl std::str::FromStr for CloudDiscover {
398 type Err = String;
399
400 fn from_str(text: &str) -> std::result::Result<Self, String> {
402 match text.trim().to_ascii_lowercase().as_str() {
403 "all" => Ok(CloudDiscover::All),
404 "none" => Ok(CloudDiscover::None),
405 _ => CloudDiscover::from_kinds(&text.split(',').collect::<Vec<_>>()).map_err(|_| {
406 format!(
407 "cloud.discover: \"{text}\" is not \"all\", \"none\", or kinds from {}",
408 CLOUD_DISCOVER_KINDS.join(", ")
409 )
410 }),
411 }
412 }
413}
414
415#[derive(Serialize, Deserialize)]
416#[serde(untagged)]
417enum CloudDiscoverValue {
418 Switch(bool),
419 Word(String),
420 Kinds(Vec<String>),
421}
422
423impl TryFrom<CloudDiscoverValue> for CloudDiscover {
424 type Error = String;
425
426 fn try_from(value: CloudDiscoverValue) -> std::result::Result<Self, String> {
427 match value {
428 CloudDiscoverValue::Switch(true) => Ok(CloudDiscover::All),
429 CloudDiscoverValue::Switch(false) => Ok(CloudDiscover::None),
430 CloudDiscoverValue::Word(word) => word.parse(),
432 CloudDiscoverValue::Kinds(kinds) => CloudDiscover::from_kinds(&kinds),
433 }
434 }
435}
436
437impl From<CloudDiscover> for CloudDiscoverValue {
438 fn from(discover: CloudDiscover) -> Self {
439 match discover {
440 CloudDiscover::All => CloudDiscoverValue::Switch(true),
441 CloudDiscover::None => CloudDiscoverValue::Switch(false),
442 CloudDiscover::Kinds(kinds) => CloudDiscoverValue::Kinds(kinds),
443 }
444 }
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
450#[serde(default)]
451pub struct CloudConnectionConfig {
452 pub name: String,
454 #[serde(skip_serializing_if = "Option::is_none")]
456 pub label: Option<String>,
457 #[serde(skip_serializing_if = "Option::is_none")]
459 pub kind: Option<String>,
460 #[serde(skip_serializing_if = "Vec::is_empty")]
462 pub buckets: Vec<String>,
463 #[serde(skip_serializing_if = "Option::is_none")]
464 pub endpoint_url: Option<String>,
465 #[serde(skip_serializing_if = "Option::is_none")]
466 pub region: Option<String>,
467 #[serde(skip_serializing_if = "Option::is_none")]
469 pub addressing: Option<String>,
470 #[serde(skip_serializing_if = "Option::is_none")]
471 pub access_key_id_env: Option<String>,
472 #[serde(skip_serializing_if = "Option::is_none")]
473 pub secret_access_key_env: Option<String>,
474 #[serde(skip_serializing_if = "Option::is_none")]
475 pub session_token_env: Option<String>,
476 #[serde(skip_serializing_if = "Option::is_none")]
478 pub profile: Option<String>,
479 #[serde(skip_serializing_if = "Option::is_none")]
481 pub configuration: Option<String>,
482 #[serde(skip_serializing_if = "Option::is_none")]
485 pub project: Option<String>,
486 #[serde(skip_serializing_if = "Option::is_none")]
488 pub account: Option<String>,
489 #[serde(skip_serializing_if = "Option::is_none")]
490 pub account_key_env: Option<String>,
491 #[serde(skip_serializing_if = "Option::is_none")]
492 pub sas_env: Option<String>,
493 #[serde(skip_serializing_if = "Option::is_none")]
494 pub connection_string_env: Option<String>,
495 #[serde(skip_serializing_if = "Option::is_none")]
498 pub secret_command: Option<String>,
499 #[serde(skip_serializing_if = "Option::is_none")]
501 pub credentials_file: Option<String>,
502 #[serde(flatten)]
504 pub unknown: std::collections::BTreeMap<String, toml::Value>,
505}
506
507const CLOUD_SOURCE_KEYS: &str = "name, label, kind, buckets, endpoint_url, region, \
509 addressing, access_key_id_env, secret_access_key_env, session_token_env, profile, \
510 configuration, project, account, account_key_env, sas_env, connection_string_env, \
511 secret_command, credentials_file";
512
513pub fn is_valid_source_id(id: &str) -> bool {
517 let bytes = id.as_bytes();
518 !bytes.is_empty()
519 && bytes.len() <= 40
520 && bytes[0] != b'-'
521 && bytes
522 .iter()
523 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
524}
525
526impl CloudConnectionConfig {
527 fn validate(&self) -> Result<()> {
528 let name = &self.name;
529 if name.is_empty() {
530 return Err(eyre!("cloud.connections: every source needs a name"));
531 }
532 if !is_valid_source_id(name) {
533 return Err(eyre!(
534 "cloud.connections: \"{name}\" is not a valid name. Use lowercase letters, digits \
535 and '-', up to 40 characters"
536 ));
537 }
538 for secret in ["access_key_id", "secret_access_key", "session_token"] {
541 if self.unknown.contains_key(secret) {
542 return Err(eyre!(
543 "cloud.connections \"{name}\": {secret} cannot be written in the config. Put it \
544 in an environment variable and name that with {secret}_env"
545 ));
546 }
547 }
548 if !self.unknown.is_empty() {
549 let keys: Vec<String> = self.unknown.keys().map(|k| format!("'{k}'")).collect();
550 return Err(eyre!(
551 "cloud.connections \"{name}\": unknown key{} {}. Expected one of: {}",
552 if keys.len() > 1 { "s" } else { "" },
553 keys.join(", "),
554 CLOUD_SOURCE_KEYS
555 ));
556 }
557 for secret in ["account_key", "sas", "sas_token", "connection_string"] {
558 if self.unknown.contains_key(secret) {
559 return Err(eyre!(
560 "cloud.connections \"{name}\": {secret} cannot be written in the config. Put it \
561 in an environment variable and name that with {}_env",
562 secret.trim_end_matches("_token")
563 ));
564 }
565 }
566 let kind = match self.kind.as_deref() {
567 Some(kind @ ("s3" | "gcs" | "azure")) => kind,
568 Some(other) => {
569 return Err(eyre!(
570 "cloud.connections \"{name}\": kind \"{other}\" is not supported. Expected s3, gcs or azure"
571 ));
572 }
573 None => {
574 return Err(eyre!(
575 "cloud.connections \"{name}\": kind is required (s3, gcs or azure)"
576 ));
577 }
578 };
579 if let Some(command) = &self.secret_command {
580 if !matches!(kind, "s3" | "azure") {
581 return Err(eyre!(
582 "cloud.connections \"{name}\": secret_command applies only to kind = \"s3\" or \"azure\""
583 ));
584 }
585 if command.trim().is_empty() {
586 return Err(eyre!(
587 "cloud.connections \"{name}\": secret_command is not a command line"
588 ));
589 }
590 let clash = if kind == "s3" {
591 [
592 (
593 "secret_access_key_env",
594 self.secret_access_key_env.is_some(),
595 ),
596 ("profile", self.profile.is_some()),
597 ("", false),
598 ]
599 } else {
600 [
601 ("account_key_env", self.account_key_env.is_some()),
602 ("sas_env", self.sas_env.is_some()),
603 (
604 "connection_string_env",
605 self.connection_string_env.is_some(),
606 ),
607 ]
608 };
609 if let Some((field, _)) = clash.iter().find(|(_, set)| *set) {
610 return Err(eyre!(
611 "cloud.connections \"{name}\": secret_command and {field} both say where the \
612 secret comes from. Use one"
613 ));
614 }
615 if kind == "s3" && self.access_key_id_env.is_none() {
616 return Err(eyre!(
617 "cloud.connections \"{name}\": secret_command prints the secret; name the key \
618 ID with access_key_id_env"
619 ));
620 }
621 }
622 if let Some(_file) = &self.credentials_file {
623 if kind != "gcs" {
624 return Err(eyre!(
625 "cloud.connections \"{name}\": credentials_file applies only to kind = \"gcs\""
626 ));
627 }
628 if self.configuration.is_some() {
629 return Err(eyre!(
630 "cloud.connections \"{name}\": credentials_file and configuration both say how \
631 to log in. Use one"
632 ));
633 }
634 }
635 if kind != "azure" {
636 let azure_only = [
637 ("account", self.account.is_some()),
638 ("account_key_env", self.account_key_env.is_some()),
639 ("sas_env", self.sas_env.is_some()),
640 (
641 "connection_string_env",
642 self.connection_string_env.is_some(),
643 ),
644 ];
645 if let Some((field, _)) = azure_only.iter().find(|(_, set)| *set) {
646 return Err(eyre!(
647 "cloud.connections \"{name}\": {field} applies only to kind = \"azure\""
648 ));
649 }
650 } else {
651 let secrets = [
652 self.account_key_env.is_some(),
653 self.sas_env.is_some(),
654 self.connection_string_env.is_some(),
655 ];
656 if secrets.iter().filter(|set| **set).count() > 1 {
657 return Err(eyre!(
658 "cloud.connections \"{name}\": account_key_env, sas_env and \
659 connection_string_env each say how to sign in. Use one"
660 ));
661 }
662 if self.account.is_none() && self.connection_string_env.is_none() {
663 return Err(eyre!(
664 "cloud.connections \"{name}\": an azure source needs account, or \
665 connection_string_env"
666 ));
667 }
668 if !self.buckets.is_empty() {
669 return Err(eyre!(
670 "cloud.connections \"{name}\": buckets does not apply to kind = \"azure\""
671 ));
672 }
673 }
674 if kind != "s3" {
675 let s3_only = [
676 ("endpoint_url", self.endpoint_url.is_some()),
677 ("region", self.region.is_some()),
678 ("addressing", self.addressing.is_some()),
679 ("access_key_id_env", self.access_key_id_env.is_some()),
680 (
681 "secret_access_key_env",
682 self.secret_access_key_env.is_some(),
683 ),
684 ("session_token_env", self.session_token_env.is_some()),
685 ("profile", self.profile.is_some()),
686 ];
687 if let Some((field, _)) = s3_only.iter().find(|(_, set)| *set) {
688 return Err(eyre!(
689 "cloud.connections \"{name}\": {field} applies only to kind = \"s3\""
690 ));
691 }
692 }
693 if kind != "gcs" {
694 let gcs_only = [
695 ("configuration", self.configuration.is_some()),
696 ("project", self.project.is_some()),
697 ];
698 if let Some((field, _)) = gcs_only.iter().find(|(_, set)| *set) {
699 return Err(eyre!(
700 "cloud.connections \"{name}\": {field} applies only to kind = \"gcs\""
701 ));
702 }
703 }
704 if self.profile.is_some()
705 && (self.access_key_id_env.is_some()
706 || self.secret_access_key_env.is_some()
707 || self.session_token_env.is_some())
708 {
709 return Err(eyre!(
710 "cloud.connections \"{name}\": profile and the *_env keys both say where the keys \
711 come from. Use one"
712 ));
713 }
714 if let Some(addressing) = self.addressing.as_deref()
715 && !matches!(addressing, "path" | "virtual")
716 {
717 return Err(eyre!(
718 "cloud.connections \"{name}\": addressing \"{addressing}\" is not valid. Expected path \
719 or virtual"
720 ));
721 }
722 if let Some(bucket) = self.buckets.iter().find(|b| b.contains(['/', '@'])) {
723 return Err(eyre!(
724 "cloud.connections \"{name}\": \"{bucket}\" is not a bucket name{}",
725 if bucket.contains("://") {
726 ". A dataset URL goes in a catalog"
727 } else {
728 ""
729 }
730 ));
731 }
732 Ok(())
733 }
734}
735
736fn write_private(path: &Path, contents: &str) -> Result<()> {
753 #[cfg(unix)]
754 {
755 use std::io::Write;
756 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
757
758 let mut file = std::fs::OpenOptions::new()
759 .write(true)
760 .create(true)
761 .truncate(true)
762 .mode(0o600)
763 .open(path)?;
764 file.write_all(contents.as_bytes())?;
765 file.sync_all()?;
766 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
767 }
768 #[cfg(not(unix))]
769 {
770 std::fs::write(path, contents)?;
771 }
772 Ok(())
773}
774
775pub const S3_ENDPOINT_VARS: [&str; 3] = ["AWS_ENDPOINT_URL_S3", "AWS_ENDPOINT_URL", "AWS_ENDPOINT"];
779
780fn non_blank(value: String) -> Option<String> {
783 let trimmed = value.trim();
784 (!trimmed.is_empty()).then(|| trimmed.to_string())
785}
786
787impl CloudConfig {
788 pub fn from_env(var: &dyn Fn(&str) -> Option<String>) -> Self {
792 let first = |keys: &[&str]| keys.iter().find_map(|key| var(key).and_then(non_blank));
793 Self {
794 s3_endpoint_url: first(&S3_ENDPOINT_VARS),
795 s3_access_key_id: first(&["AWS_ACCESS_KEY_ID"]),
796 s3_secret_access_key: first(&["AWS_SECRET_ACCESS_KEY"]),
797 s3_region: first(&["AWS_REGION", "AWS_DEFAULT_REGION"]),
798 ..Default::default()
799 }
800 }
801
802 pub fn overlay(&mut self, over: Self) {
805 for (slot, value) in [
806 (&mut self.s3_endpoint_url, over.s3_endpoint_url),
807 (&mut self.s3_access_key_id, over.s3_access_key_id),
808 (&mut self.s3_secret_access_key, over.s3_secret_access_key),
809 (&mut self.s3_region, over.s3_region),
810 ] {
811 if let Some(value) = value.and_then(non_blank) {
812 *slot = Some(value);
813 }
814 }
815 }
816
817 pub fn validate(&self) -> Result<()> {
819 for (i, connection) in self.connections.iter().enumerate() {
820 connection.validate()?;
821 if self.connections[..i]
822 .iter()
823 .any(|c| c.name == connection.name)
824 {
825 return Err(eyre!(
826 "cloud.connections: the name \"{}\" is used twice",
827 connection.name
828 ));
829 }
830 }
831 Ok(())
832 }
833}
834
835#[derive(Debug, Clone, Serialize, Deserialize)]
837#[serde(default)]
838pub struct ReadConfig {
839 pub infer_types: InferTypes,
841 pub parquet_schema: ParquetSchema,
843 pub decompress_in_memory: bool,
845 pub temp_dir: Option<String>,
847 pub follow_interval: Interval,
851 pub exact_count_files: usize,
854 pub memory_warning: ByteSize,
857 pub audio_float: bool,
859}
860
861impl ReadConfig {
862 pub fn memory_warning(&self) -> Option<u64> {
865 let bytes = self.memory_warning.bytes();
866 (bytes > 0).then_some(bytes)
867 }
868}
869
870impl Default for ReadConfig {
871 fn default() -> Self {
872 Self {
873 infer_types: InferTypes::Switch(true),
874 parquet_schema: ParquetSchema::Union,
875 decompress_in_memory: false,
876 temp_dir: None,
877 follow_interval: Interval(crate::follow::DEFAULT_INTERVAL),
878 exact_count_files: 50_000,
879 memory_warning: ByteSize::mib(1024),
880 audio_float: false,
881 }
882 }
883}
884
885#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
887#[serde(untagged)]
888pub enum InferTypes {
889 Switch(bool),
890 Columns(Vec<String>),
891}
892
893#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
895#[serde(rename_all = "lowercase")]
896pub enum ParquetSchema {
897 Union,
899 First,
901}
902
903const FOLLOW_INTERVAL: std::ops::RangeInclusive<std::time::Duration> =
906 std::time::Duration::from_millis(10)..=std::time::Duration::from_secs(60);
907
908#[derive(Debug, Clone, Serialize, Deserialize)]
910#[serde(default)]
911pub struct CsvConfig {
912 pub comment: Option<String>,
915 pub header_join: String,
918 pub skip_initial_space: bool,
920 pub null_values: Vec<String>,
922 pub infer_rows: usize,
924 pub ignore_errors: bool,
926}
927
928impl Default for CsvConfig {
929 fn default() -> Self {
930 Self {
931 comment: None,
932 header_join: crate::csv_dialect::DEFAULT_HEADER_JOIN.to_string(),
933 skip_initial_space: false,
934 null_values: Vec::new(),
935 infer_rows: 1000,
936 ignore_errors: false,
937 }
938 }
939}
940
941#[derive(Debug, Clone, Serialize, Deserialize)]
942#[serde(default)]
943pub struct DisplayConfig {
944 pub unicode: crate::glyphs::UnicodeMode,
946 pub row_numbers: RowNumbers,
947 pub row_numbers_start: usize,
949 pub cell_padding: CellPadding,
951 pub column_colors: bool,
953 pub type_row: bool,
955 pub notes_accent: bool,
959 pub mouse: bool,
962 pub sidebar_width: Option<u16>,
965 pub right_align_numbers: bool,
967 pub number_format: NumberFormatConfig,
970}
971
972#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
975pub enum RowNumbers {
976 #[default]
977 Auto,
978 On,
979 Off,
980}
981
982impl RowNumbers {
983 pub fn for_format(self, format: Option<crate::FileFormat>) -> bool {
985 match self {
986 Self::On => true,
987 Self::Off => false,
988 Self::Auto => matches!(
989 format,
990 Some(crate::FileFormat::Text | crate::FileFormat::Journal)
991 ),
992 }
993 }
994}
995
996impl From<bool> for RowNumbers {
997 fn from(on: bool) -> Self {
998 if on { Self::On } else { Self::Off }
999 }
1000}
1001
1002impl Serialize for RowNumbers {
1003 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1004 match self {
1005 Self::Auto => serializer.serialize_str("auto"),
1006 Self::On => serializer.serialize_bool(true),
1007 Self::Off => serializer.serialize_bool(false),
1008 }
1009 }
1010}
1011
1012impl<'de> Deserialize<'de> for RowNumbers {
1013 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1014 use serde::de::Error;
1015 #[derive(Deserialize)]
1016 #[serde(untagged)]
1017 enum Raw {
1018 Bool(bool),
1019 Name(String),
1020 }
1021 const EXPECTED: &str = "row_numbers is \"auto\", true or false";
1022 match Raw::deserialize(deserializer).map_err(|_| D::Error::custom(EXPECTED))? {
1023 Raw::Bool(on) => Ok(on.into()),
1024 Raw::Name(name) if name == "auto" => Ok(Self::Auto),
1025 Raw::Name(other) => Err(D::Error::custom(format!("{EXPECTED}, not {other:?}"))),
1026 }
1027 }
1028}
1029
1030#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1033#[serde(untagged)]
1034pub enum CellPadding {
1035 Cells(usize),
1036 Density(Density),
1037}
1038
1039#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1041#[serde(rename_all = "lowercase")]
1042pub enum Density {
1043 Compact,
1045 Comfortable,
1047}
1048
1049impl Default for CellPadding {
1050 fn default() -> Self {
1051 Self::Density(Density::Comfortable)
1052 }
1053}
1054
1055impl CellPadding {
1056 pub fn cells(self) -> u16 {
1058 match self {
1059 Self::Cells(n) => u16::try_from(n).unwrap_or(u16::MAX),
1060 Self::Density(Density::Compact) => 1,
1061 Self::Density(Density::Comfortable) => 2,
1062 }
1063 }
1064}
1065
1066impl<'de> Deserialize<'de> for CellPadding {
1067 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1068 use serde::de::Error;
1069 #[derive(Deserialize)]
1070 #[serde(untagged)]
1071 enum Raw {
1072 Cells(usize),
1073 Name(String),
1074 }
1075 const EXPECTED: &str = "cell_padding is \"compact\", \"comfortable\" or a number of cells";
1076 match Raw::deserialize(deserializer).map_err(|_| D::Error::custom(EXPECTED))? {
1077 Raw::Cells(n) => Ok(Self::Cells(n)),
1078 Raw::Name(name) => match name.as_str() {
1079 "compact" => Ok(Self::Density(Density::Compact)),
1080 "comfortable" => Ok(Self::Density(Density::Comfortable)),
1081 other => Err(D::Error::custom(format!("{EXPECTED}, not {other:?}"))),
1082 },
1083 }
1084 }
1085}
1086
1087#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1100#[serde(untagged)]
1101pub enum NumberFormatConfig {
1102 Preset(String),
1104 Custom(Box<NumberFormatTable>),
1106}
1107
1108impl Default for NumberFormatConfig {
1109 fn default() -> Self {
1110 NumberFormatConfig::Preset("none".to_string())
1112 }
1113}
1114
1115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1118#[serde(default)]
1119pub struct NumberFormatTable {
1120 pub grouping: Option<String>,
1122 pub group_separator: Option<String>,
1124 pub decimal_separator: Option<String>,
1126 pub floats: Option<bool>,
1128 pub float_precision: Option<u8>,
1130 pub exclude_columns: Vec<String>,
1132 #[serde(flatten)]
1140 pub unknown: std::collections::BTreeMap<String, toml::Value>,
1141}
1142
1143const NUMBER_FORMAT_KEYS: &str =
1145 "grouping, group_separator, decimal_separator, floats, float_precision, exclude_columns";
1146
1147impl NumberFormatConfig {
1148 pub fn resolve(&self, align_numeric_right: bool) -> Result<NumberFormatSettings> {
1154 let (format, exclude) = match self {
1155 NumberFormatConfig::Preset(name) => (Self::lookup_preset(name)?, Vec::new()),
1156 NumberFormatConfig::Custom(table) => {
1157 if !table.unknown.is_empty() {
1158 let keys: Vec<&str> = table.unknown.keys().map(String::as_str).collect();
1159 return Err(eyre!(
1160 "display.number_format: unknown key{} {}. Expected one of: {}",
1161 if keys.len() > 1 { "s" } else { "" },
1162 keys.iter()
1163 .map(|k| format!("'{}'", k))
1164 .collect::<Vec<_>>()
1165 .join(", "),
1166 NUMBER_FORMAT_KEYS
1167 ));
1168 }
1169 let base = match table.grouping.as_deref() {
1170 Some(name) => Self::lookup_preset(name)?,
1171 None => NumberFormat::PLAIN,
1172 };
1173 let mut fmt = base;
1174 if let Some(sep) = table.group_separator.as_deref() {
1175 fmt.group_sep = Self::single_char(sep, "group_separator")?;
1176 }
1177 if let Some(sep) = table.decimal_separator.as_deref() {
1178 fmt.decimal_sep = Self::single_char(sep, "decimal_separator")?;
1179 }
1180 if let Some(v) = table.floats {
1181 fmt.floats = v;
1182 }
1183 if table.float_precision.is_some() {
1184 fmt.float_precision = table.float_precision;
1185 }
1186 (fmt, table.exclude_columns.iter().map(Glob::new).collect())
1187 }
1188 };
1189
1190 if format.grouping != Grouping::None && format.group_sep == format.decimal_sep {
1191 return Err(eyre!(
1192 "display.number_format: group_separator and decimal_separator are both '{}'; \
1193 they must differ or numbers become ambiguous",
1194 format.group_sep
1195 ));
1196 }
1197
1198 let enabled = !format.is_noop();
1204 let format = if enabled {
1205 format
1206 } else {
1207 NumberFormat {
1208 grouping: Grouping::Thousands,
1209 ..format
1210 }
1211 };
1212
1213 Ok(NumberFormatSettings {
1214 format,
1215 enabled,
1216 exclude,
1217 align_numeric_right,
1218 })
1219 }
1220
1221 pub fn with_grouping_override(&self, name: &str) -> Self {
1228 match self {
1229 NumberFormatConfig::Preset(_) => NumberFormatConfig::Preset(name.to_string()),
1230 NumberFormatConfig::Custom(table) => {
1231 let mut table = table.clone();
1232 table.grouping = Some(name.to_string());
1233 NumberFormatConfig::Custom(table)
1234 }
1235 }
1236 }
1237
1238 fn lookup_preset(name: &str) -> Result<NumberFormat> {
1240 let name = if name == "system" {
1244 match numfmt::system_locale_tag() {
1245 Some(tag) => numfmt::preset_for_locale_tag(&tag),
1246 None => "thousands",
1249 }
1250 } else {
1251 name
1252 };
1253 NumberFormat::preset(name).ok_or_else(|| {
1254 eyre!(
1255 "display.number_format: unknown value '{}'. Expected one of: {}, system",
1256 name,
1257 NumberFormat::PRESET_NAMES.join(", ")
1258 )
1259 })
1260 }
1261
1262 fn single_char(s: &str, field: &str) -> Result<char> {
1263 let mut chars = s.chars();
1264 match (chars.next(), chars.next()) {
1265 (Some(c), None) => Ok(c),
1266 _ => Err(eyre!(
1267 "display.number_format.{}: expected a single character, got {:?}",
1268 field,
1269 s
1270 )),
1271 }
1272 }
1273}
1274
1275pub const DEFAULT_ANALYSIS_SAMPLE_ROWS: usize = 100_000;
1278
1279#[derive(Debug, Clone, Serialize, Deserialize)]
1280#[serde(default)]
1281pub struct PerformanceConfig {
1282 pub pages_ahead: usize,
1284 pub pages_behind: usize,
1285 pub max_buffered_rows: usize,
1287 pub max_buffered: ByteSize,
1290 pub streaming: bool,
1292}
1293
1294impl PerformanceConfig {
1295 pub fn max_buffered_mb(&self) -> usize {
1298 usize::try_from(self.max_buffered.bytes().div_ceil(1 << 20)).unwrap_or(usize::MAX)
1299 }
1300}
1301
1302pub const DEFAULT_QUALITY_LOCAL_COPY: ByteSize = ByteSize::mib(2048);
1304
1305pub const DEFAULT_CHART_ROW_LIMIT: usize = 10_000;
1307pub const MAX_CHART_ROW_LIMIT: usize = u32::MAX as usize;
1309
1310#[derive(Debug, Clone, Serialize, Deserialize)]
1312#[serde(default)]
1313pub struct AnalysisConfig {
1314 pub sample_rows: usize,
1318 pub chart_rows: usize,
1321 pub chart_grid: bool,
1323 pub quality_local_copy: ByteSize,
1326 pub sample_memory_limit: Option<ByteSize>,
1329}
1330
1331impl Default for AnalysisConfig {
1332 fn default() -> Self {
1333 Self {
1334 sample_rows: DEFAULT_ANALYSIS_SAMPLE_ROWS,
1335 chart_rows: DEFAULT_CHART_ROW_LIMIT,
1336 chart_grid: false,
1337 quality_local_copy: DEFAULT_QUALITY_LOCAL_COPY,
1338 sample_memory_limit: None,
1339 }
1340 }
1341}
1342
1343#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1351#[serde(rename_all = "lowercase")]
1352pub enum ThemeMode {
1353 #[default]
1355 Auto,
1356 Dark,
1357 Light,
1358}
1359
1360impl ThemeMode {
1361 pub fn resolve(self) -> Self {
1369 match self {
1370 Self::Auto => detect_terminal_mode(),
1371 other => other,
1372 }
1373 }
1374}
1375
1376fn detect_terminal_mode() -> ThemeMode {
1378 let Ok(raw) = std::env::var("COLORFGBG") else {
1379 return ThemeMode::Dark;
1380 };
1381 match raw
1383 .rsplit(';')
1384 .next()
1385 .and_then(|b| b.trim().parse::<u8>().ok())
1386 {
1387 Some(7) | Some(15) => ThemeMode::Light,
1388 _ => ThemeMode::Dark,
1389 }
1390}
1391
1392#[derive(Debug, Clone, Serialize, Deserialize)]
1397#[serde(default)]
1398pub struct HomeConfig {
1399 pub desktop_recents: bool,
1402 pub show_unreadable: bool,
1405 pub hide: Vec<String>,
1408 pub preview_max: ByteSize,
1412 pub search: SearchConfig,
1414}
1415
1416#[derive(Debug, Clone, Serialize, Deserialize)]
1422#[serde(default)]
1423pub struct SearchConfig {
1424 pub enabled: bool,
1426 pub max_depth: usize,
1429 pub max_results: usize,
1433 pub time_budget: Interval,
1436 pub cross_filesystems: bool,
1442 pub follow_gitignore: bool,
1450 pub skip: Vec<String>,
1452 pub skip_extra: Vec<String>,
1455 pub extensions: Vec<String>,
1459}
1460
1461pub const DEFAULT_SEARCH_SKIP: &[&str] = &[
1468 "node_modules",
1469 "target",
1470 "build",
1471 "dist",
1472 "vendor",
1473 "site-packages",
1474 "__pycache__",
1475 "venv",
1476 "env",
1477];
1478
1479impl Default for SearchConfig {
1480 fn default() -> Self {
1481 Self {
1482 enabled: true,
1483 max_depth: 8,
1484 max_results: 1_000,
1485 time_budget: Interval::ms(1_500),
1486 cross_filesystems: false,
1487 follow_gitignore: false,
1488 skip: DEFAULT_SEARCH_SKIP.iter().map(|s| s.to_string()).collect(),
1489 skip_extra: Vec::new(),
1490 extensions: Vec::new(),
1491 }
1492 }
1493}
1494
1495impl SearchConfig {
1496 pub fn skipped_dirs(&self) -> Vec<String> {
1498 let mut out = self.skip.clone();
1499 out.extend(self.skip_extra.iter().cloned());
1500 out
1501 }
1502}
1503
1504impl Default for HomeConfig {
1505 fn default() -> Self {
1506 Self {
1507 desktop_recents: true,
1510 show_unreadable: false,
1511 hide: Vec::new(),
1512 preview_max: ByteSize::mib(64),
1513 search: SearchConfig::default(),
1514 }
1515 }
1516}
1517
1518#[derive(Debug, Clone, Serialize, Deserialize)]
1519#[serde(default)]
1520pub struct ThemeConfig {
1521 pub mode: Option<ThemeMode>,
1524 pub dark: String,
1527 pub light: String,
1529 pub colors: ColorConfig,
1530 #[serde(skip)]
1533 pub follow: bool,
1534 #[serde(skip)]
1537 pub overrides: toml::Table,
1538 #[serde(skip)]
1540 pub library: crate::themes::Library,
1541 #[serde(skip)]
1544 pub dark_theme: String,
1545 #[serde(skip)]
1546 pub light_theme: String,
1547 #[serde(skip)]
1549 pub dark_palette: ColorConfig,
1550 #[serde(skip)]
1551 pub light_palette: ColorConfig,
1552 #[serde(skip)]
1554 pub problems: Vec<String>,
1555 #[serde(skip)]
1557 pub fallbacks: Vec<String>,
1558}
1559
1560impl Default for ThemeConfig {
1561 fn default() -> Self {
1562 Self {
1563 mode: None,
1564 dark: crate::themes::NIGHT_MARKET.to_string(),
1565 light: crate::themes::DAY_MARKET.to_string(),
1566 colors: ColorConfig::default(),
1567 follow: false,
1568 overrides: toml::Table::new(),
1569 library: crate::themes::Library::default(),
1570 dark_theme: crate::themes::NIGHT_MARKET.to_string(),
1571 light_theme: crate::themes::DAY_MARKET.to_string(),
1572 dark_palette: ColorConfig::dark(),
1573 light_palette: ColorConfig::light(),
1574 problems: Vec::new(),
1575 fallbacks: Vec::new(),
1576 }
1577 }
1578}
1579
1580impl ThemeConfig {
1581 pub fn palette_for(&self, mode: ThemeMode) -> Result<ColorConfig> {
1583 let base = match mode.resolve() {
1584 ThemeMode::Light => &self.light_palette,
1585 _ => &self.dark_palette,
1586 };
1587 let mut palette = crate::themes::slots(base);
1588 palette.extend(self.overrides.clone());
1589 Ok(toml::Value::Table(palette).try_into()?)
1590 }
1591
1592 pub fn warnings(&self) -> Vec<String> {
1594 let broken = self
1595 .library
1596 .broken
1597 .iter()
1598 .map(|b| format!("warning: theme left out: {}", b.full()));
1599 let problems = self.problems.iter().map(|p| format!("warning: {p}"));
1600 broken.chain(problems).collect()
1601 }
1602
1603 pub fn use_library(&mut self, library: crate::themes::Library, active: ThemeMode) {
1607 self.problems.clear();
1608 self.fallbacks.clear();
1609 for mode in [ThemeMode::Dark, ThemeMode::Light] {
1610 let (key, name) = match mode {
1611 ThemeMode::Light => ("theme.light", self.light.clone()),
1612 _ => ("theme.dark", self.dark.clone()),
1613 };
1614 let (used, palette) = match library.resolve(&name, mode) {
1615 Ok(palette) => (name, palette),
1616 Err(why) => {
1617 let fallback = crate::themes::built_in_name(mode);
1618 if self.follow || active == mode {
1619 let short = format!("{key}: using {fallback}, not {name}");
1620 self.problems.push(format!("{short}: {why}"));
1621 self.fallbacks.push(short);
1622 }
1623 (fallback.to_string(), ColorConfig::for_mode(mode))
1624 }
1625 };
1626 match mode {
1627 ThemeMode::Light => (self.light_theme, self.light_palette) = (used, palette),
1628 _ => (self.dark_theme, self.dark_palette) = (used, palette),
1629 }
1630 }
1631 self.library = library;
1632 }
1633}
1634
1635#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1639#[serde(default)]
1640pub struct ColorConfig {
1641 pub chip_key: String,
1642 pub chip_label: String,
1643 pub throbber: String,
1644 pub success: String,
1645 pub error: String,
1646 pub warning: String,
1647 pub dimmed: String,
1648 pub background: String,
1649 pub surface: String,
1650 pub controls_bg: String,
1651 pub text_primary: String,
1652 pub text_secondary: String,
1653 pub text_inverse: String,
1654 pub table_header: String,
1655 pub table_header_bg: String,
1656 pub table_row_numbers: String,
1658 pub table_column_separator: String,
1659 pub table_selected: String,
1661 pub table_column_cursor: String,
1663 pub table_cell_cursor: String,
1665 pub table_alternate_row: String,
1667 pub sidebar_border: String,
1668 pub modal_border_active: String,
1669 pub modal_border_error: String,
1670 pub distribution_normal: String,
1671 pub distribution_skewed: String,
1672 pub distribution_other: String,
1673 pub outlier_marker: String,
1674 pub input_cursor: String,
1676 pub input_cursor_text: String,
1678 pub type_str: String,
1680 pub type_int: String,
1681 pub type_float: String,
1682 pub type_bool: String,
1683 pub type_temporal: String,
1684 pub type_binary: String,
1686 pub chart_1: String,
1688 pub chart_2: String,
1689 pub chart_3: String,
1690 pub chart_4: String,
1691 pub chart_5: String,
1692 pub chart_6: String,
1693 pub chart_7: String,
1694 pub chart_8: String,
1695 pub chart_9: String,
1696 pub chart_10: String,
1697 pub chart_grid: String,
1699 pub accent: String,
1702 pub accent_bright: String,
1704 pub gradient_start: String,
1707 pub gradient_end: String,
1708 pub find_match: String,
1710 pub hex_null: String,
1712 pub hex_printable: String,
1713 pub hex_whitespace: String,
1714 pub hex_control: String,
1715 pub hex_high: String,
1716 pub hex_ff: String,
1717}
1718
1719#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1721#[serde(default)]
1722pub struct HttpConfig {
1723 pub user_agent: String,
1725}
1726
1727#[derive(Debug, Clone, Serialize, Deserialize)]
1728#[serde(default)]
1729pub struct QueryConfig {
1730 pub history_limit: usize,
1731 pub history: bool,
1733 pub default_mode: QueryMode,
1734}
1735
1736#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1738#[serde(rename_all = "kebab-case")]
1739pub enum QueryMode {
1740 #[default]
1741 Sql,
1742 Q,
1744}
1745
1746impl QueryMode {
1747 pub fn available() -> &'static [QueryMode] {
1750 #[cfg(feature = "sql")]
1751 {
1752 &[QueryMode::Sql, QueryMode::Q]
1753 }
1754 #[cfg(not(feature = "sql"))]
1755 {
1756 &[QueryMode::Q]
1757 }
1758 }
1759
1760 pub fn resolve(self) -> QueryMode {
1762 if Self::available().contains(&self) {
1763 self
1764 } else {
1765 QueryMode::Q
1766 }
1767 }
1768
1769 pub fn prefix(self) -> &'static str {
1771 match self {
1772 QueryMode::Sql => "sql",
1773 QueryMode::Q => "q",
1774 }
1775 }
1776
1777 pub fn prefix_colon(self) -> &'static str {
1779 match self {
1780 QueryMode::Sql => "sql:",
1781 QueryMode::Q => "q:",
1782 }
1783 }
1784
1785 pub fn next(self) -> QueryMode {
1787 let modes = Self::available();
1788 let at = modes.iter().position(|&m| m == self).unwrap_or(0);
1789 modes[(at + 1) % modes.len()]
1790 }
1791}
1792
1793#[derive(Debug, Clone, Serialize, Deserialize)]
1795#[serde(default)]
1796pub struct ChartConfig {
1797 pub export_recipe: bool,
1800}
1801
1802impl Default for ChartConfig {
1803 fn default() -> Self {
1804 Self {
1805 export_recipe: true,
1806 }
1807 }
1808}
1809
1810#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1812#[serde(default)]
1813pub struct ViewsConfig {
1814 pub auto_apply: bool,
1815}
1816
1817#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1819#[serde(default)]
1820pub struct LogConfig {
1821 pub file: Option<String>,
1823 pub level: Option<String>,
1825}
1826
1827#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1829#[serde(default)]
1830pub struct FormatsConfig {
1831 pub path: Vec<String>,
1834}
1835
1836#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1839#[serde(default)]
1840pub struct ClipboardConfig {
1841 pub backend: String,
1844 pub osc52_limit: ByteSize,
1847}
1848
1849impl Default for ClipboardConfig {
1850 fn default() -> Self {
1851 Self {
1852 backend: "auto".to_string(),
1853 osc52_limit: ByteSize::kib(100),
1854 }
1855 }
1856}
1857
1858#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1865#[serde(default)]
1866pub struct GlyphsConfig {
1867 #[serde(flatten)]
1868 pub overrides: std::collections::BTreeMap<String, crate::glyphs::SlotOverride>,
1869}
1870
1871impl Default for AppConfig {
1872 fn default() -> Self {
1873 let mut config = Self {
1874 import: Vec::new(),
1875 catalogs: Vec::new(),
1876 read_catalogs: Vec::new(),
1877 catalog_dir: None,
1878 broken_catalogs: Vec::new(),
1879 read: ReadConfig::default(),
1880 csv: CsvConfig::default(),
1881 display: DisplayConfig::default(),
1882 performance: PerformanceConfig::default(),
1883 analysis: AnalysisConfig::default(),
1884 chart: ChartConfig::default(),
1885 home: HomeConfig::default(),
1886 cloud: CloudConfig::default(),
1887 http: HttpConfig::default(),
1888 query: QueryConfig::default(),
1889 views: ViewsConfig::default(),
1890 clipboard: ClipboardConfig::default(),
1891 formats: FormatsConfig::default(),
1892 log: LogConfig::default(),
1893 theme: ThemeConfig::default(),
1894 glyphs: GlyphsConfig::default(),
1895 };
1896 config.sync_dataset_access();
1897 config
1898 }
1899}
1900
1901impl Default for DisplayConfig {
1902 fn default() -> Self {
1903 Self {
1904 unicode: crate::glyphs::UnicodeMode::default(),
1905 row_numbers: RowNumbers::Auto,
1906 row_numbers_start: 1,
1907 cell_padding: CellPadding::default(),
1908 column_colors: true,
1909 type_row: true,
1910 notes_accent: true,
1911 mouse: true,
1912 sidebar_width: None,
1913 right_align_numbers: true,
1914 number_format: NumberFormatConfig::default(),
1915 }
1916 }
1917}
1918
1919impl Default for PerformanceConfig {
1920 fn default() -> Self {
1921 Self {
1922 pages_ahead: 3,
1923 pages_behind: 3,
1924 max_buffered_rows: crate::widgets::datatable::DEFAULT_MAX_BUFFERED_ROWS,
1925 max_buffered: ByteSize::mib(512),
1926 streaming: true,
1927 }
1928 }
1929}
1930
1931impl Default for ColorConfig {
1932 fn default() -> Self {
1935 Self::dark()
1936 }
1937}
1938
1939impl ColorConfig {
1940 pub fn for_mode(mode: ThemeMode) -> Self {
1942 match mode.resolve() {
1943 ThemeMode::Light => Self::light(),
1944 _ => Self::dark(),
1945 }
1946 }
1947
1948 pub fn dark() -> Self {
1950 Self {
1955 chip_key: "#7dcfff".to_string(),
1956 chip_label: "#a9b1d6".to_string(),
1957 throbber: "#7dcfff".to_string(),
1958 success: "#9ece6a".to_string(),
1959 error: "#f7768e".to_string(),
1960 warning: "#e0af68".to_string(),
1961 dimmed: "#565f89".to_string(),
1962 background: "default".to_string(),
1963 surface: "default".to_string(),
1964 controls_bg: "#262a3f".to_string(),
1965 text_primary: "default".to_string(),
1966 text_secondary: "#737aa2".to_string(),
1967 text_inverse: "#1a1b26".to_string(),
1968 table_header: "#c0caf5".to_string(),
1969 table_header_bg: "#2b3047".to_string(),
1970 table_row_numbers: "#565f89".to_string(),
1971 table_column_separator: "#3b4261".to_string(),
1972 table_selected: "#283457".to_string(),
1973 table_column_cursor: "#292e42".to_string(),
1976 table_cell_cursor: "#3b4261".to_string(),
1977 sidebar_border: "#565f89".to_string(),
1980 modal_border_active: "#7dcfff".to_string(),
1981 modal_border_error: "#f7768e".to_string(),
1982 distribution_normal: "#9ece6a".to_string(),
1983 distribution_skewed: "#e0af68".to_string(),
1984 distribution_other: "#c0caf5".to_string(),
1985 outlier_marker: "#f7768e".to_string(),
1986 input_cursor: "default".to_string(),
1987 input_cursor_text: "default".to_string(),
1988 table_alternate_row: "#1e2030".to_string(),
1989 type_str: "#9ece6a".to_string(),
1990 type_int: "#7aa2f7".to_string(),
1991 type_float: "#2ac3de".to_string(),
1992 type_bool: "#e0af68".to_string(),
1993 type_temporal: "#bb9af7".to_string(),
1994 type_binary: "#565f89".to_string(),
1995 chart_1: "#7dcfff".to_string(),
1996 chart_2: "#bb9af7".to_string(),
1997 chart_3: "#9ece6a".to_string(),
1998 chart_4: "#e0af68".to_string(),
1999 chart_5: "#7aa2f7".to_string(),
2000 chart_6: "#f7768e".to_string(),
2001 chart_7: "#ff9e64".to_string(),
2002 chart_8: "#1abc9c".to_string(),
2006 chart_9: "#ff5fd2".to_string(),
2007 chart_10: "#f4ef8a".to_string(),
2008 chart_grid: "#3d4785".to_string(),
2011 accent: "#7dcfff".to_string(),
2012 accent_bright: "#a4daff".to_string(),
2013 gradient_start: "#7aa2f7".to_string(),
2014 gradient_end: "#bb9af7".to_string(),
2015 find_match: "#e0af68".to_string(),
2016 hex_null: "#565f89".to_string(),
2017 hex_printable: "#7dcfff".to_string(),
2018 hex_whitespace: "#9ece6a".to_string(),
2019 hex_control: "#bb9af7".to_string(),
2020 hex_high: "#e0af68".to_string(),
2021 hex_ff: "#f7768e".to_string(),
2022 }
2023 }
2024
2025 pub fn light() -> Self {
2033 Self {
2037 chip_key: "#2e7de9".to_string(),
2038 chip_label: "#3760bf".to_string(),
2039 throbber: "#2e7de9".to_string(),
2040 success: "#587539".to_string(),
2041 error: "#f52a65".to_string(),
2042 warning: "#8c6c3e".to_string(),
2043 dimmed: "#848cb5".to_string(),
2044 background: "default".to_string(),
2045 surface: "default".to_string(),
2046 controls_bg: "#d0d5e3".to_string(),
2047 text_primary: "default".to_string(),
2048 text_secondary: "#6172b0".to_string(),
2049 text_inverse: "#e1e2e7".to_string(),
2050 table_header: "#3760bf".to_string(),
2051 table_header_bg: "#c4c8da".to_string(),
2052 table_row_numbers: "#848cb5".to_string(),
2053 table_column_separator: "#a8aecb".to_string(),
2054 table_selected: "#b6bfe2".to_string(),
2055 table_column_cursor: "#cbd3f2".to_string(),
2056 table_cell_cursor: "#a0aef0".to_string(),
2057 sidebar_border: "#6172b0".to_string(),
2058 modal_border_active: "#2e7de9".to_string(),
2059 modal_border_error: "#f52a65".to_string(),
2060 distribution_normal: "#587539".to_string(),
2061 distribution_skewed: "#8c6c3e".to_string(),
2062 distribution_other: "#3760bf".to_string(),
2063 outlier_marker: "#f52a65".to_string(),
2064 input_cursor: "default".to_string(),
2065 input_cursor_text: "default".to_string(),
2066 table_alternate_row: "#dcdfea".to_string(),
2067 type_str: "#587539".to_string(),
2068 type_int: "#2e7de9".to_string(),
2069 type_float: "#007197".to_string(),
2070 type_bool: "#8c6c3e".to_string(),
2071 type_temporal: "#9854f1".to_string(),
2072 type_binary: "#848cb5".to_string(),
2073 chart_1: "#2e7de9".to_string(),
2074 chart_2: "#9854f1".to_string(),
2075 chart_3: "#587539".to_string(),
2076 chart_4: "#8c6c3e".to_string(),
2077 chart_5: "#007197".to_string(),
2078 chart_6: "#f52a65".to_string(),
2079 chart_7: "#b15c00".to_string(),
2080 chart_8: "#118c74".to_string(),
2082 chart_9: "#d1188c".to_string(),
2083 chart_10: "#24357a".to_string(),
2084 chart_grid: "#70aabf".to_string(),
2087 accent: "#2e7de9".to_string(),
2088 accent_bright: "#1a6cd0".to_string(),
2089 gradient_start: "#2e7de9".to_string(),
2090 gradient_end: "#9854f1".to_string(),
2091 find_match: "#f0c35a".to_string(),
2092 hex_null: "#848cb5".to_string(),
2093 hex_printable: "#007197".to_string(),
2094 hex_whitespace: "#587539".to_string(),
2095 hex_control: "#9854f1".to_string(),
2096 hex_high: "#8c6c3e".to_string(),
2097 hex_ff: "#f52a65".to_string(),
2098 }
2099 }
2100}
2101
2102impl Default for QueryConfig {
2103 fn default() -> Self {
2104 Self {
2105 history_limit: 1000,
2106 history: true,
2107 default_mode: QueryMode::default(),
2108 }
2109 }
2110}
2111
2112const MAX_IMPORT_DEPTH: usize = 8;
2117
2118pub fn expand_config_path(raw: &str) -> PathBuf {
2124 expand_path(raw)
2125}
2126
2127pub fn expand_home(path: &Path) -> PathBuf {
2133 expand_home_unless(path, |p| p.symlink_metadata().is_ok())
2134}
2135
2136fn expand_home_unless(path: &Path, there: impl FnOnce(&Path) -> bool) -> PathBuf {
2137 path.to_str()
2138 .and_then(home_path)
2139 .filter(|_| !there(path))
2140 .unwrap_or_else(|| path.to_path_buf())
2141}
2142
2143fn home_path(text: &str) -> Option<PathBuf> {
2146 if text == "~" {
2147 return dirs::home_dir();
2148 }
2149 let rest = text
2150 .strip_prefix("~/")
2151 .or_else(|| text.strip_prefix("~\\").filter(|_| cfg!(windows)))?;
2153 dirs::home_dir().map(|home| home.join(rest))
2154}
2155
2156pub(crate) fn path_place(path: &Path) -> PathBuf {
2162 use std::path::{Component, Prefix};
2163 let mut place = PathBuf::new();
2164 for component in path.components() {
2165 match component {
2166 Component::CurDir => {}
2167 Component::Prefix(prefix) => match prefix.kind() {
2168 Prefix::Disk(drive) => {
2169 place.push(format!("{}:", char::from(drive.to_ascii_uppercase())));
2170 }
2171 Prefix::UNC(server, share) => {
2172 let mut unc = std::ffi::OsString::from(r"\\");
2173 unc.push(server);
2174 unc.push(r"\");
2175 unc.push(share);
2176 place.push(unc);
2177 }
2178 _ => place.push(prefix.as_os_str()),
2179 },
2180 other => place.push(other),
2181 }
2182 }
2183 place
2184}
2185
2186pub(crate) fn expand_path(raw: &str) -> PathBuf {
2187 let mut expanded = String::with_capacity(raw.len());
2188 let mut chars = raw.chars().peekable();
2189
2190 while let Some(c) = chars.next() {
2191 if c != '$' {
2192 expanded.push(c);
2193 continue;
2194 }
2195
2196 let braced = chars.peek() == Some(&'{');
2197 if braced {
2198 chars.next();
2199 }
2200
2201 let mut name = String::new();
2202 while let Some(&next) = chars.peek() {
2203 if braced && next == '}' {
2204 chars.next();
2205 break;
2206 }
2207 if !next.is_ascii_alphanumeric() && next != '_' {
2208 break;
2209 }
2210 name.push(next);
2211 chars.next();
2212 }
2213
2214 if name.is_empty() {
2215 expanded.push('$');
2217 } else if let Ok(value) = std::env::var(&name) {
2218 expanded.push_str(&value);
2219 }
2220 }
2221
2222 home_path(&expanded).unwrap_or_else(|| PathBuf::from(expanded))
2224}
2225
2226#[derive(Debug, Clone, Default, PartialEq)]
2233pub struct ConfigLayer {
2234 table: toml::Table,
2235 imports: Vec<String>,
2237}
2238
2239#[derive(Debug, Clone, PartialEq, Eq)]
2241pub enum LayerSource {
2242 File(PathBuf),
2244 Override,
2246}
2247
2248impl std::fmt::Display for LayerSource {
2249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2250 match self {
2251 Self::File(path) => write!(f, "{}", path.display()),
2252 Self::Override => f.write_str("-c"),
2253 }
2254 }
2255}
2256
2257#[derive(Debug, Clone, Copy)]
2259enum Combine {
2260 ByName,
2264 Union,
2266}
2267
2268const COMBINED_KEYS: &[(&str, Combine)] = &[
2271 ("formats.path", Combine::Union),
2272 ("catalogs", Combine::Union),
2273 ("cloud.connections", Combine::ByName),
2274 ("cloud.hide", Combine::Union),
2275 ("cloud.env_files", Combine::Union),
2276 ("home.hide", Combine::Union),
2277];
2278
2279pub fn way_out(imported: bool, what: &str) -> String {
2283 if imported {
2284 format!("Fix that {what}, or move the file aside: a missing import is skipped.")
2285 } else {
2286 format!(
2287 "Fix that {what}, or move the file aside to start from the defaults; \
2288 `datui config init` then writes a fresh one."
2289 )
2290 }
2291}
2292
2293impl ConfigLayer {
2294 pub fn parse(text: &str) -> Result<Self> {
2297 let typed: AppConfig = toml::from_str(text)?;
2298 let table: toml::Table = toml::from_str(text)?;
2299 Ok(Self::from_table(table, typed.import))
2300 }
2301
2302 pub fn from_overrides(overrides: &[datui_cli::settings::Override]) -> Result<Self> {
2306 let mut table = toml::Table::new();
2307 for o in overrides {
2308 let mut at = &mut table;
2309 let mut parts: Vec<&str> = o.key.split('.').collect();
2310 let last = parts.pop().unwrap_or_default();
2311 for part in parts {
2312 let entry = at
2313 .entry(part.to_string())
2314 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2315 if !entry.is_table() {
2316 *entry = toml::Value::Table(toml::Table::new());
2317 }
2318 at = entry.as_table_mut().expect("just made a table");
2319 }
2320 at.insert(last.to_string(), o.value.clone());
2321 }
2322 toml::Value::Table(table.clone())
2323 .try_into::<AppConfig>()
2324 .map_err(|e| eyre!("-c: {}", e.message().trim_end()))?;
2325 Ok(Self::from_table(table, Vec::new()))
2326 }
2327
2328 fn from_table(mut table: toml::Table, imports: Vec<String>) -> Self {
2329 table.remove("import");
2330 Self { table, imports }
2331 }
2332
2333 fn read(path: &Path, importer: Option<&Path>) -> Result<Option<Self>> {
2337 let named = match importer {
2339 Some(importer) => format!("{} (imported by {})", path.display(), importer.display()),
2340 None => path.display().to_string(),
2341 };
2342 let content = match std::fs::read_to_string(path) {
2343 Ok(content) => content,
2344 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2345 Err(e) => return Err(eyre!("Failed to read config file at {named}: {e}")),
2346 };
2347 let mut layer = Self::parse(&content).map_err(|e| {
2348 eyre!(
2349 "Failed to parse config file at {named}: {}\n{}",
2350 parse_reason(&e),
2351 way_out(importer.is_some(), "line")
2352 )
2353 })?;
2354 for unknown in unknown_keys_in(&layer.table) {
2357 eprintln!("datui: warning: {}: {unknown}", path.display());
2358 }
2359 layer.anchor_paths(path.parent().unwrap_or_else(|| Path::new(".")));
2360 Ok(Some(layer))
2361 }
2362
2363 fn anchor_paths(&mut self, dir: &Path) {
2366 if let Some(toml::Value::Array(entries)) = self
2367 .table
2368 .get_mut("formats")
2369 .and_then(|f| f.get_mut("path"))
2370 {
2371 for entry in entries {
2372 if let toml::Value::String(path) = entry
2373 && !path.trim().is_empty()
2374 && expand_path(path).is_relative()
2375 {
2376 *path = dir.join(expand_path(path)).to_string_lossy().into_owned();
2377 }
2378 }
2379 }
2380 if let Some(toml::Value::Array(files)) = self.table.get_mut("catalogs") {
2381 for entry in files {
2382 let path = match entry {
2384 toml::Value::Table(table) => table.get_mut("path"),
2385 other => Some(other),
2386 };
2387 if let Some(toml::Value::String(path)) = path
2388 && !path.trim().is_empty()
2389 && expand_path(path).is_relative()
2390 {
2391 *path = dir.join(expand_path(path)).to_string_lossy().into_owned();
2392 }
2393 }
2394 }
2395 }
2396
2397 pub fn get(&self, key: &str) -> Option<&toml::Value> {
2399 let mut parts = key.split('.');
2400 let mut value = self.table.get(parts.next()?)?;
2401 for part in parts {
2402 value = value.as_table()?.get(part)?;
2403 }
2404 Some(value)
2405 }
2406
2407 pub fn merge(&mut self, upper: ConfigLayer) {
2411 merge_tables(&mut self.table, upper.table, "");
2412 }
2413}
2414
2415const RETIRED_KEYS: &[(&str, &str)] = &[
2417 (
2418 "sources",
2419 "a collection is a catalog file now; put its datasets in catalog.toml as [id] \
2420 tables, or list the file in catalogs = [...] (datui catalog check FILE)",
2421 ),
2422 (
2423 "home.directories",
2424 "a directory is a catalog entry now; Ctrl+D on its row adds it to catalog.toml",
2425 ),
2426 (
2427 "home.builtin_catalog",
2428 "home.hide = [\"examples\"] hides the example datasets",
2429 ),
2430];
2431
2432fn unknown_keys_in(table: &toml::Table) -> Vec<String> {
2436 fn walk(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
2437 for (key, value) in table {
2438 let path = if prefix.is_empty() {
2439 key.clone()
2440 } else {
2441 format!("{prefix}.{key}")
2442 };
2443 if datui_cli::settings::find(&path).is_some() {
2444 continue;
2445 }
2446 if let Some((_, moved)) = RETIRED_KEYS.iter().find(|(key, _)| *key == path) {
2447 out.push(format!("{path} is not read any more: {moved}"));
2448 continue;
2449 }
2450 match value {
2451 toml::Value::Table(inner) => walk(inner, &path, out),
2454 _ => {
2455 let near = datui_cli::settings::suggestions(&path);
2456 let mut said = format!("{path} is not a config key, and is not read");
2457 if !near.is_empty() {
2458 said.push_str(&format!("; did you mean {}?", near.join(" or ")));
2459 }
2460 out.push(said);
2461 }
2462 }
2463 }
2464 }
2465 let mut out = Vec::new();
2466 walk(table, "", &mut out);
2467 out.sort();
2468 out
2469}
2470
2471fn parse_reason(error: &color_eyre::eyre::Report) -> String {
2475 let Some(toml_error) = error.downcast_ref::<toml::de::Error>() else {
2476 return error.to_string();
2477 };
2478 let message = toml_error.message().trim_end();
2479 let full = toml_error.to_string();
2480 let body = full.trim_end().strip_suffix(message).unwrap_or(&full);
2481 match body.split_once('\n') {
2482 Some((head, excerpt)) if head.starts_with("TOML parse error at ") => format!(
2483 "{message} ({})\n{}",
2484 head.trim_start_matches("TOML parse error at "),
2485 excerpt.trim_end()
2486 ),
2487 _ => message.to_string(),
2488 }
2489}
2490
2491fn merge_tables(lower: &mut toml::Table, upper: toml::Table, prefix: &str) {
2492 for (key, value) in upper {
2493 let path = if prefix.is_empty() {
2494 key.clone()
2495 } else {
2496 format!("{prefix}.{key}")
2497 };
2498 let combine = COMBINED_KEYS
2499 .iter()
2500 .find(|(p, _)| *p == path)
2501 .map(|(_, c)| *c);
2502 match (combine, value) {
2503 (Some(combine), toml::Value::Array(upper)) => {
2504 let mut combined = match lower.remove(&key) {
2505 Some(toml::Value::Array(lower)) => lower,
2506 _ => Vec::new(),
2507 };
2508 match combine {
2509 Combine::ByName => merge_by_name(&mut combined, upper),
2510 Combine::Union => {
2511 for item in upper {
2512 if !combined.contains(&item) {
2513 combined.push(item);
2514 }
2515 }
2516 }
2517 }
2518 lower.insert(key, toml::Value::Array(combined));
2519 }
2520 (None, toml::Value::Table(upper)) => match lower.get_mut(&key) {
2521 Some(toml::Value::Table(lower)) => merge_tables(lower, upper, &path),
2522 _ => {
2523 lower.insert(key, toml::Value::Table(upper));
2524 }
2525 },
2526 (_, value) => {
2527 lower.insert(key, value);
2528 }
2529 }
2530 }
2531}
2532
2533fn merge_by_name(lower: &mut Vec<toml::Value>, upper: Vec<toml::Value>) {
2534 let name_of = |entry: &toml::Value| {
2535 entry
2536 .get("name")
2537 .and_then(toml::Value::as_str)
2538 .map(str::to_owned)
2539 };
2540 let mut seen = std::collections::HashSet::new();
2541 for entry in upper {
2542 let name = name_of(&entry);
2543 let first = name.clone().is_some_and(|n| seen.insert(n));
2544 match lower
2545 .iter()
2546 .position(|e| name.is_some() && name_of(e) == name)
2547 {
2548 Some(i) if first => lower[i] = entry,
2549 _ => lower.push(entry),
2550 }
2551 }
2552}
2553
2554impl AppConfig {
2556 pub fn load(app_name: &str) -> Result<Self> {
2558 Self::load_with(app_name, &[])
2559 }
2560
2561 pub fn load_with(app_name: &str, overrides: &[datui_cli::settings::Override]) -> Result<Self> {
2563 match ConfigManager::new(app_name) {
2564 Ok(manager) => {
2565 Self::load_from_file_with(&manager.config_path("config.toml"), overrides)
2566 }
2567 Err(_) => {
2569 let layers = vec![ConfigLayer::from_overrides(overrides)?];
2570 let mut config =
2571 Self::from_layers(layers).map_err(|e| eyre!("Invalid configuration: {}", e))?;
2572 config.read_theme_files(None)?;
2573 config
2574 .validate()
2575 .map_err(|e| eyre!("Invalid configuration: {}", e))?;
2576 Ok(config)
2577 }
2578 }
2579 }
2580
2581 pub fn load_from_file(config_path: &Path) -> Result<Self> {
2595 Self::load_from_file_with(config_path, &[])
2596 }
2597
2598 pub fn load_from_file_with(
2600 config_path: &Path,
2601 overrides: &[datui_cli::settings::Override],
2602 ) -> Result<Self> {
2603 let layers = Self::read_layers(config_path, overrides)?;
2604 Self::from_read_layers(config_path, overrides, &layers)
2605 }
2606
2607 pub fn read_layers(
2611 config_path: &Path,
2612 overrides: &[datui_cli::settings::Override],
2613 ) -> Result<Vec<(LayerSource, ConfigLayer)>> {
2614 let mut layers = Vec::new();
2615 if let Some(root) = ConfigLayer::read(config_path, None)? {
2616 let canonical = crate::canonical::canonicalize(config_path)
2617 .unwrap_or_else(|_| config_path.to_path_buf());
2618 let mut stack = vec![canonical];
2619 Self::collect_imports(&root.imports, config_path, &mut stack, &mut layers)?;
2620 layers.push((LayerSource::File(config_path.to_path_buf()), root));
2621 }
2622 if !overrides.is_empty() {
2623 layers.push((
2624 LayerSource::Override,
2625 ConfigLayer::from_overrides(overrides)?,
2626 ));
2627 }
2628 Ok(layers)
2629 }
2630
2631 pub fn from_read_layers(
2634 config_path: &Path,
2635 overrides: &[datui_cli::settings::Override],
2636 layers: &[(LayerSource, ConfigLayer)],
2637 ) -> Result<Self> {
2638 let place = if overrides.is_empty() {
2640 config_path.display().to_string()
2641 } else {
2642 format!("{} with -c", config_path.display())
2643 };
2644 let imports = layers
2645 .iter()
2646 .find(|(source, _)| *source == LayerSource::File(config_path.to_path_buf()))
2647 .map(|(_, root)| root.imports.clone())
2648 .unwrap_or_default();
2649
2650 let mut config = Self::from_layers(layers.iter().map(|(_, layer)| layer.clone()))
2651 .map_err(|e| eyre!("Invalid configuration in {place}: {e}"))?;
2652 config.import = imports;
2654 config.read_theme_files(config_path.parent())?;
2655 config.read_catalog_files(config_path.parent())?;
2657 for broken in &config.broken_catalogs {
2658 eprintln!("datui: warning: catalog left out: {}", broken.full());
2659 log::warn!(target: "datui", "catalog left out: {}", broken.full());
2660 }
2661 for name in config.unknown_hidden() {
2663 eprintln!("datui: warning: home.hide: {}", Self::hides_nothing(&name));
2664 }
2665
2666 config.validate().map_err(|e| {
2667 eyre!(
2668 "Invalid configuration in {place}: {e}\n{}",
2669 way_out(false, "setting")
2670 )
2671 })?;
2672
2673 Ok(config)
2674 }
2675
2676 fn collect_imports(
2682 imports: &[String],
2683 origin: &Path,
2684 stack: &mut Vec<PathBuf>,
2685 out: &mut Vec<(LayerSource, ConfigLayer)>,
2686 ) -> Result<()> {
2687 if imports.is_empty() {
2688 return Ok(());
2689 }
2690
2691 if stack.len() >= MAX_IMPORT_DEPTH {
2692 return Err(eyre!(
2693 "config import chain is more than {} files deep (at {}); \
2694 flatten the chain or remove the extra levels",
2695 MAX_IMPORT_DEPTH,
2696 origin.display()
2697 ));
2698 }
2699
2700 let origin_dir = origin.parent().unwrap_or_else(|| Path::new("."));
2701
2702 for entry in imports {
2703 let expanded = expand_path(entry);
2704 let path = if expanded.is_absolute() {
2705 expanded
2706 } else {
2707 origin_dir.join(expanded)
2708 };
2709
2710 let canonical = crate::canonical::canonicalize(&path).unwrap_or_else(|_| path.clone());
2711 if stack.contains(&canonical) {
2712 return Err(eyre!(
2713 "circular config import: {} is already being loaded (imported by {})",
2714 canonical.display(),
2715 origin.display()
2716 ));
2717 }
2718
2719 let Some(layer) = ConfigLayer::read(&path, Some(origin))? else {
2720 eprintln!(
2721 "datui: warning: config import not found, skipping: {} (imported by {})",
2722 path.display(),
2723 origin.display()
2724 );
2725 continue;
2726 };
2727
2728 stack.push(canonical);
2729 Self::collect_imports(&layer.imports, &path, stack, out)?;
2730 stack.pop();
2731
2732 out.push((LayerSource::File(path), layer));
2733 }
2734
2735 Ok(())
2736 }
2737
2738 pub fn from_layers(layers: impl IntoIterator<Item = ConfigLayer>) -> Result<Self> {
2746 let mut merged = ConfigLayer::default();
2747 for layer in layers {
2748 merged.merge(layer);
2749 }
2750 let mut table = merged.table;
2751
2752 let theme = table.get_mut("theme").and_then(toml::Value::as_table_mut);
2753 let mode: ThemeMode = match theme.as_ref().and_then(|t| t.get("mode")) {
2754 Some(mode) => mode.clone().try_into()?,
2755 None => ThemeMode::default(),
2756 };
2757 let resolved = mode.resolve();
2758 let colors = theme.and_then(|t| t.remove("colors"));
2759
2760 let mut config: AppConfig = toml::Value::Table(table).try_into()?;
2761
2762 if let Some(toml::Value::Table(colors)) = colors {
2763 config.theme.overrides = colors;
2764 }
2765 config.theme.follow = mode == ThemeMode::Auto;
2766 config.theme.mode = Some(resolved);
2767 config
2769 .theme
2770 .use_library(crate::themes::Library::default(), resolved);
2771 config.theme.colors = config.theme.palette_for(resolved)?;
2772 config.sync_dataset_access();
2773 Ok(config)
2774 }
2775
2776 pub fn read_theme_files(&mut self, config_dir: Option<&Path>) -> Result<()> {
2781 let library = crate::themes::Library::read(config_dir);
2782 let active = self.theme.mode.unwrap_or_default().resolve();
2783 self.theme.use_library(library, active);
2784 for warning in self.theme.warnings() {
2785 eprintln!("datui: {warning}");
2786 log::warn!(target: "datui", "{warning}");
2787 }
2788 self.theme.colors = self.theme.palette_for(active)?;
2789 Ok(())
2790 }
2791
2792 pub fn catalogs(&self) -> Vec<crate::catalog::Catalog> {
2796 let mut all = self.read_catalogs.clone();
2797 if !all.iter().any(|c| c.id == crate::catalog::EXAMPLES) {
2798 all.push(crate::catalog::bundled());
2799 }
2800 all
2801 }
2802
2803 pub fn shown_catalogs(&self) -> Vec<crate::catalog::Catalog> {
2806 self.catalogs()
2807 .into_iter()
2808 .filter(|c| !self.home.hide.contains(&c.id))
2809 .map(|mut c| {
2810 c.datasets.retain(|d| {
2811 !self
2812 .home
2813 .hide
2814 .iter()
2815 .any(|h| h.split_once('/') == Some((c.id.as_str(), d.id.as_str())))
2816 });
2817 c
2818 })
2819 .collect()
2820 }
2821
2822 pub fn hides_nothing(name: &str) -> String {
2826 let old = crate::catalog::OLD_EXAMPLES_ID;
2827 let renamed = match name.split_once('/') {
2828 None if name == old => Some(crate::catalog::EXAMPLES.to_string()),
2829 Some((catalog, id)) if catalog == old => {
2830 Some(format!("{}/{id}", crate::catalog::EXAMPLES))
2831 }
2832 _ => None,
2833 };
2834 match renamed {
2835 Some(new) => format!("`{name}` is now `{new}`: hide = [\"{new}\"]"),
2836 None => format!("no catalog or entry is named {name}"),
2837 }
2838 }
2839
2840 pub fn unknown_hidden(&self) -> Vec<String> {
2841 let catalogs = self.catalogs();
2842 let mut out: Vec<String> = Vec::new();
2843 for name in &self.home.hide {
2844 let known = match name.split_once('/') {
2845 None => {
2846 catalogs.iter().any(|c| c.id == *name)
2847 || self.broken_catalogs.iter().any(|b| b.id == *name)
2848 }
2849 Some((catalog, _)) if self.broken_catalogs.iter().any(|b| b.id == catalog) => true,
2850 Some((catalog, id)) => catalogs
2851 .iter()
2852 .any(|c| c.id == catalog && c.datasets.iter().any(|d| d.id == id)),
2853 };
2854 if !known && !out.contains(name) {
2855 out.push(name.clone());
2856 }
2857 }
2858 out
2859 }
2860
2861 pub fn read_catalog_files(&mut self, config_dir: Option<&Path>) -> Result<()> {
2866 use crate::catalog::{self, Origin};
2867 let mut read: Vec<catalog::Catalog> = Vec::new();
2868 let mut broken: Vec<catalog::Broken> = Vec::new();
2869 let connections = self.cloud.connections.clone();
2870 let take = |found: std::result::Result<Option<catalog::Catalog>, catalog::Broken>,
2873 read: &mut Vec<catalog::Catalog>,
2874 broken: &mut Vec<catalog::Broken>|
2875 -> bool {
2876 match found {
2877 Ok(Some(c)) => match c.check_connections(&connections) {
2878 Ok(()) => {
2879 read.push(c);
2880 true
2881 }
2882 Err(e) => {
2883 broken.push(catalog::Broken {
2884 id: c.id.clone(),
2885 origin: c.origin,
2886 file: c.file.clone().unwrap_or_default(),
2887 line: e.line,
2888 message: e.message,
2889 });
2890 true
2891 }
2892 },
2893 Ok(None) => false,
2894 Err(b) => {
2895 broken.push(b);
2896 true
2897 }
2898 }
2899 };
2900 let mut files: Vec<(PathBuf, Origin, Option<String>, Option<String>)> = Vec::new();
2902 if let Some(dir) = config_dir {
2903 take(
2904 catalog::load(&dir.join(catalog::MINE_FILE), catalog::MINE, Origin::Mine),
2905 &mut read,
2906 &mut broken,
2907 );
2908 let folder = dir.join(catalog::FOLDER);
2909 let mut found: Vec<PathBuf> = match std::fs::read_dir(&folder) {
2910 Ok(entries) => entries
2911 .filter_map(|e| e.ok().map(|e| e.path()))
2912 .filter(|p| {
2913 p.extension().is_some_and(|x| x == "toml")
2914 && std::fs::metadata(p).is_ok_and(|m| m.is_file())
2915 })
2916 .collect(),
2917 Err(_) => Vec::new(),
2918 };
2919 found.sort();
2920 files.extend(found.into_iter().map(|p| (p, Origin::Folder, None, None)));
2921 }
2922 files.extend(self.catalogs.iter().map(|entry| {
2923 (
2924 expand_path(entry.path()),
2925 Origin::Listed,
2926 entry.id().map(str::to_string),
2927 entry.label().map(str::to_string),
2928 )
2929 }));
2930 for (path, origin, given_id, label) in files {
2931 let id = given_id
2932 .clone()
2933 .unwrap_or_else(|| catalog::id_of_file(&path));
2934 let refuse = |message: String| catalog::Broken {
2935 id: id.clone(),
2936 origin,
2937 file: path.clone(),
2938 line: None,
2939 message,
2940 };
2941 if !is_valid_source_id(&id) || id == catalog::MINE {
2942 broken.push(refuse(format!(
2943 "\"{id}\" cannot be a catalog's id: lowercase letters, digits and '-', \
2944 and not \"{}\", which is catalog.toml's. {}",
2945 catalog::MINE,
2946 if given_id.is_some() {
2947 "Give another id = \"...\""
2948 } else {
2949 "Rename the file, or list it as { path = \"...\", id = \"...\" }"
2950 }
2951 )));
2952 continue;
2953 }
2954 if let Some(first) = read.iter().find(|c| c.id == id) {
2955 let first = first.file_name();
2956 broken.push(refuse(format!(
2957 "{first} and this file are both the catalog \"{id}\". Rename one, or \
2958 list one as {{ path = \"...\", id = \"...\" }}"
2959 )));
2960 continue;
2961 }
2962 let found = catalog::load(&path, &id, origin).map(|found| {
2963 found.map(|mut listed| {
2964 if let Some(label) = &label {
2965 listed.label = label.clone();
2966 }
2967 listed
2968 })
2969 });
2970 if !take(found, &mut read, &mut broken) {
2971 eprintln!(
2972 "datui: warning: catalog not found, skipping: {}",
2973 path.display()
2974 );
2975 }
2976 }
2977 self.read_catalogs = read;
2978 self.broken_catalogs = broken;
2979 self.catalog_dir = config_dir.map(Path::to_path_buf);
2980 self.sync_dataset_access();
2981 Ok(())
2982 }
2983
2984 pub fn sync_dataset_access(&mut self) {
2987 self.cloud.dataset_access = self
2988 .catalogs()
2989 .iter()
2990 .flat_map(|catalog| {
2991 catalog.datasets.iter().filter_map(|dataset| {
2992 Some(DatasetAccess {
2993 url: dataset.url.clone()?,
2994 catalog: catalog.id.clone(),
2995 auth: dataset.object_store_auth()?,
2996 })
2997 })
2998 })
2999 .collect();
3000 }
3001
3002 pub fn validate(&self) -> Result<()> {
3004 let rows = self.analysis.chart_rows;
3005 if rows == 0 || rows > MAX_CHART_ROW_LIMIT {
3006 return Err(eyre!(
3007 "analysis.chart_rows must be between 1 and {MAX_CHART_ROW_LIMIT}, got {rows}"
3008 ));
3009 }
3010
3011 self.display
3014 .number_format
3015 .resolve(self.display.right_align_numbers)?;
3016
3017 let interval = self.read.follow_interval;
3018 if !FOLLOW_INTERVAL.contains(&interval.duration()) {
3019 return Err(eyre!(
3020 "read.follow_interval must be between 10ms and 1m, got {interval}"
3021 ));
3022 }
3023
3024 if let Some(c) = &self.csv.comment {
3025 crate::csv_dialect::check_comment_char(c).map_err(|e| eyre!("csv.comment: {e}"))?;
3026 }
3027
3028 if let Some(level) = &self.log.level
3029 && !datui_cli::LOG_LEVELS.contains(&level.as_str())
3030 {
3031 return Err(eyre!(
3032 "log.level must be one of {}, got {level:?}",
3033 datui_cli::LOG_LEVELS.join(", ")
3034 ));
3035 }
3036
3037 self.cloud.validate()?;
3038 for catalog in &self.read_catalogs {
3039 catalog
3040 .check_connections(&self.cloud.connections)
3041 .map_err(|e| eyre!("{}", e.in_file(&catalog.file_name())))?;
3042 }
3043 let hide_name = |name: &str| match name.split_once('/') {
3044 Some((catalog, id)) => is_valid_source_id(catalog) && is_valid_source_id(id),
3045 None => is_valid_source_id(name),
3046 };
3047 if let Some(name) = self.home.hide.iter().find(|name| !hide_name(name)) {
3048 return Err(eyre!(
3049 "home.hide: \"{name}\" is not a catalog id or catalog/id. Use the ids (mine, \
3050 examples, a listed file's name; examples/nyc-taxis for one entry), not the labels"
3051 ));
3052 }
3053
3054 let parser = ColorParser::new();
3056 self.theme.colors.validate(&parser)?;
3057
3058 crate::glyphs::validate_overrides(&self.glyphs.overrides)
3059 .map_err(|e| eyre!("[glyphs]: {e}"))?;
3060
3061 if crate::clipboard::BackendChoice::parse(&self.clipboard.backend).is_none() {
3062 return Err(eyre!(
3063 "[clipboard] backend must be auto, native or osc52, got {:?}",
3064 self.clipboard.backend
3065 ));
3066 }
3067 if self.clipboard.osc52_limit.bytes() == 0 {
3068 return Err(eyre!("[clipboard] osc52_limit must be greater than 0"));
3069 }
3070 if !crate::user_agent::is_valid(&self.http.user_agent) {
3071 return Err(eyre!(
3072 "[http] user_agent must be printable ASCII, got {:?}",
3073 self.http.user_agent
3074 ));
3075 }
3076
3077 Ok(())
3078 }
3079}
3080
3081impl ColorConfig {
3082 fn slots(&self) -> Vec<(String, String)> {
3084 match toml::Value::try_from(self) {
3085 Ok(toml::Value::Table(table)) => table
3086 .into_iter()
3087 .map(|(name, value)| (name, value.as_str().unwrap_or_default().to_string()))
3088 .collect(),
3089 _ => Vec::new(),
3090 }
3091 }
3092
3093 fn validate(&self, parser: &ColorParser) -> Result<()> {
3095 for (name, value) in self.slots() {
3096 if name == "table_alternate_row" && value == "default" {
3098 continue;
3099 }
3100 parser.parse(&value).map_err(|e| {
3101 eyre!(
3102 "theme.colors.{name}: {e}. Use a valid color name (e.g. red, cyan, \
3103 bright_red), hex (#rrggbb), or indexed(0-255)"
3104 )
3105 })?;
3106 }
3107 Ok(())
3108 }
3109}
3110
3111pub struct ColorParser {
3113 supports_true_color: bool,
3114 supports_256: bool,
3115 no_color: bool,
3116}
3117
3118impl ColorParser {
3119 pub fn new() -> Self {
3121 let no_color = std::env::var("NO_COLOR").is_ok();
3122 let support = supports_color::on(Stream::Stdout);
3123 #[cfg(windows)]
3124 let console = windows_console_true_color(
3125 std::env::var_os("TERM").is_some() || std::env::var_os("FORCE_COLOR").is_some(),
3127 std::io::IsTerminal::is_terminal(&std::io::stdout()),
3128 crossterm::ansi_support::supports_ansi,
3129 );
3130 #[cfg(not(windows))]
3131 let console = false;
3132
3133 Self {
3134 supports_true_color: console || support.as_ref().is_some_and(|s| s.has_16m),
3135 supports_256: console || support.as_ref().is_some_and(|s| s.has_256),
3136 no_color,
3137 }
3138 }
3139
3140 pub fn parse(&self, s: &str) -> Result<Color> {
3142 if self.no_color {
3143 return Ok(Color::Reset);
3144 }
3145
3146 let trimmed = s.trim();
3147
3148 if trimmed.starts_with('#') && trimmed.len() == 7 {
3150 let (r, g, b) = parse_hex(trimmed)?;
3151 return Ok(self.convert_rgb_to_terminal_color(r, g, b));
3152 }
3153
3154 if trimmed.to_lowercase().starts_with("indexed(") && trimmed.ends_with(')') {
3156 let num_str = &trimmed[8..trimmed.len() - 1]; let num = num_str.parse::<u8>().map_err(|_| {
3158 eyre!(
3159 "Invalid indexed color: '{}'. Expected format: indexed(0-255)",
3160 trimmed
3161 )
3162 })?;
3163 return Ok(Color::Indexed(num));
3164 }
3165
3166 let lower = trimmed.to_lowercase();
3168 match lower.as_str() {
3169 "black" => Ok(Color::Black),
3171 "red" => Ok(Color::Red),
3172 "green" => Ok(Color::Green),
3173 "yellow" => Ok(Color::Yellow),
3174 "blue" => Ok(Color::Blue),
3175 "magenta" => Ok(Color::Magenta),
3176 "cyan" => Ok(Color::Cyan),
3177 "white" => Ok(Color::White),
3178
3179 "bright_black" | "bright black" => Ok(Color::Indexed(8)),
3181 "bright_red" | "bright red" => Ok(Color::Indexed(9)),
3182 "bright_green" | "bright green" => Ok(Color::Indexed(10)),
3183 "bright_yellow" | "bright yellow" => Ok(Color::Indexed(11)),
3184 "bright_blue" | "bright blue" => Ok(Color::Indexed(12)),
3185 "bright_magenta" | "bright magenta" => Ok(Color::Indexed(13)),
3186 "bright_cyan" | "bright cyan" => Ok(Color::Indexed(14)),
3187 "bright_white" | "bright white" => Ok(Color::Indexed(15)),
3188
3189 "gray" | "grey" => Ok(Color::Indexed(8)),
3191 "dark_gray" | "dark gray" | "dark_grey" | "dark grey" => Ok(Color::Indexed(8)),
3192 "light_gray" | "light gray" | "light_grey" | "light grey" => Ok(Color::Indexed(7)),
3193
3194 "reset" | "default" | "none" | "reversed" => Ok(Color::Reset),
3196
3197 _ => Err(eyre!(
3198 "Unknown color name: '{}'. Supported: basic ANSI colors (red, blue, etc.), \
3199 bright variants (bright_red, etc.), or hex colors (#ff0000)",
3200 trimmed
3201 )),
3202 }
3203 }
3204
3205 fn convert_rgb_to_terminal_color(&self, r: u8, g: u8, b: u8) -> Color {
3207 if self.supports_true_color {
3208 Color::Rgb(r, g, b)
3209 } else if self.supports_256 {
3210 Color::Indexed(rgb_to_256_color(r, g, b))
3211 } else {
3212 rgb_to_basic_ansi(r, g, b)
3213 }
3214 }
3215}
3216
3217#[cfg(windows)]
3224fn windows_console_true_color(env_says: bool, terminal: bool, vt: impl FnOnce() -> bool) -> bool {
3225 !env_says && terminal && vt()
3226}
3227
3228impl Default for ColorParser {
3229 fn default() -> Self {
3230 Self::new()
3231 }
3232}
3233
3234fn parse_hex(s: &str) -> Result<(u8, u8, u8)> {
3236 let hex = s
3240 .strip_prefix('#')
3241 .filter(|hex| hex.len() == 6 && hex.is_ascii())
3242 .ok_or_else(|| {
3243 eyre!(
3244 "Invalid hex color format: '{}'. Expected format: #rrggbb",
3245 s
3246 )
3247 })?;
3248
3249 let r = u8::from_str_radix(&hex[0..2], 16)
3250 .map_err(|_| eyre!("Invalid red component in hex color: {}", s))?;
3251 let g = u8::from_str_radix(&hex[2..4], 16)
3252 .map_err(|_| eyre!("Invalid green component in hex color: {}", s))?;
3253 let b = u8::from_str_radix(&hex[4..6], 16)
3254 .map_err(|_| eyre!("Invalid blue component in hex color: {}", s))?;
3255
3256 Ok((r, g, b))
3257}
3258
3259pub fn rgb_to_256_color(r: u8, g: u8, b: u8) -> u8 {
3262 let dist = |cr: i32, cg: i32, cb: i32| -> i32 {
3267 let (dr, dg, db) = (cr - r as i32, cg - g as i32, cb - b as i32);
3268 dr * dr + dg * dg + db * db
3269 };
3270 const LEVELS: [i32; 6] = [0, 95, 135, 175, 215, 255];
3271 let mut best = (i32::MAX, 16u8);
3272 for (ri, &cr) in LEVELS.iter().enumerate() {
3273 for (gi, &cg) in LEVELS.iter().enumerate() {
3274 for (bi, &cb) in LEVELS.iter().enumerate() {
3275 let d = dist(cr, cg, cb);
3276 if d < best.0 {
3277 best = (d, 16 + 36 * ri as u8 + 6 * gi as u8 + bi as u8);
3278 }
3279 }
3280 }
3281 }
3282 for i in 0..24u8 {
3283 let v = 8 + 10 * i as i32;
3284 let d = dist(v, v, v);
3285 if d < best.0 {
3286 best = (d, 232 + i);
3287 }
3288 }
3289 best.1
3290}
3291
3292pub fn rgb_to_basic_ansi(r: u8, g: u8, b: u8) -> Color {
3294 let r_bright = r > 128;
3296 let g_bright = g > 128;
3297 let b_bright = b > 128;
3298
3299 let max_diff = r.max(g).max(b) as i16 - r.min(g).min(b) as i16;
3301 if max_diff < 30 {
3302 let avg = (r as u16 + g as u16 + b as u16) / 3;
3303 return if avg < 64 { Color::Black } else { Color::White };
3304 }
3305
3306 match (r_bright, g_bright, b_bright) {
3308 (false, false, false) => Color::Black,
3309 (true, false, false) => Color::Red,
3310 (false, true, false) => Color::Green,
3311 (true, true, false) => Color::Yellow,
3312 (false, false, true) => Color::Blue,
3313 (true, false, true) => Color::Magenta,
3314 (false, true, true) => Color::Cyan,
3315 (true, true, true) => Color::White,
3316 }
3317}
3318
3319#[derive(Debug, Clone)]
3321pub struct Theme {
3322 pub colors: HashMap<String, Color>,
3323}
3324
3325pub const CHART_SERIES_SLOTS: usize = 10;
3327
3328impl Theme {
3329 pub fn from_config(config: &ThemeConfig) -> Result<Self> {
3331 let parser = ColorParser::new();
3332 let mut colors = HashMap::new();
3333 for (name, value) in config.colors.slots() {
3334 let absent = match name.as_str() {
3338 "table_selected" => value.trim().eq_ignore_ascii_case("reversed"),
3339 "table_alternate_row" => value == "default",
3340 _ => false,
3341 };
3342 if !absent {
3343 colors.insert(name, parser.parse(&value)?);
3344 }
3345 }
3346 colors.insert("modal_border".to_string(), colors["sidebar_border"]);
3350 colors.insert("label".to_string(), colors["text_secondary"]);
3351 Ok(Self { colors })
3352 }
3353
3354 pub fn get(&self, name: &str) -> Color {
3356 self.colors.get(name).copied().unwrap_or(Color::Reset)
3357 }
3358
3359 pub fn get_optional(&self, name: &str) -> Option<Color> {
3361 self.colors.get(name).copied()
3362 }
3363
3364 pub fn series_colors(&self) -> Vec<Color> {
3369 let mut colors: Vec<Color> = Vec::with_capacity(CHART_SERIES_SLOTS);
3370 for i in 1..=CHART_SERIES_SLOTS {
3371 let color = self.get(&format!("chart_{i}"));
3372 if !colors.contains(&color) {
3373 colors.push(color);
3374 }
3375 }
3376 colors
3377 }
3378
3379 pub fn highlight_style(&self) -> ratatui::style::Style {
3382 match self.get_optional("table_selected") {
3383 Some(bg) => ratatui::style::Style::default().bg(bg),
3384 None => {
3385 ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
3386 }
3387 }
3388 }
3389
3390 pub fn column_cursor_style(&self) -> ratatui::style::Style {
3392 column_cursor_style(self.get_optional("table_column_cursor"))
3393 }
3394
3395 pub fn cell_cursor_style(&self) -> ratatui::style::Style {
3398 cell_cursor_style(self.get_optional("table_cell_cursor"))
3399 }
3400
3401 pub fn text_selection_style(&self) -> ratatui::style::Style {
3406 match self.get_optional("table_selected") {
3407 Some(Color::Reset | Color::Black | Color::White) => {
3408 ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
3409 }
3410 _ => self.highlight_style(),
3411 }
3412 }
3413
3414 pub fn find_match_style(&self) -> ratatui::style::Style {
3417 use ratatui::style::{Modifier, Style};
3418 match self.get("find_match") {
3419 Color::Reset => Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
3420 bg => Style::default().bg(bg).fg(contrasting_text(bg)),
3421 }
3422 }
3423
3424 pub fn cursor_text_for(&self, cursor: Color) -> Color {
3428 match self.get("input_cursor_text") {
3429 Color::Reset => contrasting_text(cursor),
3430 configured => configured,
3431 }
3432 }
3433}
3434
3435pub fn tint_shows(tint: Option<Color>) -> Option<Color> {
3438 tint.filter(|c| !matches!(c, Color::Reset | Color::Black | Color::White))
3439}
3440
3441pub fn column_cursor_style(tint: Option<Color>) -> ratatui::style::Style {
3444 match tint_shows(tint) {
3445 Some(bg) => ratatui::style::Style::default().bg(bg),
3446 None => ratatui::style::Style::default(),
3447 }
3448}
3449
3450pub fn cell_cursor_style(tint: Option<Color>) -> ratatui::style::Style {
3454 use ratatui::style::{Modifier, Style};
3455 match tint_shows(tint) {
3456 Some(bg) => Style::default().bg(bg).add_modifier(Modifier::BOLD),
3457 None => Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
3458 }
3459}
3460
3461fn contrasting_text(bg: Color) -> Color {
3463 let (r, g, b) = approx_rgb(bg);
3464 let luma = 299 * r as u32 + 587 * g as u32 + 114 * b as u32;
3465 if luma >= 128_000 {
3466 Color::Black
3467 } else {
3468 Color::White
3469 }
3470}
3471
3472fn approx_rgb(color: Color) -> (u8, u8, u8) {
3476 match color {
3477 Color::Rgb(r, g, b) => (r, g, b),
3478 Color::Indexed(i) => xterm_rgb(i),
3479 Color::Black => (0, 0, 0),
3480 Color::Red => (205, 0, 0),
3481 Color::Green => (0, 205, 0),
3482 Color::Yellow => (205, 205, 0),
3483 Color::Blue => (0, 0, 238),
3484 Color::Magenta => (205, 0, 205),
3485 Color::Cyan => (0, 205, 205),
3486 Color::Gray => (229, 229, 229),
3487 Color::DarkGray => (127, 127, 127),
3488 Color::LightRed => (255, 0, 0),
3489 Color::LightGreen => (0, 255, 0),
3490 Color::LightYellow => (255, 255, 0),
3491 Color::LightBlue => (92, 92, 255),
3492 Color::LightMagenta => (255, 0, 255),
3493 Color::LightCyan => (0, 255, 255),
3494 Color::White => (255, 255, 255),
3495 Color::Reset => (0, 0, 0),
3496 }
3497}
3498
3499fn xterm_rgb(i: u8) -> (u8, u8, u8) {
3501 match i {
3502 0..=15 => approx_rgb(match i {
3503 0 => Color::Black,
3504 1 => Color::Red,
3505 2 => Color::Green,
3506 3 => Color::Yellow,
3507 4 => Color::Blue,
3508 5 => Color::Magenta,
3509 6 => Color::Cyan,
3510 7 => Color::Gray,
3511 8 => Color::DarkGray,
3512 9 => Color::LightRed,
3513 10 => Color::LightGreen,
3514 11 => Color::LightYellow,
3515 12 => Color::LightBlue,
3516 13 => Color::LightMagenta,
3517 14 => Color::LightCyan,
3518 _ => Color::White,
3519 }),
3520 16..=231 => {
3521 let level = |n: u8| if n == 0 { 0 } else { 55 + 40 * n };
3522 let c = i - 16;
3523 (level(c / 36), level(c / 6 % 6), level(c % 6))
3524 }
3525 232..=255 => {
3526 let v = 8 + 10 * (i - 232);
3527 (v, v, v)
3528 }
3529 }
3530}
3531
3532fn wrap(text: &str, width: usize) -> Vec<String> {
3534 let mut lines: Vec<String> = Vec::new();
3535 for word in text.split_whitespace() {
3536 match lines.last_mut() {
3537 Some(line) if line.len() + 1 + word.len() <= width => {
3538 line.push(' ');
3539 line.push_str(word);
3540 }
3541 _ => lines.push(word.to_string()),
3542 }
3543 }
3544 lines
3545}
3546
3547#[cfg(test)]
3548mod tests {
3549 use std::path::{Path, PathBuf};
3550
3551 #[test]
3554 fn a_command_line_path_expands_only_a_leading_tilde() {
3555 let home = dirs::home_dir().expect("a home directory");
3556 let expand = |p: &str| super::expand_home(Path::new(p));
3557 assert_eq!(expand("~"), home);
3558 assert_eq!(expand("~/data/a.csv"), home.join("data/a.csv"));
3559 for kept in [
3560 "a/~/b.csv",
3561 "~user/a.csv",
3562 "$HOME/a.csv",
3563 "-",
3564 "s3://b/~/a.csv",
3565 ] {
3566 assert_eq!(expand(kept), PathBuf::from(kept), "{kept}");
3567 }
3568 #[cfg(windows)]
3569 assert_eq!(expand(r"~\data\a.csv"), home.join(r"data\a.csv"));
3570 #[cfg(not(windows))]
3572 assert_eq!(expand(r"~\a.csv"), PathBuf::from(r"~\a.csv"));
3573 for there in ["~", "~/a.csv"] {
3575 let kept = super::expand_home_unless(Path::new(there), |_| true);
3576 assert_eq!(kept, PathBuf::from(there), "{there}");
3577 }
3578 }
3579
3580 #[cfg(windows)]
3584 #[test]
3585 fn a_windows_console_with_vt_takes_true_color() {
3586 use super::windows_console_true_color as rule;
3587 assert!(rule(false, true, || true));
3588 assert!(!rule(false, true, || false), "a legacy console");
3589 assert!(
3590 !rule(true, true, || true),
3591 "TERM or FORCE_COLOR set: supports_color decides"
3592 );
3593 assert!(!rule(false, false, || true), "not a terminal");
3594 }
3595
3596 #[test]
3597 fn a_path_place_ignores_spelling_but_not_meaning() {
3598 let place = |p: &str| super::path_place(std::path::Path::new(p));
3599 for (a, b) in [
3600 ("/d/a.csv", "/d//a.csv"),
3601 ("/d/a.csv", "/d/./a.csv"),
3602 ("/d/sub", "/d/sub/"),
3603 ("a.csv", "./a.csv"),
3604 ] {
3605 assert_eq!(place(a), place(b), "{a} and {b}");
3606 }
3607 for (a, b) in [
3608 ("/d/../a.csv", "/a.csv"),
3609 ("/d/a.csv", "/d/A.csv"),
3610 ("/d/a.csv", "d/a.csv"),
3611 ("/d/a.csv", "/d/a.csv.gz"),
3612 ] {
3613 assert_ne!(place(a), place(b), "{a} and {b}");
3614 }
3615 #[cfg(windows)]
3616 {
3617 for (a, b) in [
3618 (r"C:\d\a.csv", r"c:\d\a.csv"),
3619 (r"C:\d\a.csv", "C:/d/a.csv"),
3620 (r"\\srv\share\a.csv", "//srv/share/a.csv"),
3621 ] {
3622 assert_eq!(place(a), place(b), "{a} and {b}");
3623 }
3624 for (a, b) in [
3625 (r"C:\d\a.csv", r"D:\d\a.csv"),
3626 (r"C:\a.csv", "C:a.csv"),
3627 (r"\\srv\share\a.csv", r"\\srv\other\a.csv"),
3628 ] {
3629 assert_ne!(place(a), place(b), "{a} and {b}");
3630 }
3631 }
3632 }
3633
3634 use super::*;
3635
3636 #[test]
3637 fn a_key_the_registry_does_not_know_is_named_with_the_nearest() {
3638 let found = |text: &str| unknown_keys_in(&toml::from_str(text).unwrap());
3639 let unknown = found(
3640 "[file_loading]\ncomment_char = \"#\"\n[display]\nmouse = false\nrow_numbr = true\n\
3641 number_format = { grouping = \"thousands\" }\n[glyphs]\nspinner = [\"a\"]\n\
3642 [theme.colors]\naccent = \"red\"\n[[cloud.connections]]\nname = \"x\"\n\
3643 [[sources]]\nname = \"x\"\n[home]\ndirectories = [\"/d\"]\n",
3644 );
3645 assert_eq!(unknown.len(), 4, "{unknown:?}");
3646 assert!(
3648 unknown[2].starts_with("home.directories is not read any more")
3649 && unknown[2].contains("Ctrl+D"),
3650 "{unknown:?}"
3651 );
3652 assert!(
3653 unknown[3].starts_with("sources is not read any more")
3654 && unknown[3].contains("catalog.toml"),
3655 "{unknown:?}"
3656 );
3657 assert!(
3658 unknown[0].starts_with("display.row_numbr")
3659 && unknown[0].contains("display.row_numbers")
3660 );
3661 assert!(
3662 unknown[1].starts_with("file_loading.comment_char")
3663 && unknown[1].contains("csv.comment")
3664 );
3665 }
3666
3667 #[test]
3668 fn a_field_selection_stays_visible_when_the_tint_degrades() {
3669 use ratatui::style::{Modifier, Style};
3670 let theme_with = |tint: Option<Color>| Theme {
3671 colors: tint
3672 .map(|c| HashMap::from([("table_selected".to_string(), c)]))
3673 .unwrap_or_default(),
3674 };
3675 let reversed = Style::default().add_modifier(Modifier::REVERSED);
3676 for tint in [Color::Black, Color::White, Color::Reset] {
3678 assert_eq!(
3679 theme_with(Some(tint)).text_selection_style(),
3680 reversed,
3681 "{tint:?}"
3682 );
3683 }
3684 assert_eq!(theme_with(None).text_selection_style(), reversed);
3685 for tint in [
3686 Color::Rgb(0x28, 0x34, 0x57),
3687 Color::Indexed(237),
3688 Color::Blue,
3689 ] {
3690 assert_eq!(
3691 theme_with(Some(tint)).text_selection_style(),
3692 Style::default().bg(tint)
3693 );
3694 }
3695 }
3696
3697 fn leaf_settings() -> Vec<(String, toml::Value)> {
3700 fn walk(table: &toml::Table, prefix: &str, out: &mut Vec<(String, toml::Value)>) {
3701 for (key, value) in table {
3702 let path = if prefix.is_empty() {
3703 key.clone()
3704 } else {
3705 format!("{prefix}.{key}")
3706 };
3707 match value {
3708 toml::Value::Table(inner) => walk(inner, &path, out),
3709 toml::Value::Array(items) if items.iter().any(toml::Value::is_table) => {}
3710 _ => out.push((path, value.clone())),
3711 }
3712 }
3713 }
3714 let toml::Value::Table(defaults) = toml::Value::try_from(AppConfig::default()).unwrap()
3715 else {
3716 unreachable!("a struct serializes to a table")
3717 };
3718 let mut out = Vec::new();
3719 walk(&defaults, "", &mut out);
3720 for setting in datui_cli::settings::SETTINGS {
3721 if let datui_cli::settings::DefaultValue::Unset(example) = setting.default
3722 && !setting.key.ends_with(".*")
3723 && setting.kind != datui_cli::settings::Kind::Tables
3724 {
3725 let value: toml::Table = toml::from_str(&format!("v = {example}")).unwrap();
3726 out.push((setting.key.to_string(), value["v"].clone()));
3727 }
3728 }
3729 out
3730 }
3731
3732 fn layer_at(path: &str, value: toml::Value) -> Result<ConfigLayer> {
3734 let table = path.rsplit('.').fold(value, |inner, key| {
3735 toml::Value::Table(toml::Table::from_iter([(key.to_string(), inner)]))
3736 });
3737 ConfigLayer::parse(&toml::to_string(&table)?)
3738 }
3739
3740 fn value_at<'a>(table: &'a toml::Table, path: &str) -> Option<&'a toml::Value> {
3741 let (parents, key) = path.rsplit_once('.').unwrap_or(("", path));
3742 let mut table = table;
3743 for part in parents.split('.').filter(|p| !p.is_empty()) {
3744 table = table.get(part)?.as_table()?;
3745 }
3746 table.get(key)
3747 }
3748
3749 #[test]
3753 fn the_registry_and_the_config_structs_agree() {
3754 use datui_cli::settings::{DefaultValue, Kind, SETTINGS, find};
3755 let toml::Value::Table(defaults) = toml::Value::try_from(AppConfig::default()).unwrap()
3756 else {
3757 unreachable!("a struct serializes to a table")
3758 };
3759 let light = toml::Value::try_from(ColorConfig::light()).unwrap();
3760 for (path, value) in leaf_settings() {
3761 let setting = find(&path).unwrap_or_else(|| panic!("{path} is not registered"));
3762 let registered = match setting.default {
3763 DefaultValue::Value(v) | DefaultValue::Unset(v) => {
3764 toml::from_str::<toml::Table>(&format!("v = {v}")).unwrap()["v"].clone()
3765 }
3766 DefaultValue::Color { dark, light: lit } => {
3767 let name = setting.name();
3768 assert_eq!(
3769 light.get(name).and_then(|v| v.as_str()),
3770 Some(lit),
3771 "{path} (light)"
3772 );
3773 toml::Value::String(dark.to_string())
3774 }
3775 };
3776 assert_eq!(value, registered, "{path}: the registry's default differs");
3777 }
3778 for setting in SETTINGS {
3779 if setting.key.ends_with(".*") || setting.kind == Kind::Tables {
3780 continue;
3781 }
3782 let serialized = value_at(&defaults, setting.key).is_some();
3783 let unset = matches!(setting.default, DefaultValue::Unset(_));
3784 assert_eq!(
3785 serialized,
3786 !unset,
3787 "{}: registered as {}set by default",
3788 setting.key,
3789 if unset { "un" } else { "" }
3790 );
3791 let example = match setting.default {
3793 DefaultValue::Value(v) | DefaultValue::Unset(v) => v.to_string(),
3794 DefaultValue::Color { dark, .. } => format!("\"{dark}\""),
3795 };
3796 let value =
3797 toml::from_str::<toml::Table>(&format!("v = {example}")).unwrap()["v"].clone();
3798 layer_at(setting.key, value).unwrap_or_else(|e| panic!("{}: {e}", setting.key));
3799 }
3800 }
3801
3802 #[test]
3803 fn the_generated_config_shows_every_key_and_parses_uncommented() {
3804 let generated = ConfigManager::with_dir(PathBuf::new()).generate_default_config();
3805 let mut shown = std::collections::HashSet::new();
3806 let mut section = String::new();
3807 let mut uncommented = String::new();
3808 for line in generated.lines() {
3809 let Some(line) = line.strip_prefix("# ") else {
3810 uncommented.push_str(line);
3811 uncommented.push('\n');
3812 continue;
3813 };
3814 if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
3815 section = name.to_string();
3816 uncommented.push_str(line);
3817 uncommented.push('\n');
3818 } else if let Some((key, _)) = line.split_once(" = ")
3819 && !key.contains(' ')
3820 {
3821 shown.insert(match section.as_str() {
3822 "" => key.to_string(),
3823 s => format!("{s}.{key}"),
3824 });
3825 uncommented.push_str(line);
3826 uncommented.push('\n');
3827 }
3828 }
3829 for (path, _) in leaf_settings() {
3830 assert!(
3831 shown.contains(&path),
3832 "{path} is not in the generated config"
3833 );
3834 }
3835 ConfigLayer::parse(&uncommented).unwrap();
3837 }
3838
3839 #[test]
3840 fn every_setting_written_as_its_default_overrides_an_import() {
3841 let defaults = leaf_settings();
3845 let mut import = ConfigLayer::default();
3846 let mut moved = Vec::new();
3847 for (path, value) in &defaults {
3848 if path == "import" || COMBINED_KEYS.iter().any(|(p, _)| p == path) {
3850 continue;
3851 }
3852 let other = match value {
3853 toml::Value::Boolean(b) => toml::Value::Boolean(!b),
3854 toml::Value::Integer(n) => toml::Value::Integer(n + 1),
3855 toml::Value::Array(items) if items.is_empty() => vec!["x"].into(),
3856 toml::Value::Array(_) => toml::Value::Array(Vec::new()),
3857 toml::Value::String(text) if text == "auto" && path == "display.row_numbers" => {
3859 true.into()
3860 }
3861 toml::Value::String(text) => format!("{text}0").into(),
3862 other => panic!("{path}: no rule to change {other}"),
3863 };
3864 if let Ok(layer) = layer_at(path, other.clone()) {
3866 import.merge(layer);
3867 moved.push((path.clone(), other));
3868 }
3869 }
3870 assert!(moved.len() > 100, "only {} settings moved", moved.len());
3871
3872 let serialized = |config: AppConfig| match toml::Value::try_from(config).unwrap() {
3873 toml::Value::Table(table) => table,
3874 _ => unreachable!("a struct serializes to a table"),
3875 };
3876 let kept = serialized(AppConfig::from_layers([import.clone()]).unwrap());
3877 for (path, other) in &moved {
3878 assert_eq!(value_at(&kept, path), Some(other), "{path} was not read");
3879 }
3880
3881 let mut own = ConfigLayer::default();
3882 for (path, value) in &defaults {
3883 own.merge(layer_at(path, value.clone()).unwrap());
3884 }
3885 let restored = serialized(AppConfig::from_layers([import, own]).unwrap());
3886 for (path, _) in &moved {
3887 let default = defaults.iter().find(|(p, _)| p == path).map(|(_, v)| v);
3888 assert_eq!(value_at(&restored, path), default, "{path} kept the import");
3889 }
3890 }
3891}