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 journal_bytes: ByteSize,
1783 pub sdf_fields: usize,
1785 pub vcd_signals: usize,
1787 pub fix_tags: usize,
1789 pub fix_fields: usize,
1791 pub gpx_fields: usize,
1793 pub npy_header_bytes: ByteSize,
1795}
1796
1797impl LimitsConfig {
1798 pub const DEFAULT: Self = Self {
1799 indexed_records: 64 << 20,
1800 elf_symbols: 10_000_000,
1801 midi_bytes: ByteSize::mib(64),
1802 midi_events: 10_000_000,
1803 detail_rows: 10_000,
1804 journal_bytes: ByteSize::mib(1024),
1805 sdf_fields: 4096,
1806 vcd_signals: 1 << 20,
1807 fix_tags: 4096,
1808 fix_fields: 4096,
1809 gpx_fields: 256,
1810 npy_header_bytes: ByteSize::mib(4),
1811 };
1812}
1813
1814impl Default for LimitsConfig {
1815 fn default() -> Self {
1816 Self::DEFAULT
1817 }
1818}
1819
1820impl Default for AppConfig {
1821 fn default() -> Self {
1822 let mut config = Self {
1823 import: Vec::new(),
1824 catalogs: Vec::new(),
1825 read_catalogs: Vec::new(),
1826 catalog_dir: None,
1827 broken_catalogs: Vec::new(),
1828 read: ReadConfig::default(),
1829 csv: CsvConfig::default(),
1830 display: DisplayConfig::default(),
1831 performance: PerformanceConfig::default(),
1832 analysis: AnalysisConfig::default(),
1833 chart: ChartConfig::default(),
1834 home: HomeConfig::default(),
1835 cloud: CloudConfig::default(),
1836 http: HttpConfig::default(),
1837 query: QueryConfig::default(),
1838 views: ViewsConfig::default(),
1839 clipboard: ClipboardConfig::default(),
1840 formats: FormatsConfig::default(),
1841 limits: LimitsConfig::DEFAULT,
1842 log: LogConfig::default(),
1843 theme: ThemeConfig::default(),
1844 glyphs: GlyphsConfig::default(),
1845 };
1846 config.sync_dataset_access();
1847 config
1848 }
1849}
1850
1851impl Default for DisplayConfig {
1852 fn default() -> Self {
1853 Self {
1854 unicode: crate::glyphs::UnicodeMode::default(),
1855 row_numbers: RowNumbers::Auto,
1856 row_numbers_start: 1,
1857 cell_padding: CellPadding::default(),
1858 column_colors: true,
1859 type_row: true,
1860 notes_accent: true,
1861 mouse: true,
1862 scroll_region: true,
1863 sidebar_width: None,
1864 right_align_numbers: true,
1865 number_format: NumberFormatConfig::default(),
1866 }
1867 }
1868}
1869
1870impl Default for PerformanceConfig {
1871 fn default() -> Self {
1872 Self {
1873 pages_ahead: 3,
1874 pages_behind: 3,
1875 max_buffered_rows: crate::table::DEFAULT_MAX_BUFFERED_ROWS,
1876 max_buffered: ByteSize::mib(512),
1877 streaming: true,
1878 threads: 0,
1879 }
1880 }
1881}
1882
1883impl Default for ColorConfig {
1884 fn default() -> Self {
1886 Self::dark()
1887 }
1888}
1889
1890impl ColorConfig {
1891 pub fn for_mode(mode: ThemeMode) -> Self {
1893 match mode.resolve() {
1894 ThemeMode::Light => Self::light(),
1895 _ => Self::dark(),
1896 }
1897 }
1898
1899 pub fn dark() -> Self {
1901 Self {
1905 chip_key: "#7dcfff".to_string(),
1906 chip_label: "#a9b1d6".to_string(),
1907 throbber: "#7dcfff".to_string(),
1908 success: "#9ece6a".to_string(),
1909 error: "#f7768e".to_string(),
1910 warning: "#e0af68".to_string(),
1911 dimmed: "#565f89".to_string(),
1912 background: "default".to_string(),
1913 surface: "default".to_string(),
1914 controls_bg: "#262a3f".to_string(),
1915 text_primary: "default".to_string(),
1916 text_secondary: "#737aa2".to_string(),
1917 text_inverse: "#1a1b26".to_string(),
1918 table_header: "#c0caf5".to_string(),
1919 table_header_bg: "#2b3047".to_string(),
1920 table_row_numbers: "#565f89".to_string(),
1921 table_column_separator: "#3b4261".to_string(),
1922 table_selected: "#283457".to_string(),
1923 table_column_cursor: "#292e42".to_string(),
1926 table_cell_cursor: "#3b4261".to_string(),
1927 sidebar_border: "#565f89".to_string(),
1929 modal_border_active: "#7dcfff".to_string(),
1930 modal_border_error: "#f7768e".to_string(),
1931 distribution_normal: "#9ece6a".to_string(),
1932 distribution_skewed: "#e0af68".to_string(),
1933 distribution_other: "#c0caf5".to_string(),
1934 outlier_marker: "#f7768e".to_string(),
1935 input_cursor: "default".to_string(),
1936 input_cursor_text: "default".to_string(),
1937 table_alternate_row: "#1e2030".to_string(),
1938 type_str: "#9ece6a".to_string(),
1939 type_int: "#7aa2f7".to_string(),
1940 type_float: "#2ac3de".to_string(),
1941 type_bool: "#e0af68".to_string(),
1942 type_temporal: "#bb9af7".to_string(),
1943 type_binary: "#565f89".to_string(),
1944 chart_1: "#7dcfff".to_string(),
1945 chart_2: "#bb9af7".to_string(),
1946 chart_3: "#9ece6a".to_string(),
1947 chart_4: "#e0af68".to_string(),
1948 chart_5: "#7aa2f7".to_string(),
1949 chart_6: "#f7768e".to_string(),
1950 chart_7: "#ff9e64".to_string(),
1951 chart_8: "#1abc9c".to_string(),
1954 chart_9: "#ff5fd2".to_string(),
1955 chart_10: "#f4ef8a".to_string(),
1956 chart_grid: "#3d4785".to_string(),
1958 accent: "#7dcfff".to_string(),
1959 accent_bright: "#a4daff".to_string(),
1960 gradient_start: "#7aa2f7".to_string(),
1961 gradient_end: "#bb9af7".to_string(),
1962 find_match: "#e0af68".to_string(),
1963 hex_null: "#565f89".to_string(),
1964 hex_printable: "#7dcfff".to_string(),
1965 hex_whitespace: "#9ece6a".to_string(),
1966 hex_control: "#bb9af7".to_string(),
1967 hex_high: "#e0af68".to_string(),
1968 hex_ff: "#f7768e".to_string(),
1969 }
1970 }
1971
1972 pub fn light() -> Self {
1975 Self {
1978 chip_key: "#2e7de9".to_string(),
1979 chip_label: "#3760bf".to_string(),
1980 throbber: "#2e7de9".to_string(),
1981 success: "#587539".to_string(),
1982 error: "#f52a65".to_string(),
1983 warning: "#8c6c3e".to_string(),
1984 dimmed: "#848cb5".to_string(),
1985 background: "default".to_string(),
1986 surface: "default".to_string(),
1987 controls_bg: "#d0d5e3".to_string(),
1988 text_primary: "default".to_string(),
1989 text_secondary: "#6172b0".to_string(),
1990 text_inverse: "#e1e2e7".to_string(),
1991 table_header: "#3760bf".to_string(),
1992 table_header_bg: "#c4c8da".to_string(),
1993 table_row_numbers: "#848cb5".to_string(),
1994 table_column_separator: "#a8aecb".to_string(),
1995 table_selected: "#b6bfe2".to_string(),
1996 table_column_cursor: "#cbd3f2".to_string(),
1997 table_cell_cursor: "#a0aef0".to_string(),
1998 sidebar_border: "#6172b0".to_string(),
1999 modal_border_active: "#2e7de9".to_string(),
2000 modal_border_error: "#f52a65".to_string(),
2001 distribution_normal: "#587539".to_string(),
2002 distribution_skewed: "#8c6c3e".to_string(),
2003 distribution_other: "#3760bf".to_string(),
2004 outlier_marker: "#f52a65".to_string(),
2005 input_cursor: "default".to_string(),
2006 input_cursor_text: "default".to_string(),
2007 table_alternate_row: "#dcdfea".to_string(),
2008 type_str: "#587539".to_string(),
2009 type_int: "#2e7de9".to_string(),
2010 type_float: "#007197".to_string(),
2011 type_bool: "#8c6c3e".to_string(),
2012 type_temporal: "#9854f1".to_string(),
2013 type_binary: "#848cb5".to_string(),
2014 chart_1: "#2e7de9".to_string(),
2015 chart_2: "#9854f1".to_string(),
2016 chart_3: "#587539".to_string(),
2017 chart_4: "#8c6c3e".to_string(),
2018 chart_5: "#007197".to_string(),
2019 chart_6: "#f52a65".to_string(),
2020 chart_7: "#b15c00".to_string(),
2021 chart_8: "#118c74".to_string(),
2023 chart_9: "#d1188c".to_string(),
2024 chart_10: "#24357a".to_string(),
2025 chart_grid: "#70aabf".to_string(),
2027 accent: "#2e7de9".to_string(),
2028 accent_bright: "#1a6cd0".to_string(),
2029 gradient_start: "#2e7de9".to_string(),
2030 gradient_end: "#9854f1".to_string(),
2031 find_match: "#f0c35a".to_string(),
2032 hex_null: "#848cb5".to_string(),
2033 hex_printable: "#007197".to_string(),
2034 hex_whitespace: "#587539".to_string(),
2035 hex_control: "#9854f1".to_string(),
2036 hex_high: "#8c6c3e".to_string(),
2037 hex_ff: "#f52a65".to_string(),
2038 }
2039 }
2040}
2041
2042impl Default for QueryConfig {
2043 fn default() -> Self {
2044 Self {
2045 history_limit: 1000,
2046 history: true,
2047 default_mode: QueryMode::default(),
2048 }
2049 }
2050}
2051
2052const MAX_IMPORT_DEPTH: usize = 8;
2054
2055pub fn expand_config_path(raw: &str) -> PathBuf {
2058 expand_path(raw)
2059}
2060
2061pub fn expand_home(path: &Path) -> PathBuf {
2066 expand_home_unless(path, |p| p.symlink_metadata().is_ok())
2067}
2068
2069fn expand_home_unless(path: &Path, there: impl FnOnce(&Path) -> bool) -> PathBuf {
2070 path.to_str()
2071 .and_then(home_path)
2072 .filter(|_| !there(path))
2073 .unwrap_or_else(|| path.to_path_buf())
2074}
2075
2076fn home_path(text: &str) -> Option<PathBuf> {
2079 if text == "~" {
2080 return dirs::home_dir();
2081 }
2082 let rest = text
2083 .strip_prefix("~/")
2084 .or_else(|| text.strip_prefix("~\\").filter(|_| cfg!(windows)))?;
2086 dirs::home_dir().map(|home| home.join(rest))
2087}
2088
2089pub(crate) fn path_place(path: &Path) -> PathBuf {
2093 use std::path::{Component, Prefix};
2094 let mut place = PathBuf::new();
2095 for component in path.components() {
2096 match component {
2097 Component::CurDir => {}
2098 Component::Prefix(prefix) => match prefix.kind() {
2099 Prefix::Disk(drive) => {
2100 place.push(format!("{}:", char::from(drive.to_ascii_uppercase())));
2101 }
2102 Prefix::UNC(server, share) => {
2103 let mut unc = std::ffi::OsString::from(r"\\");
2104 unc.push(server);
2105 unc.push(r"\");
2106 unc.push(share);
2107 place.push(unc);
2108 }
2109 _ => place.push(prefix.as_os_str()),
2110 },
2111 other => place.push(other),
2112 }
2113 }
2114 place
2115}
2116
2117pub(crate) fn expand_path(raw: &str) -> PathBuf {
2118 let mut expanded = String::with_capacity(raw.len());
2119 let mut chars = raw.chars().peekable();
2120
2121 while let Some(c) = chars.next() {
2122 if c != '$' {
2123 expanded.push(c);
2124 continue;
2125 }
2126
2127 let braced = chars.peek() == Some(&'{');
2128 if braced {
2129 chars.next();
2130 }
2131
2132 let mut name = String::new();
2133 while let Some(&next) = chars.peek() {
2134 if braced && next == '}' {
2135 chars.next();
2136 break;
2137 }
2138 if !next.is_ascii_alphanumeric() && next != '_' {
2139 break;
2140 }
2141 name.push(next);
2142 chars.next();
2143 }
2144
2145 if name.is_empty() {
2146 expanded.push('$');
2148 } else if let Ok(value) = std::env::var(&name) {
2149 expanded.push_str(&value);
2150 }
2151 }
2152
2153 home_path(&expanded).unwrap_or_else(|| PathBuf::from(expanded))
2155}
2156
2157#[derive(Debug, Clone, Default, PartialEq)]
2161pub struct ConfigLayer {
2162 table: toml::Table,
2163 imports: Vec<String>,
2165}
2166
2167#[derive(Debug, Clone, PartialEq, Eq)]
2169pub enum LayerSource {
2170 File(PathBuf),
2172 Override,
2174}
2175
2176impl std::fmt::Display for LayerSource {
2177 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2178 match self {
2179 Self::File(path) => write!(f, "{}", path.display()),
2180 Self::Override => f.write_str("-c"),
2181 }
2182 }
2183}
2184
2185#[derive(Debug, Clone, Copy)]
2187enum Combine {
2188 ByName,
2191 Union,
2193}
2194
2195const COMBINED_KEYS: &[(&str, Combine)] = &[
2198 ("formats.path", Combine::Union),
2199 ("catalogs", Combine::Union),
2200 ("cloud.connections", Combine::ByName),
2201 ("cloud.hide", Combine::Union),
2202 ("cloud.env_files", Combine::Union),
2203 ("home.hide", Combine::Union),
2204];
2205
2206pub fn way_out(imported: bool, what: &str) -> String {
2209 if imported {
2210 format!("Fix that {what}, or move the file aside: a missing import is skipped.")
2211 } else {
2212 format!(
2213 "Fix that {what}, or move the file aside to start from the defaults; \
2214 `datui config init` then writes a fresh one."
2215 )
2216 }
2217}
2218
2219impl ConfigLayer {
2220 pub fn parse(text: &str) -> Result<Self> {
2222 let typed: AppConfig = toml::from_str(text)?;
2223 let table: toml::Table = toml::from_str(text)?;
2224 Ok(Self::from_table(table, typed.import))
2225 }
2226
2227 pub fn from_overrides(overrides: &[datui_cli::settings::Override]) -> Result<Self> {
2230 let mut table = toml::Table::new();
2231 for o in overrides {
2232 let mut at = &mut table;
2233 let mut parts: Vec<&str> = o.key.split('.').collect();
2234 let last = parts.pop().unwrap_or_default();
2235 for part in parts {
2236 let entry = at
2237 .entry(part.to_string())
2238 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2239 if !entry.is_table() {
2240 *entry = toml::Value::Table(toml::Table::new());
2241 }
2242 at = entry.as_table_mut().expect("just made a table");
2243 }
2244 at.insert(last.to_string(), o.value.clone());
2245 }
2246 toml::Value::Table(table.clone())
2247 .try_into::<AppConfig>()
2248 .map_err(|e| eyre!("-c: {}", e.message().trim_end()))?;
2249 Ok(Self::from_table(table, Vec::new()))
2250 }
2251
2252 fn from_table(mut table: toml::Table, imports: Vec<String>) -> Self {
2253 table.remove("import");
2254 Self { table, imports }
2255 }
2256
2257 fn read(path: &Path, importer: Option<&Path>) -> Result<Option<Self>> {
2260 let named = match importer {
2262 Some(importer) => format!("{} (imported by {})", path.display(), importer.display()),
2263 None => path.display().to_string(),
2264 };
2265 let content = match std::fs::read_to_string(path) {
2266 Ok(content) => content,
2267 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2268 Err(e) => return Err(eyre!("Failed to read config file at {named}: {e}")),
2269 };
2270 let mut layer = Self::parse(&content).map_err(|e| {
2271 eyre!(
2272 "Failed to parse config file at {named}: {}\n{}",
2273 parse_reason(&e),
2274 way_out(importer.is_some(), "line")
2275 )
2276 })?;
2277 for unknown in unknown_keys_in(&layer.table) {
2279 eprintln!("datui: warning: {}: {unknown}", path.display());
2280 }
2281 layer.anchor_paths(path.parent().unwrap_or_else(|| Path::new(".")));
2282 Ok(Some(layer))
2283 }
2284
2285 fn anchor_paths(&mut self, dir: &Path) {
2288 if let Some(toml::Value::Array(entries)) = self
2289 .table
2290 .get_mut("formats")
2291 .and_then(|f| f.get_mut("path"))
2292 {
2293 for entry in entries {
2294 if let toml::Value::String(path) = entry
2295 && !path.trim().is_empty()
2296 && expand_path(path).is_relative()
2297 {
2298 *path = dir.join(expand_path(path)).to_string_lossy().into_owned();
2299 }
2300 }
2301 }
2302 if let Some(toml::Value::Array(files)) = self.table.get_mut("catalogs") {
2303 for entry in files {
2304 let path = match entry {
2306 toml::Value::Table(table) => table.get_mut("path"),
2307 other => Some(other),
2308 };
2309 if let Some(toml::Value::String(path)) = path
2310 && !path.trim().is_empty()
2311 && expand_path(path).is_relative()
2312 {
2313 *path = dir.join(expand_path(path)).to_string_lossy().into_owned();
2314 }
2315 }
2316 }
2317 }
2318
2319 pub fn get(&self, key: &str) -> Option<&toml::Value> {
2321 let mut parts = key.split('.');
2322 let mut value = self.table.get(parts.next()?)?;
2323 for part in parts {
2324 value = value.as_table()?.get(part)?;
2325 }
2326 Some(value)
2327 }
2328
2329 pub fn merge(&mut self, upper: ConfigLayer) {
2332 merge_tables(&mut self.table, upper.table, "");
2333 }
2334}
2335
2336const RETIRED_KEYS: &[(&str, &str)] = &[
2338 (
2339 "sources",
2340 "a collection is a catalog file now; put its datasets in catalog.toml as [id] \
2341 tables, or list the file in catalogs = [...] (datui catalog check FILE)",
2342 ),
2343 (
2344 "home.directories",
2345 "a directory is a catalog entry now; Ctrl+D on its row adds it to catalog.toml",
2346 ),
2347 (
2348 "home.builtin_catalog",
2349 "home.hide = [\"examples\"] hides the example datasets",
2350 ),
2351];
2352
2353fn unknown_keys_in(table: &toml::Table) -> Vec<String> {
2357 fn walk(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
2358 for (key, value) in table {
2359 let path = if prefix.is_empty() {
2360 key.clone()
2361 } else {
2362 format!("{prefix}.{key}")
2363 };
2364 if datui_cli::settings::find(&path).is_some() {
2365 continue;
2366 }
2367 if let Some((_, moved)) = RETIRED_KEYS.iter().find(|(key, _)| *key == path) {
2368 out.push(format!("{path} is not read any more: {moved}"));
2369 continue;
2370 }
2371 match value {
2372 toml::Value::Table(inner) => walk(inner, &path, out),
2375 _ => {
2376 let near = datui_cli::settings::suggestions(&path);
2377 let mut said = format!("{path} is not a config key, and is not read");
2378 if !near.is_empty() {
2379 said.push_str(&format!("; did you mean {}?", near.join(" or ")));
2380 }
2381 out.push(said);
2382 }
2383 }
2384 }
2385 }
2386 let mut out = Vec::new();
2387 walk(table, "", &mut out);
2388 out.sort();
2389 out
2390}
2391
2392fn parse_reason(error: &color_eyre::eyre::Report) -> String {
2395 let Some(toml_error) = error.downcast_ref::<toml::de::Error>() else {
2396 return error.to_string();
2397 };
2398 let message = toml_error.message().trim_end();
2399 let full = toml_error.to_string();
2400 let body = full.trim_end().strip_suffix(message).unwrap_or(&full);
2401 match body.split_once('\n') {
2402 Some((head, excerpt)) if head.starts_with("TOML parse error at ") => format!(
2403 "{message} ({})\n{}",
2404 head.trim_start_matches("TOML parse error at "),
2405 excerpt.trim_end()
2406 ),
2407 _ => message.to_string(),
2408 }
2409}
2410
2411fn merge_tables(lower: &mut toml::Table, upper: toml::Table, prefix: &str) {
2412 for (key, value) in upper {
2413 let path = if prefix.is_empty() {
2414 key.clone()
2415 } else {
2416 format!("{prefix}.{key}")
2417 };
2418 let combine = COMBINED_KEYS
2419 .iter()
2420 .find(|(p, _)| *p == path)
2421 .map(|(_, c)| *c);
2422 match (combine, value) {
2423 (Some(combine), toml::Value::Array(upper)) => {
2424 let mut combined = match lower.remove(&key) {
2425 Some(toml::Value::Array(lower)) => lower,
2426 _ => Vec::new(),
2427 };
2428 match combine {
2429 Combine::ByName => merge_by_name(&mut combined, upper),
2430 Combine::Union => {
2431 for item in upper {
2432 if !combined.contains(&item) {
2433 combined.push(item);
2434 }
2435 }
2436 }
2437 }
2438 lower.insert(key, toml::Value::Array(combined));
2439 }
2440 (None, toml::Value::Table(upper)) => match lower.get_mut(&key) {
2441 Some(toml::Value::Table(lower)) => merge_tables(lower, upper, &path),
2442 _ => {
2443 lower.insert(key, toml::Value::Table(upper));
2444 }
2445 },
2446 (_, value) => {
2447 lower.insert(key, value);
2448 }
2449 }
2450 }
2451}
2452
2453fn merge_by_name(lower: &mut Vec<toml::Value>, upper: Vec<toml::Value>) {
2454 let name_of = |entry: &toml::Value| {
2455 entry
2456 .get("name")
2457 .and_then(toml::Value::as_str)
2458 .map(str::to_owned)
2459 };
2460 let mut seen = std::collections::HashSet::new();
2461 for entry in upper {
2462 let name = name_of(&entry);
2463 let first = name.clone().is_some_and(|n| seen.insert(n));
2464 match lower
2465 .iter()
2466 .position(|e| name.is_some() && name_of(e) == name)
2467 {
2468 Some(i) if first => lower[i] = entry,
2469 _ => lower.push(entry),
2470 }
2471 }
2472}
2473
2474impl AppConfig {
2476 pub fn load_with(app_name: &str, overrides: &[datui_cli::settings::Override]) -> Result<Self> {
2479 match ConfigManager::new(app_name) {
2480 Ok(manager) => {
2481 Self::load_from_file_with(&manager.config_path("config.toml"), overrides)
2482 }
2483 Err(_) => {
2485 let layers = vec![ConfigLayer::from_overrides(overrides)?];
2486 let mut config =
2487 Self::from_layers(layers).map_err(|e| eyre!("Invalid configuration: {}", e))?;
2488 config.read_theme_files(None)?;
2489 config
2490 .validate()
2491 .map_err(|e| eyre!("Invalid configuration: {}", e))?;
2492 Ok(config)
2493 }
2494 }
2495 }
2496
2497 pub fn load_from_file(config_path: &Path) -> Result<Self> {
2503 Self::load_from_file_with(config_path, &[])
2504 }
2505
2506 pub fn load_from_file_with(
2508 config_path: &Path,
2509 overrides: &[datui_cli::settings::Override],
2510 ) -> Result<Self> {
2511 let layers = Self::read_layers(config_path, overrides)?;
2512 Self::from_read_layers(config_path, overrides, &layers)
2513 }
2514
2515 pub fn read_layers(
2519 config_path: &Path,
2520 overrides: &[datui_cli::settings::Override],
2521 ) -> Result<Vec<(LayerSource, ConfigLayer)>> {
2522 let mut layers = Vec::new();
2523 if let Some(root) = ConfigLayer::read(config_path, None)? {
2524 let canonical = crate::canonical::canonicalize(config_path)
2525 .unwrap_or_else(|_| config_path.to_path_buf());
2526 let mut stack = vec![canonical];
2527 Self::collect_imports(&root.imports, config_path, &mut stack, &mut layers)?;
2528 layers.push((LayerSource::File(config_path.to_path_buf()), root));
2529 }
2530 if !overrides.is_empty() {
2531 layers.push((
2532 LayerSource::Override,
2533 ConfigLayer::from_overrides(overrides)?,
2534 ));
2535 }
2536 Ok(layers)
2537 }
2538
2539 pub fn from_read_layers(
2542 config_path: &Path,
2543 overrides: &[datui_cli::settings::Override],
2544 layers: &[(LayerSource, ConfigLayer)],
2545 ) -> Result<Self> {
2546 let place = if overrides.is_empty() {
2548 config_path.display().to_string()
2549 } else {
2550 format!("{} with -c", config_path.display())
2551 };
2552 let imports = layers
2553 .iter()
2554 .find(|(source, _)| *source == LayerSource::File(config_path.to_path_buf()))
2555 .map(|(_, root)| root.imports.clone())
2556 .unwrap_or_default();
2557
2558 let mut config = Self::from_layers(layers.iter().map(|(_, layer)| layer.clone()))
2559 .map_err(|e| eyre!("Invalid configuration in {place}: {e}"))?;
2560 config.import = imports;
2562 config.read_theme_files(config_path.parent())?;
2563 config.read_catalog_files(config_path.parent())?;
2565 for broken in &config.broken_catalogs {
2566 eprintln!("datui: warning: catalog left out: {}", broken.full());
2567 log::warn!(target: "datui", "catalog left out: {}", broken.full());
2568 }
2569 for name in config.unknown_hidden() {
2571 eprintln!("datui: warning: home.hide: {}", Self::hides_nothing(&name));
2572 }
2573
2574 config.validate().map_err(|e| {
2575 eyre!(
2576 "Invalid configuration in {place}: {e}\n{}",
2577 way_out(false, "setting")
2578 )
2579 })?;
2580
2581 Ok(config)
2582 }
2583
2584 fn collect_imports(
2587 imports: &[String],
2588 origin: &Path,
2589 stack: &mut Vec<PathBuf>,
2590 out: &mut Vec<(LayerSource, ConfigLayer)>,
2591 ) -> Result<()> {
2592 if imports.is_empty() {
2593 return Ok(());
2594 }
2595
2596 if stack.len() >= MAX_IMPORT_DEPTH {
2597 return Err(eyre!(
2598 "config import chain is more than {} files deep (at {}); \
2599 flatten the chain or remove the extra levels",
2600 MAX_IMPORT_DEPTH,
2601 origin.display()
2602 ));
2603 }
2604
2605 let origin_dir = origin.parent().unwrap_or_else(|| Path::new("."));
2606
2607 for entry in imports {
2608 let expanded = expand_path(entry);
2609 let path = if expanded.is_absolute() {
2610 expanded
2611 } else {
2612 origin_dir.join(expanded)
2613 };
2614
2615 let canonical = crate::canonical::canonicalize(&path).unwrap_or_else(|_| path.clone());
2616 if stack.contains(&canonical) {
2617 return Err(eyre!(
2618 "circular config import: {} is already being loaded (imported by {})",
2619 canonical.display(),
2620 origin.display()
2621 ));
2622 }
2623
2624 let Some(layer) = ConfigLayer::read(&path, Some(origin))? else {
2625 eprintln!(
2626 "datui: warning: config import not found, skipping: {} (imported by {})",
2627 path.display(),
2628 origin.display()
2629 );
2630 continue;
2631 };
2632
2633 stack.push(canonical);
2634 Self::collect_imports(&layer.imports, &path, stack, out)?;
2635 stack.pop();
2636
2637 out.push((LayerSource::File(path), layer));
2638 }
2639
2640 Ok(())
2641 }
2642
2643 pub fn from_layers(layers: impl IntoIterator<Item = ConfigLayer>) -> Result<Self> {
2648 let mut merged = ConfigLayer::default();
2649 for layer in layers {
2650 merged.merge(layer);
2651 }
2652 let mut table = merged.table;
2653
2654 let theme = table.get_mut("theme").and_then(toml::Value::as_table_mut);
2655 let mode: ThemeMode = match theme.as_ref().and_then(|t| t.get("mode")) {
2656 Some(mode) => mode.clone().try_into()?,
2657 None => ThemeMode::default(),
2658 };
2659 let resolved = mode.resolve();
2660 let colors = theme.and_then(|t| t.remove("colors"));
2661
2662 let mut config: AppConfig = toml::Value::Table(table).try_into()?;
2663
2664 if let Some(toml::Value::Table(colors)) = colors {
2665 config.theme.overrides = colors;
2666 }
2667 config.theme.follow = mode == ThemeMode::Auto;
2668 config.theme.mode = Some(resolved);
2669 config
2671 .theme
2672 .use_library(crate::config::themes::Library::default(), resolved);
2673 config.theme.colors = config.theme.palette_for(resolved)?;
2674 config.sync_dataset_access();
2675 Ok(config)
2676 }
2677
2678 pub fn read_theme_files(&mut self, config_dir: Option<&Path>) -> Result<()> {
2682 let library = crate::config::themes::Library::read(config_dir);
2683 let active = self.theme.mode.unwrap_or_default().resolve();
2684 self.theme.use_library(library, active);
2685 for warning in self.theme.warnings() {
2686 eprintln!("datui: {warning}");
2687 log::warn!(target: "datui", "{warning}");
2688 }
2689 self.theme.colors = self.theme.palette_for(active)?;
2690 Ok(())
2691 }
2692
2693 pub fn catalogs(&self) -> Vec<crate::home::catalog::Catalog> {
2696 let mut all = self.read_catalogs.clone();
2697 if !all.iter().any(|c| c.id == crate::home::catalog::EXAMPLES) {
2698 all.push(crate::home::catalog::bundled());
2699 }
2700 all
2701 }
2702
2703 pub fn shown_catalogs(&self) -> Vec<crate::home::catalog::Catalog> {
2706 self.catalogs()
2707 .into_iter()
2708 .filter(|c| !self.home.hide.contains(&c.id))
2709 .map(|mut c| {
2710 c.datasets.retain(|d| {
2711 !self
2712 .home
2713 .hide
2714 .iter()
2715 .any(|h| h.split_once('/') == Some((c.id.as_str(), d.id.as_str())))
2716 });
2717 c
2718 })
2719 .collect()
2720 }
2721
2722 pub fn hides_nothing(name: &str) -> String {
2725 let old = crate::home::catalog::OLD_EXAMPLES_ID;
2726 let renamed = match name.split_once('/') {
2727 None if name == old => Some(crate::home::catalog::EXAMPLES.to_string()),
2728 Some((catalog, id)) if catalog == old => {
2729 Some(format!("{}/{id}", crate::home::catalog::EXAMPLES))
2730 }
2731 _ => None,
2732 };
2733 match renamed {
2734 Some(new) => format!("`{name}` is now `{new}`: hide = [\"{new}\"]"),
2735 None => format!("no catalog or entry is named {name}"),
2736 }
2737 }
2738
2739 pub fn unknown_hidden(&self) -> Vec<String> {
2741 let catalogs = self.catalogs();
2742 let mut out: Vec<String> = Vec::new();
2743 for name in &self.home.hide {
2744 let known = match name.split_once('/') {
2745 None => {
2746 catalogs.iter().any(|c| c.id == *name)
2747 || self.broken_catalogs.iter().any(|b| b.id == *name)
2748 }
2749 Some((catalog, _)) if self.broken_catalogs.iter().any(|b| b.id == catalog) => true,
2750 Some((catalog, id)) => catalogs
2751 .iter()
2752 .any(|c| c.id == catalog && c.datasets.iter().any(|d| d.id == id)),
2753 };
2754 if !known && !out.contains(name) {
2755 out.push(name.clone());
2756 }
2757 }
2758 out
2759 }
2760
2761 pub fn read_catalog_files(&mut self, config_dir: Option<&Path>) -> Result<()> {
2765 use crate::home::catalog::{self, Origin};
2766 let mut read: Vec<catalog::Catalog> = Vec::new();
2767 let mut broken: Vec<catalog::Broken> = Vec::new();
2768 let connections = self.cloud.connections.clone();
2769 let take = |found: std::result::Result<Option<catalog::Catalog>, catalog::Broken>,
2771 read: &mut Vec<catalog::Catalog>,
2772 broken: &mut Vec<catalog::Broken>|
2773 -> bool {
2774 match found {
2775 Ok(Some(c)) => match c.check_connections(&connections) {
2776 Ok(()) => {
2777 read.push(c);
2778 true
2779 }
2780 Err(e) => {
2781 broken.push(catalog::Broken {
2782 id: c.id.clone(),
2783 origin: c.origin,
2784 file: c.file.clone().unwrap_or_default(),
2785 line: e.line,
2786 message: e.message,
2787 });
2788 true
2789 }
2790 },
2791 Ok(None) => false,
2792 Err(b) => {
2793 broken.push(b);
2794 true
2795 }
2796 }
2797 };
2798 let mut files: Vec<(PathBuf, Origin, Option<String>, Option<String>)> = Vec::new();
2800 if let Some(dir) = config_dir {
2801 take(
2802 catalog::load(&dir.join(catalog::MINE_FILE), catalog::MINE, Origin::Mine),
2803 &mut read,
2804 &mut broken,
2805 );
2806 let folder = dir.join(catalog::FOLDER);
2807 let mut found: Vec<PathBuf> = match std::fs::read_dir(&folder) {
2808 Ok(entries) => entries
2809 .filter_map(|e| e.ok().map(|e| e.path()))
2810 .filter(|p| {
2811 p.extension().is_some_and(|x| x == "toml")
2812 && std::fs::metadata(p).is_ok_and(|m| m.is_file())
2813 })
2814 .collect(),
2815 Err(_) => Vec::new(),
2816 };
2817 found.sort();
2818 files.extend(found.into_iter().map(|p| (p, Origin::Folder, None, None)));
2819 }
2820 files.extend(self.catalogs.iter().map(|entry| {
2821 (
2822 expand_path(entry.path()),
2823 Origin::Listed,
2824 entry.id().map(str::to_string),
2825 entry.label().map(str::to_string),
2826 )
2827 }));
2828 for (path, origin, given_id, label) in files {
2829 let id = given_id
2830 .clone()
2831 .unwrap_or_else(|| catalog::id_of_file(&path));
2832 let refuse = |message: String| catalog::Broken {
2833 id: id.clone(),
2834 origin,
2835 file: path.clone(),
2836 line: None,
2837 message,
2838 };
2839 if !is_valid_source_id(&id) || id == catalog::MINE {
2840 broken.push(refuse(format!(
2841 "\"{id}\" cannot be a catalog's id: lowercase letters, digits and '-', \
2842 and not \"{}\", which is catalog.toml's. {}",
2843 catalog::MINE,
2844 if given_id.is_some() {
2845 "Give another id = \"...\""
2846 } else {
2847 "Rename the file, or list it as { path = \"...\", id = \"...\" }"
2848 }
2849 )));
2850 continue;
2851 }
2852 if let Some(first) = read.iter().find(|c| c.id == id) {
2853 let first = first.file_name();
2854 broken.push(refuse(format!(
2855 "{first} and this file are both the catalog \"{id}\". Rename one, or \
2856 list one as {{ path = \"...\", id = \"...\" }}"
2857 )));
2858 continue;
2859 }
2860 let found = catalog::load(&path, &id, origin).map(|found| {
2861 found.map(|mut listed| {
2862 if let Some(label) = &label {
2863 listed.label = label.clone();
2864 }
2865 listed
2866 })
2867 });
2868 if !take(found, &mut read, &mut broken) {
2869 eprintln!(
2870 "datui: warning: catalog not found, skipping: {}",
2871 path.display()
2872 );
2873 }
2874 }
2875 self.read_catalogs = read;
2876 self.broken_catalogs = broken;
2877 self.catalog_dir = config_dir.map(Path::to_path_buf);
2878 self.sync_dataset_access();
2879 Ok(())
2880 }
2881
2882 pub fn sync_dataset_access(&mut self) {
2885 self.cloud.dataset_access = self
2886 .catalogs()
2887 .iter()
2888 .flat_map(|catalog| {
2889 catalog.datasets.iter().filter_map(|dataset| {
2890 Some(DatasetAccess {
2891 url: dataset.url.clone()?,
2892 catalog: catalog.id.clone(),
2893 auth: dataset.object_store_auth()?,
2894 })
2895 })
2896 })
2897 .collect();
2898 }
2899
2900 pub fn validate(&self) -> Result<()> {
2902 let rows = self.analysis.chart_rows;
2903 if rows == 0 || rows > MAX_CHART_ROW_LIMIT {
2904 return Err(eyre!(
2905 "analysis.chart_rows must be between 1 and {MAX_CHART_ROW_LIMIT}, got {rows}"
2906 ));
2907 }
2908
2909 self.display
2911 .number_format
2912 .resolve(self.display.right_align_numbers)?;
2913
2914 let interval = self.read.follow_interval;
2915 if !FOLLOW_INTERVAL.contains(&interval.duration()) {
2916 return Err(eyre!(
2917 "read.follow_interval must be between 10ms and 1m, got {interval}"
2918 ));
2919 }
2920
2921 if let Some(c) = &self.csv.comment {
2922 crate::formats::csv_dialect::check_comment_char(c)
2923 .map_err(|e| eyre!("csv.comment: {e}"))?;
2924 }
2925
2926 if let Some(level) = &self.log.level
2927 && !datui_cli::LOG_LEVELS.contains(&level.as_str())
2928 {
2929 return Err(eyre!(
2930 "log.level must be one of {}, got {level:?}",
2931 datui_cli::LOG_LEVELS.join(", ")
2932 ));
2933 }
2934
2935 self.cloud.validate()?;
2936 for catalog in &self.read_catalogs {
2937 catalog
2938 .check_connections(&self.cloud.connections)
2939 .map_err(|e| eyre!("{}", e.in_file(&catalog.file_name())))?;
2940 }
2941 let hide_name = |name: &str| match name.split_once('/') {
2942 Some((catalog, id)) => is_valid_source_id(catalog) && is_valid_source_id(id),
2943 None => is_valid_source_id(name),
2944 };
2945 if let Some(name) = self.home.hide.iter().find(|name| !hide_name(name)) {
2946 return Err(eyre!(
2947 "home.hide: \"{name}\" is not a catalog id or catalog/id. Use the ids (mine, \
2948 examples, a listed file's name; examples/nyc-taxis for one entry), not the labels"
2949 ));
2950 }
2951
2952 let parser = ColorParser::new();
2953 self.theme.colors.validate(&parser)?;
2954
2955 crate::glyphs::validate_overrides(&self.glyphs.overrides)
2956 .map_err(|e| eyre!("[glyphs]: {e}"))?;
2957
2958 if crate::clipboard::BackendChoice::parse(&self.clipboard.backend).is_none() {
2959 return Err(eyre!(
2960 "[clipboard] backend must be auto, native or osc52, got {:?}",
2961 self.clipboard.backend
2962 ));
2963 }
2964 if self.clipboard.osc52_limit.bytes() == 0 {
2965 return Err(eyre!("[clipboard] osc52_limit must be greater than 0"));
2966 }
2967 if !crate::cloud::user_agent::is_valid(&self.http.user_agent) {
2968 return Err(eyre!(
2969 "[http] user_agent must be printable ASCII, got {:?}",
2970 self.http.user_agent
2971 ));
2972 }
2973
2974 Ok(())
2975 }
2976}
2977
2978impl ColorConfig {
2979 pub(crate) fn slots(&self) -> Vec<(String, String)> {
2981 match toml::Value::try_from(self) {
2982 Ok(toml::Value::Table(table)) => table
2983 .into_iter()
2984 .map(|(name, value)| (name, value.as_str().unwrap_or_default().to_string()))
2985 .collect(),
2986 _ => Vec::new(),
2987 }
2988 }
2989
2990 fn validate(&self, parser: &ColorParser) -> Result<()> {
2992 for (name, value) in self.slots() {
2993 if name == "table_alternate_row" && value == "default" {
2995 continue;
2996 }
2997 parser.parse(&value).map_err(|e| {
2998 eyre!(
2999 "theme.colors.{name}: {e}. Use a valid color name (e.g. red, cyan, \
3000 bright_red), hex (#rrggbb), or indexed(0-255)"
3001 )
3002 })?;
3003 }
3004 Ok(())
3005 }
3006}
3007
3008pub struct ColorParser {
3010 supports_true_color: bool,
3011 supports_256: bool,
3012 no_color: bool,
3013}
3014
3015impl ColorParser {
3016 pub fn new() -> Self {
3018 let no_color = std::env::var("NO_COLOR").is_ok();
3019 let support = supports_color::on(Stream::Stdout);
3020 #[cfg(windows)]
3021 let console = windows_console_true_color(
3022 std::env::var_os("TERM").is_some() || std::env::var_os("FORCE_COLOR").is_some(),
3024 std::io::IsTerminal::is_terminal(&std::io::stdout()),
3025 crossterm::ansi_support::supports_ansi,
3026 );
3027 #[cfg(not(windows))]
3028 let console = false;
3029
3030 Self {
3031 supports_true_color: console || support.as_ref().is_some_and(|s| s.has_16m),
3032 supports_256: console || support.as_ref().is_some_and(|s| s.has_256),
3033 no_color,
3034 }
3035 }
3036
3037 pub fn parse(&self, s: &str) -> Result<Color> {
3039 if self.no_color {
3040 return Ok(Color::Reset);
3041 }
3042
3043 let trimmed = s.trim();
3044
3045 if trimmed.starts_with('#') && trimmed.len() == 7 {
3047 let (r, g, b) = parse_hex(trimmed)?;
3048 return Ok(self.convert_rgb_to_terminal_color(r, g, b));
3049 }
3050
3051 if trimmed.to_lowercase().starts_with("indexed(") && trimmed.ends_with(')') {
3053 let num_str = &trimmed[8..trimmed.len() - 1]; let num = num_str.parse::<u8>().map_err(|_| {
3055 eyre!(
3056 "Invalid indexed color: '{}'. Expected format: indexed(0-255)",
3057 trimmed
3058 )
3059 })?;
3060 return Ok(Color::Indexed(num));
3061 }
3062
3063 let lower = trimmed.to_lowercase();
3065 match lower.as_str() {
3066 "black" => Ok(Color::Black),
3068 "red" => Ok(Color::Red),
3069 "green" => Ok(Color::Green),
3070 "yellow" => Ok(Color::Yellow),
3071 "blue" => Ok(Color::Blue),
3072 "magenta" => Ok(Color::Magenta),
3073 "cyan" => Ok(Color::Cyan),
3074 "white" => Ok(Color::White),
3075
3076 "bright_black" | "bright black" => Ok(Color::Indexed(8)),
3078 "bright_red" | "bright red" => Ok(Color::Indexed(9)),
3079 "bright_green" | "bright green" => Ok(Color::Indexed(10)),
3080 "bright_yellow" | "bright yellow" => Ok(Color::Indexed(11)),
3081 "bright_blue" | "bright blue" => Ok(Color::Indexed(12)),
3082 "bright_magenta" | "bright magenta" => Ok(Color::Indexed(13)),
3083 "bright_cyan" | "bright cyan" => Ok(Color::Indexed(14)),
3084 "bright_white" | "bright white" => Ok(Color::Indexed(15)),
3085
3086 "gray" | "grey" => Ok(Color::Indexed(8)),
3088 "dark_gray" | "dark gray" | "dark_grey" | "dark grey" => Ok(Color::Indexed(8)),
3089 "light_gray" | "light gray" | "light_grey" | "light grey" => Ok(Color::Indexed(7)),
3090
3091 "reset" | "default" | "none" | "reversed" => Ok(Color::Reset),
3093
3094 _ => Err(eyre!(
3095 "Unknown color name: '{}'. Supported: basic ANSI colors (red, blue, etc.), \
3096 bright variants (bright_red, etc.), or hex colors (#ff0000)",
3097 trimmed
3098 )),
3099 }
3100 }
3101
3102 fn convert_rgb_to_terminal_color(&self, r: u8, g: u8, b: u8) -> Color {
3104 if self.supports_true_color {
3105 Color::Rgb(r, g, b)
3106 } else if self.supports_256 {
3107 Color::Indexed(rgb_to_256_color(r, g, b))
3108 } else {
3109 rgb_to_basic_ansi(r, g, b)
3110 }
3111 }
3112}
3113
3114#[cfg(windows)]
3119fn windows_console_true_color(env_says: bool, terminal: bool, vt: impl FnOnce() -> bool) -> bool {
3120 !env_says && terminal && vt()
3121}
3122
3123impl Default for ColorParser {
3124 fn default() -> Self {
3125 Self::new()
3126 }
3127}
3128
3129fn parse_hex(s: &str) -> Result<(u8, u8, u8)> {
3131 let hex = s
3134 .strip_prefix('#')
3135 .filter(|hex| hex.len() == 6 && hex.is_ascii())
3136 .ok_or_else(|| {
3137 eyre!(
3138 "Invalid hex color format: '{}'. Expected format: #rrggbb",
3139 s
3140 )
3141 })?;
3142
3143 let r = u8::from_str_radix(&hex[0..2], 16)
3144 .map_err(|_| eyre!("Invalid red component in hex color: {}", s))?;
3145 let g = u8::from_str_radix(&hex[2..4], 16)
3146 .map_err(|_| eyre!("Invalid green component in hex color: {}", s))?;
3147 let b = u8::from_str_radix(&hex[4..6], 16)
3148 .map_err(|_| eyre!("Invalid blue component in hex color: {}", s))?;
3149
3150 Ok((r, g, b))
3151}
3152
3153pub fn rgb_to_256_color(r: u8, g: u8, b: u8) -> u8 {
3155 let dist = |cr: i32, cg: i32, cb: i32| -> i32 {
3158 let (dr, dg, db) = (cr - r as i32, cg - g as i32, cb - b as i32);
3159 dr * dr + dg * dg + db * db
3160 };
3161 const LEVELS: [i32; 6] = [0, 95, 135, 175, 215, 255];
3162 let mut best = (i32::MAX, 16u8);
3163 for (ri, &cr) in LEVELS.iter().enumerate() {
3164 for (gi, &cg) in LEVELS.iter().enumerate() {
3165 for (bi, &cb) in LEVELS.iter().enumerate() {
3166 let d = dist(cr, cg, cb);
3167 if d < best.0 {
3168 best = (d, 16 + 36 * ri as u8 + 6 * gi as u8 + bi as u8);
3169 }
3170 }
3171 }
3172 }
3173 for i in 0..24u8 {
3174 let v = 8 + 10 * i as i32;
3175 let d = dist(v, v, v);
3176 if d < best.0 {
3177 best = (d, 232 + i);
3178 }
3179 }
3180 best.1
3181}
3182
3183pub fn rgb_to_basic_ansi(r: u8, g: u8, b: u8) -> Color {
3185 let r_bright = r > 128;
3187 let g_bright = g > 128;
3188 let b_bright = b > 128;
3189
3190 let max_diff = r.max(g).max(b) as i16 - r.min(g).min(b) as i16;
3192 if max_diff < 30 {
3193 let avg = (r as u16 + g as u16 + b as u16) / 3;
3194 return if avg < 64 { Color::Black } else { Color::White };
3195 }
3196
3197 match (r_bright, g_bright, b_bright) {
3198 (false, false, false) => Color::Black,
3199 (true, false, false) => Color::Red,
3200 (false, true, false) => Color::Green,
3201 (true, true, false) => Color::Yellow,
3202 (false, false, true) => Color::Blue,
3203 (true, false, true) => Color::Magenta,
3204 (false, true, true) => Color::Cyan,
3205 (true, true, true) => Color::White,
3206 }
3207}
3208
3209#[derive(Debug, Clone)]
3211pub struct Theme {
3212 pub colors: HashMap<String, Color>,
3213}
3214
3215pub const CHART_SERIES_SLOTS: usize = 10;
3217
3218macro_rules! color_slots {
3221 ($($slot:ident),* $(,)?) => {
3222 impl Theme {
3223 $(
3224 pub fn $slot(&self) -> Color {
3225 self.get(stringify!($slot))
3226 }
3227 )*
3228 }
3229
3230 #[cfg(test)]
3232 pub(crate) const COLOR_SLOTS: &[&str] = &[$(stringify!($slot)),*];
3233 };
3234}
3235
3236color_slots!(
3237 accent,
3238 accent_bright,
3239 background,
3240 chart_1,
3241 chart_grid,
3242 chip_key,
3243 chip_label,
3244 controls_bg,
3245 dimmed,
3246 distribution_normal,
3247 distribution_skewed,
3248 error,
3249 find_match,
3250 gradient_end,
3251 gradient_start,
3252 hex_control,
3253 hex_ff,
3254 hex_high,
3255 hex_null,
3256 hex_printable,
3257 hex_whitespace,
3258 input_cursor,
3259 label,
3260 modal_border,
3261 modal_border_active,
3262 modal_border_error,
3263 outlier_marker,
3264 sidebar_border,
3265 success,
3266 surface,
3267 table_column_separator,
3268 table_header,
3269 table_header_bg,
3270 table_row_numbers,
3271 text_inverse,
3272 text_primary,
3273 text_secondary,
3274 throbber,
3275 type_binary,
3276 type_bool,
3277 type_float,
3278 type_int,
3279 type_str,
3280 type_temporal,
3281 warning,
3282);
3283
3284impl Theme {
3285 pub fn from_config(config: &ThemeConfig) -> Result<Self> {
3287 let parser = ColorParser::new();
3288 let mut colors = HashMap::new();
3289 for (name, value) in config.colors.slots() {
3290 let absent = match name.as_str() {
3293 "table_selected" => value.trim().eq_ignore_ascii_case("reversed"),
3294 "table_alternate_row" => value == "default",
3295 _ => false,
3296 };
3297 if !absent {
3298 colors.insert(name, parser.parse(&value)?);
3299 }
3300 }
3301 colors.insert("modal_border".to_string(), colors["sidebar_border"]);
3304 colors.insert("label".to_string(), colors["text_secondary"]);
3305 Ok(Self { colors })
3306 }
3307
3308 fn get(&self, name: &str) -> Color {
3311 self.colors.get(name).copied().unwrap_or(Color::Reset)
3312 }
3313
3314 pub fn get_optional(&self, name: &str) -> Option<Color> {
3316 self.colors.get(name).copied()
3317 }
3318
3319 pub fn series_colors(&self) -> Vec<Color> {
3322 let mut colors: Vec<Color> = Vec::with_capacity(CHART_SERIES_SLOTS);
3323 for i in 1..=CHART_SERIES_SLOTS {
3324 let color = self.get(&format!("chart_{i}"));
3325 if !colors.contains(&color) {
3326 colors.push(color);
3327 }
3328 }
3329 colors
3330 }
3331
3332 pub fn highlight_style(&self) -> ratatui::style::Style {
3335 match self.get_optional("table_selected") {
3336 Some(bg) => ratatui::style::Style::default().bg(bg),
3337 None => {
3338 ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
3339 }
3340 }
3341 }
3342
3343 pub fn column_cursor_style(&self) -> ratatui::style::Style {
3345 column_cursor_style(self.get_optional("table_column_cursor"))
3346 }
3347
3348 pub fn cell_cursor_style(&self) -> ratatui::style::Style {
3351 cell_cursor_style(self.get_optional("table_cell_cursor"))
3352 }
3353
3354 pub fn text_selection_style(&self) -> ratatui::style::Style {
3357 match self.get_optional("table_selected") {
3358 Some(Color::Reset | Color::Black | Color::White) => {
3359 ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
3360 }
3361 _ => self.highlight_style(),
3362 }
3363 }
3364
3365 pub fn find_match_style(&self) -> ratatui::style::Style {
3368 use ratatui::style::{Modifier, Style};
3369 match self.get("find_match") {
3370 Color::Reset => Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
3371 bg => Style::default().bg(bg).fg(contrasting_text(bg)),
3372 }
3373 }
3374
3375 pub fn cursor_text_for(&self, cursor: Color) -> Color {
3378 match self.get("input_cursor_text") {
3379 Color::Reset => contrasting_text(cursor),
3380 configured => configured,
3381 }
3382 }
3383}
3384
3385pub fn tint_shows(tint: Option<Color>) -> Option<Color> {
3388 tint.filter(|c| !matches!(c, Color::Reset | Color::Black | Color::White))
3389}
3390
3391pub fn column_cursor_style(tint: Option<Color>) -> ratatui::style::Style {
3394 match tint_shows(tint) {
3395 Some(bg) => ratatui::style::Style::default().bg(bg),
3396 None => ratatui::style::Style::default(),
3397 }
3398}
3399
3400pub fn cell_cursor_style(tint: Option<Color>) -> ratatui::style::Style {
3404 use ratatui::style::{Modifier, Style};
3405 match tint_shows(tint) {
3406 Some(bg) => Style::default().bg(bg).add_modifier(Modifier::BOLD),
3407 None => Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
3408 }
3409}
3410
3411fn contrasting_text(bg: Color) -> Color {
3413 let (r, g, b) = approx_rgb(bg);
3414 let luma = 299 * r as u32 + 587 * g as u32 + 114 * b as u32;
3415 if luma >= 128_000 {
3416 Color::Black
3417 } else {
3418 Color::White
3419 }
3420}
3421
3422fn approx_rgb(color: Color) -> (u8, u8, u8) {
3425 match color {
3426 Color::Rgb(r, g, b) => (r, g, b),
3427 Color::Indexed(i) => xterm_rgb(i),
3428 Color::Black => (0, 0, 0),
3429 Color::Red => (205, 0, 0),
3430 Color::Green => (0, 205, 0),
3431 Color::Yellow => (205, 205, 0),
3432 Color::Blue => (0, 0, 238),
3433 Color::Magenta => (205, 0, 205),
3434 Color::Cyan => (0, 205, 205),
3435 Color::Gray => (229, 229, 229),
3436 Color::DarkGray => (127, 127, 127),
3437 Color::LightRed => (255, 0, 0),
3438 Color::LightGreen => (0, 255, 0),
3439 Color::LightYellow => (255, 255, 0),
3440 Color::LightBlue => (92, 92, 255),
3441 Color::LightMagenta => (255, 0, 255),
3442 Color::LightCyan => (0, 255, 255),
3443 Color::White => (255, 255, 255),
3444 Color::Reset => (0, 0, 0),
3445 }
3446}
3447
3448fn xterm_rgb(i: u8) -> (u8, u8, u8) {
3450 match i {
3451 0..=15 => approx_rgb(match i {
3452 0 => Color::Black,
3453 1 => Color::Red,
3454 2 => Color::Green,
3455 3 => Color::Yellow,
3456 4 => Color::Blue,
3457 5 => Color::Magenta,
3458 6 => Color::Cyan,
3459 7 => Color::Gray,
3460 8 => Color::DarkGray,
3461 9 => Color::LightRed,
3462 10 => Color::LightGreen,
3463 11 => Color::LightYellow,
3464 12 => Color::LightBlue,
3465 13 => Color::LightMagenta,
3466 14 => Color::LightCyan,
3467 _ => Color::White,
3468 }),
3469 16..=231 => {
3470 let level = |n: u8| if n == 0 { 0 } else { 55 + 40 * n };
3471 let c = i - 16;
3472 (level(c / 36), level(c / 6 % 6), level(c % 6))
3473 }
3474 232..=255 => {
3475 let v = 8 + 10 * (i - 232);
3476 (v, v, v)
3477 }
3478 }
3479}
3480
3481fn wrap(text: &str, width: usize) -> Vec<String> {
3483 let mut lines: Vec<String> = Vec::new();
3484 for word in text.split_whitespace() {
3485 match lines.last_mut() {
3486 Some(line) if line.len() + 1 + word.len() <= width => {
3487 line.push(' ');
3488 line.push_str(word);
3489 }
3490 _ => lines.push(word.to_string()),
3491 }
3492 }
3493 lines
3494}
3495
3496#[cfg(test)]
3497mod tests {
3498 use std::path::{Path, PathBuf};
3499
3500 #[test]
3503 fn a_command_line_path_expands_only_a_leading_tilde() {
3504 let home = dirs::home_dir().expect("a home directory");
3505 let expand = |p: &str| super::expand_home(Path::new(p));
3506 assert_eq!(expand("~"), home);
3507 assert_eq!(expand("~/data/a.csv"), home.join("data/a.csv"));
3508 for kept in [
3509 "a/~/b.csv",
3510 "~user/a.csv",
3511 "$HOME/a.csv",
3512 "-",
3513 "s3://b/~/a.csv",
3514 ] {
3515 assert_eq!(expand(kept), PathBuf::from(kept), "{kept}");
3516 }
3517 #[cfg(windows)]
3518 assert_eq!(expand(r"~\data\a.csv"), home.join(r"data\a.csv"));
3519 #[cfg(not(windows))]
3521 assert_eq!(expand(r"~\a.csv"), PathBuf::from(r"~\a.csv"));
3522 for there in ["~", "~/a.csv"] {
3524 let kept = super::expand_home_unless(Path::new(there), |_| true);
3525 assert_eq!(kept, PathBuf::from(there), "{there}");
3526 }
3527 }
3528
3529 #[test]
3532 fn no_color_parses_every_color_as_reset() {
3533 use ratatui::style::Color;
3534 let parser = super::ColorParser {
3535 supports_true_color: true,
3536 supports_256: true,
3537 no_color: true,
3538 };
3539 for name in ["red", "#ff0000", "cyan", "indexed(240)"] {
3540 assert_eq!(parser.parse(name).unwrap(), Color::Reset, "{name}");
3541 }
3542 }
3543
3544 #[cfg(windows)]
3548 #[test]
3549 fn a_windows_console_with_vt_takes_true_color() {
3550 use super::windows_console_true_color as rule;
3551 assert!(rule(false, true, || true));
3552 assert!(!rule(false, true, || false), "a legacy console");
3553 assert!(
3554 !rule(true, true, || true),
3555 "TERM or FORCE_COLOR set: supports_color decides"
3556 );
3557 assert!(!rule(false, false, || true), "not a terminal");
3558 }
3559
3560 #[test]
3561 fn a_path_place_ignores_spelling_but_not_meaning() {
3562 let place = |p: &str| super::path_place(std::path::Path::new(p));
3563 for (a, b) in [
3564 ("/d/a.csv", "/d//a.csv"),
3565 ("/d/a.csv", "/d/./a.csv"),
3566 ("/d/sub", "/d/sub/"),
3567 ("a.csv", "./a.csv"),
3568 ] {
3569 assert_eq!(place(a), place(b), "{a} and {b}");
3570 }
3571 for (a, b) in [
3572 ("/d/../a.csv", "/a.csv"),
3573 ("/d/a.csv", "/d/A.csv"),
3574 ("/d/a.csv", "d/a.csv"),
3575 ("/d/a.csv", "/d/a.csv.gz"),
3576 ] {
3577 assert_ne!(place(a), place(b), "{a} and {b}");
3578 }
3579 #[cfg(windows)]
3580 {
3581 for (a, b) in [
3582 (r"C:\d\a.csv", r"c:\d\a.csv"),
3583 (r"C:\d\a.csv", "C:/d/a.csv"),
3584 (r"\\srv\share\a.csv", "//srv/share/a.csv"),
3585 ] {
3586 assert_eq!(place(a), place(b), "{a} and {b}");
3587 }
3588 for (a, b) in [
3589 (r"C:\d\a.csv", r"D:\d\a.csv"),
3590 (r"C:\a.csv", "C:a.csv"),
3591 (r"\\srv\share\a.csv", r"\\srv\other\a.csv"),
3592 ] {
3593 assert_ne!(place(a), place(b), "{a} and {b}");
3594 }
3595 }
3596 }
3597
3598 use super::*;
3599
3600 #[test]
3603 fn every_color_accessor_names_a_slot() {
3604 let theme = Theme::from_config(&AppConfig::default().theme).unwrap();
3605 for slot in COLOR_SLOTS {
3606 assert!(theme.colors.contains_key(*slot), "no {slot} slot");
3607 }
3608 }
3609
3610 #[test]
3611 fn a_key_the_registry_does_not_know_is_named_with_the_nearest() {
3612 let found = |text: &str| unknown_keys_in(&toml::from_str(text).unwrap());
3613 let unknown = found(
3614 "[file_loading]\ncomment_char = \"#\"\n[display]\nmouse = false\nrow_numbr = true\n\
3615 number_format = { grouping = \"thousands\" }\n[glyphs]\nspinner = [\"a\"]\n\
3616 [theme.colors]\naccent = \"red\"\n[[cloud.connections]]\nname = \"x\"\n\
3617 [[sources]]\nname = \"x\"\n[home]\ndirectories = [\"/d\"]\n",
3618 );
3619 assert_eq!(unknown.len(), 4, "{unknown:?}");
3620 assert!(
3622 unknown[2].starts_with("home.directories is not read any more")
3623 && unknown[2].contains("Ctrl+D"),
3624 "{unknown:?}"
3625 );
3626 assert!(
3627 unknown[3].starts_with("sources is not read any more")
3628 && unknown[3].contains("catalog.toml"),
3629 "{unknown:?}"
3630 );
3631 assert!(
3632 unknown[0].starts_with("display.row_numbr")
3633 && unknown[0].contains("display.row_numbers")
3634 );
3635 assert!(
3636 unknown[1].starts_with("file_loading.comment_char")
3637 && unknown[1].contains("csv.comment")
3638 );
3639 }
3640
3641 #[test]
3642 fn a_field_selection_stays_visible_when_the_tint_degrades() {
3643 use ratatui::style::{Modifier, Style};
3644 let theme_with = |tint: Option<Color>| Theme {
3645 colors: tint
3646 .map(|c| HashMap::from([("table_selected".to_string(), c)]))
3647 .unwrap_or_default(),
3648 };
3649 let reversed = Style::default().add_modifier(Modifier::REVERSED);
3650 for tint in [Color::Black, Color::White, Color::Reset] {
3652 assert_eq!(
3653 theme_with(Some(tint)).text_selection_style(),
3654 reversed,
3655 "{tint:?}"
3656 );
3657 }
3658 assert_eq!(theme_with(None).text_selection_style(), reversed);
3659 for tint in [
3660 Color::Rgb(0x28, 0x34, 0x57),
3661 Color::Indexed(237),
3662 Color::Blue,
3663 ] {
3664 assert_eq!(
3665 theme_with(Some(tint)).text_selection_style(),
3666 Style::default().bg(tint)
3667 );
3668 }
3669 }
3670
3671 fn leaf_settings() -> Vec<(String, toml::Value)> {
3674 fn walk(table: &toml::Table, prefix: &str, out: &mut Vec<(String, toml::Value)>) {
3675 for (key, value) in table {
3676 let path = if prefix.is_empty() {
3677 key.clone()
3678 } else {
3679 format!("{prefix}.{key}")
3680 };
3681 match value {
3682 toml::Value::Table(inner) => walk(inner, &path, out),
3683 toml::Value::Array(items) if items.iter().any(toml::Value::is_table) => {}
3684 _ => out.push((path, value.clone())),
3685 }
3686 }
3687 }
3688 let toml::Value::Table(defaults) = toml::Value::try_from(AppConfig::default()).unwrap()
3689 else {
3690 unreachable!("a struct serializes to a table")
3691 };
3692 let mut out = Vec::new();
3693 walk(&defaults, "", &mut out);
3694 for setting in datui_cli::settings::SETTINGS {
3695 if let datui_cli::settings::DefaultValue::Unset(example) = setting.default
3696 && !setting.key.ends_with(".*")
3697 && setting.kind != datui_cli::settings::Kind::Tables
3698 {
3699 let value: toml::Table = toml::from_str(&format!("v = {example}")).unwrap();
3700 out.push((setting.key.to_string(), value["v"].clone()));
3701 }
3702 }
3703 out
3704 }
3705
3706 fn layer_at(path: &str, value: toml::Value) -> Result<ConfigLayer> {
3708 let table = path.rsplit('.').fold(value, |inner, key| {
3709 toml::Value::Table(toml::Table::from_iter([(key.to_string(), inner)]))
3710 });
3711 ConfigLayer::parse(&toml::to_string(&table)?)
3712 }
3713
3714 fn value_at<'a>(table: &'a toml::Table, path: &str) -> Option<&'a toml::Value> {
3715 let (parents, key) = path.rsplit_once('.').unwrap_or(("", path));
3716 let mut table = table;
3717 for part in parents.split('.').filter(|p| !p.is_empty()) {
3718 table = table.get(part)?.as_table()?;
3719 }
3720 table.get(key)
3721 }
3722
3723 #[test]
3727 fn the_registry_and_the_config_structs_agree() {
3728 use datui_cli::settings::{DefaultValue, Kind, SETTINGS, find};
3729 let toml::Value::Table(defaults) = toml::Value::try_from(AppConfig::default()).unwrap()
3730 else {
3731 unreachable!("a struct serializes to a table")
3732 };
3733 let light = toml::Value::try_from(ColorConfig::light()).unwrap();
3734 for (path, value) in leaf_settings() {
3735 let setting = find(&path).unwrap_or_else(|| panic!("{path} is not registered"));
3736 let registered = match setting.default {
3737 DefaultValue::Value(v) | DefaultValue::Unset(v) => {
3738 toml::from_str::<toml::Table>(&format!("v = {v}")).unwrap()["v"].clone()
3739 }
3740 DefaultValue::Color { dark, light: lit } => {
3741 let name = setting.name();
3742 assert_eq!(
3743 light.get(name).and_then(|v| v.as_str()),
3744 Some(lit),
3745 "{path} (light)"
3746 );
3747 toml::Value::String(dark.to_string())
3748 }
3749 };
3750 assert_eq!(value, registered, "{path}: the registry's default differs");
3751 }
3752 for setting in SETTINGS {
3753 if setting.key.ends_with(".*") || setting.kind == Kind::Tables {
3754 continue;
3755 }
3756 let serialized = value_at(&defaults, setting.key).is_some();
3757 let unset = matches!(setting.default, DefaultValue::Unset(_));
3758 assert_eq!(
3759 serialized,
3760 !unset,
3761 "{}: registered as {}set by default",
3762 setting.key,
3763 if unset { "un" } else { "" }
3764 );
3765 let example = match setting.default {
3767 DefaultValue::Value(v) | DefaultValue::Unset(v) => v.to_string(),
3768 DefaultValue::Color { dark, .. } => format!("\"{dark}\""),
3769 };
3770 let value =
3771 toml::from_str::<toml::Table>(&format!("v = {example}")).unwrap()["v"].clone();
3772 layer_at(setting.key, value).unwrap_or_else(|e| panic!("{}: {e}", setting.key));
3773 }
3774 }
3775
3776 #[test]
3777 fn the_generated_config_shows_every_key_and_parses_uncommented() {
3778 let generated = ConfigManager::with_dir(PathBuf::new()).generate_default_config();
3779 let mut shown = std::collections::HashSet::new();
3780 let mut section = String::new();
3781 let mut uncommented = String::new();
3782 for line in generated.lines() {
3783 let Some(line) = line.strip_prefix("# ") else {
3784 uncommented.push_str(line);
3785 uncommented.push('\n');
3786 continue;
3787 };
3788 if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
3789 section = name.to_string();
3790 uncommented.push_str(line);
3791 uncommented.push('\n');
3792 } else if let Some((key, _)) = line.split_once(" = ")
3793 && !key.contains(' ')
3794 {
3795 shown.insert(match section.as_str() {
3796 "" => key.to_string(),
3797 s => format!("{s}.{key}"),
3798 });
3799 uncommented.push_str(line);
3800 uncommented.push('\n');
3801 }
3802 }
3803 for (path, _) in leaf_settings() {
3804 assert!(
3805 shown.contains(&path),
3806 "{path} is not in the generated config"
3807 );
3808 }
3809 ConfigLayer::parse(&uncommented).unwrap();
3811 }
3812
3813 #[test]
3814 fn every_setting_written_as_its_default_overrides_an_import() {
3815 let defaults = leaf_settings();
3819 let mut import = ConfigLayer::default();
3820 let mut moved = Vec::new();
3821 for (path, value) in &defaults {
3822 if path == "import" || COMBINED_KEYS.iter().any(|(p, _)| p == path) {
3824 continue;
3825 }
3826 let other = match value {
3827 toml::Value::Boolean(b) => toml::Value::Boolean(!b),
3828 toml::Value::Integer(n) => toml::Value::Integer(n + 1),
3829 toml::Value::Array(items) if items.is_empty() => vec!["x"].into(),
3830 toml::Value::Array(_) => toml::Value::Array(Vec::new()),
3831 toml::Value::String(text) if text == "auto" && path == "display.row_numbers" => {
3833 true.into()
3834 }
3835 toml::Value::String(text) => format!("{text}0").into(),
3836 other => panic!("{path}: no rule to change {other}"),
3837 };
3838 if let Ok(layer) = layer_at(path, other.clone()) {
3840 import.merge(layer);
3841 moved.push((path.clone(), other));
3842 }
3843 }
3844 assert!(moved.len() > 100, "only {} settings moved", moved.len());
3845
3846 let serialized = |config: AppConfig| match toml::Value::try_from(config).unwrap() {
3847 toml::Value::Table(table) => table,
3848 _ => unreachable!("a struct serializes to a table"),
3849 };
3850 let kept = serialized(AppConfig::from_layers([import.clone()]).unwrap());
3851 for (path, other) in &moved {
3852 assert_eq!(value_at(&kept, path), Some(other), "{path} was not read");
3853 }
3854
3855 let mut own = ConfigLayer::default();
3856 for (path, value) in &defaults {
3857 own.merge(layer_at(path, value.clone()).unwrap());
3858 }
3859 let restored = serialized(AppConfig::from_layers([import, own]).unwrap());
3860 for (path, _) in &moved {
3861 let default = defaults.iter().find(|(p, _)| p == path).map(|(_, v)| v);
3862 assert_eq!(value_at(&restored, path), default, "{path} kept the import");
3863 }
3864 }
3865}