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