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 sidebar_width: Option<u16>,
942 pub right_align_numbers: bool,
944 pub number_format: NumberFormatConfig,
947}
948
949#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
952pub enum RowNumbers {
953 #[default]
954 Auto,
955 On,
956 Off,
957}
958
959impl RowNumbers {}
960
961impl From<bool> for RowNumbers {
962 fn from(on: bool) -> Self {
963 if on { Self::On } else { Self::Off }
964 }
965}
966
967impl Serialize for RowNumbers {
968 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
969 match self {
970 Self::Auto => serializer.serialize_str("auto"),
971 Self::On => serializer.serialize_bool(true),
972 Self::Off => serializer.serialize_bool(false),
973 }
974 }
975}
976
977impl<'de> Deserialize<'de> for RowNumbers {
978 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
979 use serde::de::Error;
980 #[derive(Deserialize)]
981 #[serde(untagged)]
982 enum Raw {
983 Bool(bool),
984 Name(String),
985 }
986 const EXPECTED: &str = "row_numbers is \"auto\", true or false";
987 match Raw::deserialize(deserializer).map_err(|_| D::Error::custom(EXPECTED))? {
988 Raw::Bool(on) => Ok(on.into()),
989 Raw::Name(name) if name == "auto" => Ok(Self::Auto),
990 Raw::Name(other) => Err(D::Error::custom(format!("{EXPECTED}, not {other:?}"))),
991 }
992 }
993}
994
995#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
998#[serde(untagged)]
999pub enum CellPadding {
1000 Cells(usize),
1001 Density(Density),
1002}
1003
1004#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1006#[serde(rename_all = "lowercase")]
1007pub enum Density {
1008 Compact,
1010 Comfortable,
1012}
1013
1014impl Default for CellPadding {
1015 fn default() -> Self {
1016 Self::Density(Density::Comfortable)
1017 }
1018}
1019
1020impl CellPadding {
1021 pub fn cells(self) -> u16 {
1023 match self {
1024 Self::Cells(n) => u16::try_from(n).unwrap_or(u16::MAX),
1025 Self::Density(Density::Compact) => 1,
1026 Self::Density(Density::Comfortable) => 2,
1027 }
1028 }
1029}
1030
1031impl<'de> Deserialize<'de> for CellPadding {
1032 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1033 use serde::de::Error;
1034 #[derive(Deserialize)]
1035 #[serde(untagged)]
1036 enum Raw {
1037 Cells(usize),
1038 Name(String),
1039 }
1040 const EXPECTED: &str = "cell_padding is \"compact\", \"comfortable\" or a number of cells";
1041 match Raw::deserialize(deserializer).map_err(|_| D::Error::custom(EXPECTED))? {
1042 Raw::Cells(n) => Ok(Self::Cells(n)),
1043 Raw::Name(name) => match name.as_str() {
1044 "compact" => Ok(Self::Density(Density::Compact)),
1045 "comfortable" => Ok(Self::Density(Density::Comfortable)),
1046 other => Err(D::Error::custom(format!("{EXPECTED}, not {other:?}"))),
1047 },
1048 }
1049 }
1050}
1051
1052#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1055#[serde(untagged)]
1056pub enum NumberFormatConfig {
1057 Preset(String),
1059 Custom(Box<NumberFormatTable>),
1061}
1062
1063impl Default for NumberFormatConfig {
1064 fn default() -> Self {
1065 NumberFormatConfig::Preset("none".to_string())
1067 }
1068}
1069
1070#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1073#[serde(default)]
1074pub struct NumberFormatTable {
1075 pub grouping: Option<String>,
1077 pub group_separator: Option<String>,
1079 pub decimal_separator: Option<String>,
1081 pub floats: Option<bool>,
1083 pub float_precision: Option<u8>,
1085 pub exclude_columns: Vec<String>,
1087 #[serde(flatten)]
1090 pub unknown: std::collections::BTreeMap<String, toml::Value>,
1091}
1092
1093const NUMBER_FORMAT_KEYS: &str =
1095 "grouping, group_separator, decimal_separator, floats, float_precision, exclude_columns";
1096
1097impl NumberFormatConfig {
1098 pub fn resolve(&self, align_numeric_right: bool) -> Result<NumberFormatSettings> {
1101 let (format, exclude) = match self {
1102 NumberFormatConfig::Preset(name) => (Self::lookup_preset(name)?, Vec::new()),
1103 NumberFormatConfig::Custom(table) => {
1104 if !table.unknown.is_empty() {
1105 let keys: Vec<&str> = table.unknown.keys().map(String::as_str).collect();
1106 return Err(eyre!(
1107 "display.number_format: unknown key{} {}. Expected one of: {}",
1108 if keys.len() > 1 { "s" } else { "" },
1109 keys.iter()
1110 .map(|k| format!("'{}'", k))
1111 .collect::<Vec<_>>()
1112 .join(", "),
1113 NUMBER_FORMAT_KEYS
1114 ));
1115 }
1116 let base = match table.grouping.as_deref() {
1117 Some(name) => Self::lookup_preset(name)?,
1118 None => NumberFormat::PLAIN,
1119 };
1120 let mut fmt = base;
1121 if let Some(sep) = table.group_separator.as_deref() {
1122 fmt.group_sep = Self::single_char(sep, "group_separator")?;
1123 }
1124 if let Some(sep) = table.decimal_separator.as_deref() {
1125 fmt.decimal_sep = Self::single_char(sep, "decimal_separator")?;
1126 }
1127 if let Some(v) = table.floats {
1128 fmt.floats = v;
1129 }
1130 if table.float_precision.is_some() {
1131 fmt.float_precision = table.float_precision;
1132 }
1133 (fmt, table.exclude_columns.iter().map(Glob::new).collect())
1134 }
1135 };
1136
1137 if format.grouping != Grouping::None && format.group_sep == format.decimal_sep {
1138 return Err(eyre!(
1139 "display.number_format: group_separator and decimal_separator are both '{}'; \
1140 they must differ or numbers become ambiguous",
1141 format.group_sep
1142 ));
1143 }
1144
1145 let enabled = !format.is_noop();
1148 let format = if enabled {
1149 format
1150 } else {
1151 NumberFormat {
1152 grouping: Grouping::Thousands,
1153 ..format
1154 }
1155 };
1156
1157 Ok(NumberFormatSettings {
1158 format,
1159 enabled,
1160 exclude,
1161 align_numeric_right,
1162 })
1163 }
1164
1165 pub fn with_grouping_override(&self, name: &str) -> Self {
1168 match self {
1169 NumberFormatConfig::Preset(_) => NumberFormatConfig::Preset(name.to_string()),
1170 NumberFormatConfig::Custom(table) => {
1171 let mut table = table.clone();
1172 table.grouping = Some(name.to_string());
1173 NumberFormatConfig::Custom(table)
1174 }
1175 }
1176 }
1177
1178 fn lookup_preset(name: &str) -> Result<NumberFormat> {
1180 let name = if name == "system" {
1183 match numfmt::system_locale_tag() {
1184 Some(tag) => numfmt::preset_for_locale_tag(&tag),
1185 None => "thousands",
1187 }
1188 } else {
1189 name
1190 };
1191 NumberFormat::preset(name).ok_or_else(|| {
1192 eyre!(
1193 "display.number_format: unknown value '{}'. Expected one of: {}, system",
1194 name,
1195 NumberFormat::PRESET_NAMES.join(", ")
1196 )
1197 })
1198 }
1199
1200 fn single_char(s: &str, field: &str) -> Result<char> {
1201 let mut chars = s.chars();
1202 match (chars.next(), chars.next()) {
1203 (Some(c), None) => Ok(c),
1204 _ => Err(eyre!(
1205 "display.number_format.{}: expected a single character, got {:?}",
1206 field,
1207 s
1208 )),
1209 }
1210 }
1211}
1212
1213pub const DEFAULT_ANALYSIS_SAMPLE_ROWS: usize = 100_000;
1216
1217#[derive(Debug, Clone, Serialize, Deserialize)]
1218#[serde(default)]
1219pub struct PerformanceConfig {
1220 pub pages_ahead: usize,
1222 pub pages_behind: usize,
1223 pub max_buffered_rows: usize,
1225 pub max_buffered: ByteSize,
1228 pub streaming: bool,
1230 pub threads: usize,
1232}
1233
1234impl PerformanceConfig {
1235 pub fn max_buffered_mb(&self) -> usize {
1237 usize::try_from(self.max_buffered.bytes().div_ceil(1 << 20)).unwrap_or(usize::MAX)
1238 }
1239}
1240
1241pub const DEFAULT_QUALITY_LOCAL_COPY: ByteSize = ByteSize::mib(2048);
1243
1244pub const DEFAULT_CHART_ROW_LIMIT: usize = 10_000;
1246pub const MAX_CHART_ROW_LIMIT: usize = u32::MAX as usize;
1248
1249#[derive(Debug, Clone, Serialize, Deserialize)]
1251#[serde(default)]
1252pub struct AnalysisConfig {
1253 pub sample_rows: usize,
1256 pub chart_rows: usize,
1258 pub chart_grid: bool,
1260 pub quality_local_copy: ByteSize,
1263 pub sample_memory_limit: Option<ByteSize>,
1266}
1267
1268impl Default for AnalysisConfig {
1269 fn default() -> Self {
1270 Self {
1271 sample_rows: DEFAULT_ANALYSIS_SAMPLE_ROWS,
1272 chart_rows: DEFAULT_CHART_ROW_LIMIT,
1273 chart_grid: false,
1274 quality_local_copy: DEFAULT_QUALITY_LOCAL_COPY,
1275 sample_memory_limit: None,
1276 }
1277 }
1278}
1279
1280#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1284#[serde(rename_all = "lowercase")]
1285pub enum ThemeMode {
1286 #[default]
1288 Auto,
1289 Dark,
1290 Light,
1291}
1292
1293impl ThemeMode {
1294 pub fn resolve(self) -> Self {
1298 match self {
1299 Self::Auto => detect_terminal_mode(),
1300 other => other,
1301 }
1302 }
1303}
1304
1305fn detect_terminal_mode() -> ThemeMode {
1307 let Ok(raw) = std::env::var("COLORFGBG") else {
1308 return ThemeMode::Dark;
1309 };
1310 match raw
1312 .rsplit(';')
1313 .next()
1314 .and_then(|b| b.trim().parse::<u8>().ok())
1315 {
1316 Some(7) | Some(15) => ThemeMode::Light,
1317 _ => ThemeMode::Dark,
1318 }
1319}
1320
1321#[derive(Debug, Clone, Serialize, Deserialize)]
1324#[serde(default)]
1325pub struct HomeConfig {
1326 pub desktop_recents: bool,
1329 pub show_unreadable: bool,
1331 pub wordmark: bool,
1333 pub hide: Vec<String>,
1336 pub preview_max: ByteSize,
1340 pub search: SearchConfig,
1342}
1343
1344#[derive(Debug, Clone, Serialize, Deserialize)]
1348#[serde(default)]
1349pub struct SearchConfig {
1350 pub enabled: bool,
1352 pub max_depth: usize,
1354 pub max_results: usize,
1357 pub time_budget: Interval,
1360 pub cross_filesystems: bool,
1363 pub follow_gitignore: bool,
1366 pub skip: Vec<String>,
1368 pub skip_extra: Vec<String>,
1370 pub extensions: Vec<String>,
1373}
1374
1375pub const DEFAULT_SEARCH_SKIP: &[&str] = &[
1378 "node_modules",
1379 "target",
1380 "build",
1381 "dist",
1382 "vendor",
1383 "site-packages",
1384 "__pycache__",
1385 "venv",
1386 "env",
1387];
1388
1389impl Default for SearchConfig {
1390 fn default() -> Self {
1391 Self {
1392 enabled: true,
1393 max_depth: 8,
1394 max_results: 1_000,
1395 time_budget: Interval::ms(1_500),
1396 cross_filesystems: false,
1397 follow_gitignore: false,
1398 skip: DEFAULT_SEARCH_SKIP.iter().map(|s| s.to_string()).collect(),
1399 skip_extra: Vec::new(),
1400 extensions: Vec::new(),
1401 }
1402 }
1403}
1404
1405impl SearchConfig {
1406 pub fn skipped_dirs(&self) -> Vec<String> {
1408 let mut out = self.skip.clone();
1409 out.extend(self.skip_extra.iter().cloned());
1410 out
1411 }
1412}
1413
1414impl Default for HomeConfig {
1415 fn default() -> Self {
1416 Self {
1417 desktop_recents: true,
1419 show_unreadable: false,
1420 wordmark: true,
1421 hide: Vec::new(),
1422 preview_max: ByteSize::mib(64),
1423 search: SearchConfig::default(),
1424 }
1425 }
1426}
1427
1428#[derive(Debug, Clone, Serialize, Deserialize)]
1429#[serde(default)]
1430pub struct ThemeConfig {
1431 pub mode: Option<ThemeMode>,
1434 pub dark: String,
1437 pub light: String,
1439 pub colors: ColorConfig,
1440 #[serde(skip)]
1443 pub follow: bool,
1444 #[serde(skip)]
1447 pub overrides: toml::Table,
1448 #[serde(skip)]
1450 pub library: crate::config::themes::Library,
1451 #[serde(skip)]
1454 pub dark_theme: String,
1455 #[serde(skip)]
1456 pub light_theme: String,
1457 #[serde(skip)]
1459 pub dark_palette: ColorConfig,
1460 #[serde(skip)]
1461 pub light_palette: ColorConfig,
1462 #[serde(skip)]
1464 pub problems: Vec<String>,
1465 #[serde(skip)]
1467 pub fallbacks: Vec<String>,
1468}
1469
1470impl Default for ThemeConfig {
1471 fn default() -> Self {
1472 Self {
1473 mode: None,
1474 dark: crate::config::themes::NIGHT_MARKET.to_string(),
1475 light: crate::config::themes::DAY_MARKET.to_string(),
1476 colors: ColorConfig::default(),
1477 follow: false,
1478 overrides: toml::Table::new(),
1479 library: crate::config::themes::Library::default(),
1480 dark_theme: crate::config::themes::NIGHT_MARKET.to_string(),
1481 light_theme: crate::config::themes::DAY_MARKET.to_string(),
1482 dark_palette: ColorConfig::dark(),
1483 light_palette: ColorConfig::light(),
1484 problems: Vec::new(),
1485 fallbacks: Vec::new(),
1486 }
1487 }
1488}
1489
1490impl ThemeConfig {
1491 pub fn palette_for(&self, mode: ThemeMode) -> Result<ColorConfig> {
1493 let base = match mode.resolve() {
1494 ThemeMode::Light => &self.light_palette,
1495 _ => &self.dark_palette,
1496 };
1497 let mut palette = crate::config::themes::slots(base);
1498 palette.extend(self.overrides.clone());
1499 Ok(toml::Value::Table(palette).try_into()?)
1500 }
1501
1502 pub fn warnings(&self) -> Vec<String> {
1504 let broken = self
1505 .library
1506 .broken
1507 .iter()
1508 .map(|b| format!("warning: theme left out: {}", b.full()));
1509 let problems = self.problems.iter().map(|p| format!("warning: {p}"));
1510 broken.chain(problems).collect()
1511 }
1512
1513 pub fn use_library(&mut self, library: crate::config::themes::Library, active: ThemeMode) {
1516 self.problems.clear();
1517 self.fallbacks.clear();
1518 for mode in [ThemeMode::Dark, ThemeMode::Light] {
1519 let (key, name) = match mode {
1520 ThemeMode::Light => ("theme.light", self.light.clone()),
1521 _ => ("theme.dark", self.dark.clone()),
1522 };
1523 let (used, palette) = match library.resolve(&name, mode) {
1524 Ok(palette) => (name, palette),
1525 Err(why) => {
1526 let fallback = crate::config::themes::built_in_name(mode);
1527 if self.follow || active == mode {
1528 let short = format!("{key}: using {fallback}, not {name}");
1529 self.problems.push(format!("{short}: {why}"));
1530 self.fallbacks.push(short);
1531 }
1532 (fallback.to_string(), ColorConfig::for_mode(mode))
1533 }
1534 };
1535 match mode {
1536 ThemeMode::Light => (self.light_theme, self.light_palette) = (used, palette),
1537 _ => (self.dark_theme, self.dark_palette) = (used, palette),
1538 }
1539 }
1540 self.library = library;
1541 }
1542}
1543
1544#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1548#[serde(default)]
1549pub struct ColorConfig {
1550 pub chip_key: String,
1551 pub chip_label: String,
1552 pub throbber: String,
1553 pub success: String,
1554 pub error: String,
1555 pub warning: String,
1556 pub dimmed: String,
1557 pub background: String,
1558 pub surface: String,
1559 pub controls_bg: String,
1560 pub text_primary: String,
1561 pub text_secondary: String,
1562 pub text_inverse: String,
1563 pub table_header: String,
1564 pub table_header_bg: String,
1565 pub table_row_numbers: String,
1567 pub table_column_separator: String,
1568 pub table_selected: String,
1570 pub table_column_cursor: String,
1572 pub table_cell_cursor: String,
1574 pub table_alternate_row: String,
1576 pub sidebar_border: String,
1577 pub modal_border_active: String,
1578 pub modal_border_error: String,
1579 pub distribution_normal: String,
1580 pub distribution_skewed: String,
1581 pub distribution_other: String,
1582 pub outlier_marker: String,
1583 pub input_cursor: String,
1585 pub input_cursor_text: String,
1587 pub type_str: String,
1589 pub type_int: String,
1590 pub type_float: String,
1591 pub type_bool: String,
1592 pub type_temporal: String,
1593 pub type_binary: String,
1595 pub chart_1: String,
1597 pub chart_2: String,
1598 pub chart_3: String,
1599 pub chart_4: String,
1600 pub chart_5: String,
1601 pub chart_6: String,
1602 pub chart_7: String,
1603 pub chart_8: String,
1604 pub chart_9: String,
1605 pub chart_10: String,
1606 pub chart_grid: String,
1608 pub accent: String,
1611 pub accent_bright: String,
1613 pub gradient_start: String,
1615 pub gradient_end: String,
1616 pub find_match: String,
1618 pub hex_null: String,
1620 pub hex_printable: String,
1621 pub hex_whitespace: String,
1622 pub hex_control: String,
1623 pub hex_high: String,
1624 pub hex_ff: String,
1625}
1626
1627#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1629#[serde(default)]
1630pub struct HttpConfig {
1631 pub user_agent: String,
1633}
1634
1635#[derive(Debug, Clone, Serialize, Deserialize)]
1636#[serde(default)]
1637pub struct QueryConfig {
1638 pub history_limit: usize,
1639 pub history: bool,
1641 pub default_mode: QueryMode,
1642}
1643
1644#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1646#[serde(rename_all = "kebab-case")]
1647pub enum QueryMode {
1648 #[default]
1649 Sql,
1650 Q,
1652}
1653
1654impl QueryMode {
1655 pub fn available() -> &'static [QueryMode] {
1657 #[cfg(feature = "sql")]
1658 {
1659 &[QueryMode::Sql, QueryMode::Q]
1660 }
1661 #[cfg(not(feature = "sql"))]
1662 {
1663 &[QueryMode::Q]
1664 }
1665 }
1666
1667 pub fn resolve(self) -> QueryMode {
1669 if Self::available().contains(&self) {
1670 self
1671 } else {
1672 QueryMode::Q
1673 }
1674 }
1675
1676 pub fn prefix_colon(self) -> &'static str {
1678 match self {
1679 QueryMode::Sql => "sql:",
1680 QueryMode::Q => "q:",
1681 }
1682 }
1683
1684 pub fn next(self) -> QueryMode {
1686 let modes = Self::available();
1687 let at = modes.iter().position(|&m| m == self).unwrap_or(0);
1688 modes[(at + 1) % modes.len()]
1689 }
1690}
1691
1692#[derive(Debug, Clone, Serialize, Deserialize)]
1694#[serde(default)]
1695pub struct ChartConfig {
1696 pub export_recipe: bool,
1699}
1700
1701impl Default for ChartConfig {
1702 fn default() -> Self {
1703 Self {
1704 export_recipe: true,
1705 }
1706 }
1707}
1708
1709#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1711#[serde(default)]
1712pub struct ViewsConfig {
1713 pub auto_apply: bool,
1714}
1715
1716#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1718#[serde(default)]
1719pub struct LogConfig {
1720 pub file: Option<String>,
1722 pub level: Option<String>,
1724}
1725
1726#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1728#[serde(default)]
1729pub struct FormatsConfig {
1730 pub path: Vec<String>,
1733}
1734
1735#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1737#[serde(default)]
1738pub struct ClipboardConfig {
1739 pub backend: String,
1742 pub osc52_limit: ByteSize,
1744}
1745
1746impl Default for ClipboardConfig {
1747 fn default() -> Self {
1748 Self {
1749 backend: "auto".to_string(),
1750 osc52_limit: ByteSize::kib(100),
1751 }
1752 }
1753}
1754
1755#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1759#[serde(default)]
1760pub struct GlyphsConfig {
1761 #[serde(flatten)]
1762 pub overrides: std::collections::BTreeMap<String, crate::glyphs::SlotOverride>,
1763}
1764
1765#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1768#[serde(default)]
1769pub struct LimitsConfig {
1770 pub indexed_records: usize,
1772 pub elf_symbols: usize,
1773 pub midi_bytes: ByteSize,
1774 pub midi_events: usize,
1776 pub detail_rows: usize,
1778 pub journal_bytes: ByteSize,
1780 pub sdf_fields: usize,
1782 pub vcd_signals: usize,
1784 pub fix_tags: usize,
1786 pub fix_fields: usize,
1788 pub gpx_fields: usize,
1790 pub npy_header_bytes: ByteSize,
1792}
1793
1794impl LimitsConfig {
1795 pub const DEFAULT: Self = Self {
1796 indexed_records: 64 << 20,
1797 elf_symbols: 10_000_000,
1798 midi_bytes: ByteSize::mib(64),
1799 midi_events: 10_000_000,
1800 detail_rows: 10_000,
1801 journal_bytes: ByteSize::mib(1024),
1802 sdf_fields: 4096,
1803 vcd_signals: 1 << 20,
1804 fix_tags: 4096,
1805 fix_fields: 4096,
1806 gpx_fields: 256,
1807 npy_header_bytes: ByteSize::mib(4),
1808 };
1809}
1810
1811impl Default for LimitsConfig {
1812 fn default() -> Self {
1813 Self::DEFAULT
1814 }
1815}
1816
1817impl Default for AppConfig {
1818 fn default() -> Self {
1819 let mut config = Self {
1820 import: Vec::new(),
1821 catalogs: Vec::new(),
1822 read_catalogs: Vec::new(),
1823 catalog_dir: None,
1824 broken_catalogs: Vec::new(),
1825 read: ReadConfig::default(),
1826 csv: CsvConfig::default(),
1827 display: DisplayConfig::default(),
1828 performance: PerformanceConfig::default(),
1829 analysis: AnalysisConfig::default(),
1830 chart: ChartConfig::default(),
1831 home: HomeConfig::default(),
1832 cloud: CloudConfig::default(),
1833 http: HttpConfig::default(),
1834 query: QueryConfig::default(),
1835 views: ViewsConfig::default(),
1836 clipboard: ClipboardConfig::default(),
1837 formats: FormatsConfig::default(),
1838 limits: LimitsConfig::DEFAULT,
1839 log: LogConfig::default(),
1840 theme: ThemeConfig::default(),
1841 glyphs: GlyphsConfig::default(),
1842 };
1843 config.sync_dataset_access();
1844 config
1845 }
1846}
1847
1848impl Default for DisplayConfig {
1849 fn default() -> Self {
1850 Self {
1851 unicode: crate::glyphs::UnicodeMode::default(),
1852 row_numbers: RowNumbers::Auto,
1853 row_numbers_start: 1,
1854 cell_padding: CellPadding::default(),
1855 column_colors: true,
1856 type_row: true,
1857 notes_accent: true,
1858 mouse: true,
1859 sidebar_width: None,
1860 right_align_numbers: true,
1861 number_format: NumberFormatConfig::default(),
1862 }
1863 }
1864}
1865
1866impl Default for PerformanceConfig {
1867 fn default() -> Self {
1868 Self {
1869 pages_ahead: 3,
1870 pages_behind: 3,
1871 max_buffered_rows: crate::table::DEFAULT_MAX_BUFFERED_ROWS,
1872 max_buffered: ByteSize::mib(512),
1873 streaming: true,
1874 threads: 0,
1875 }
1876 }
1877}
1878
1879impl Default for ColorConfig {
1880 fn default() -> Self {
1882 Self::dark()
1883 }
1884}
1885
1886impl ColorConfig {
1887 pub fn for_mode(mode: ThemeMode) -> Self {
1889 match mode.resolve() {
1890 ThemeMode::Light => Self::light(),
1891 _ => Self::dark(),
1892 }
1893 }
1894
1895 pub fn dark() -> Self {
1897 Self {
1901 chip_key: "#7dcfff".to_string(),
1902 chip_label: "#a9b1d6".to_string(),
1903 throbber: "#7dcfff".to_string(),
1904 success: "#9ece6a".to_string(),
1905 error: "#f7768e".to_string(),
1906 warning: "#e0af68".to_string(),
1907 dimmed: "#565f89".to_string(),
1908 background: "default".to_string(),
1909 surface: "default".to_string(),
1910 controls_bg: "#262a3f".to_string(),
1911 text_primary: "default".to_string(),
1912 text_secondary: "#737aa2".to_string(),
1913 text_inverse: "#1a1b26".to_string(),
1914 table_header: "#c0caf5".to_string(),
1915 table_header_bg: "#2b3047".to_string(),
1916 table_row_numbers: "#565f89".to_string(),
1917 table_column_separator: "#3b4261".to_string(),
1918 table_selected: "#283457".to_string(),
1919 table_column_cursor: "#292e42".to_string(),
1922 table_cell_cursor: "#3b4261".to_string(),
1923 sidebar_border: "#565f89".to_string(),
1925 modal_border_active: "#7dcfff".to_string(),
1926 modal_border_error: "#f7768e".to_string(),
1927 distribution_normal: "#9ece6a".to_string(),
1928 distribution_skewed: "#e0af68".to_string(),
1929 distribution_other: "#c0caf5".to_string(),
1930 outlier_marker: "#f7768e".to_string(),
1931 input_cursor: "default".to_string(),
1932 input_cursor_text: "default".to_string(),
1933 table_alternate_row: "#1e2030".to_string(),
1934 type_str: "#9ece6a".to_string(),
1935 type_int: "#7aa2f7".to_string(),
1936 type_float: "#2ac3de".to_string(),
1937 type_bool: "#e0af68".to_string(),
1938 type_temporal: "#bb9af7".to_string(),
1939 type_binary: "#565f89".to_string(),
1940 chart_1: "#7dcfff".to_string(),
1941 chart_2: "#bb9af7".to_string(),
1942 chart_3: "#9ece6a".to_string(),
1943 chart_4: "#e0af68".to_string(),
1944 chart_5: "#7aa2f7".to_string(),
1945 chart_6: "#f7768e".to_string(),
1946 chart_7: "#ff9e64".to_string(),
1947 chart_8: "#1abc9c".to_string(),
1950 chart_9: "#ff5fd2".to_string(),
1951 chart_10: "#f4ef8a".to_string(),
1952 chart_grid: "#3d4785".to_string(),
1954 accent: "#7dcfff".to_string(),
1955 accent_bright: "#a4daff".to_string(),
1956 gradient_start: "#7aa2f7".to_string(),
1957 gradient_end: "#bb9af7".to_string(),
1958 find_match: "#e0af68".to_string(),
1959 hex_null: "#565f89".to_string(),
1960 hex_printable: "#7dcfff".to_string(),
1961 hex_whitespace: "#9ece6a".to_string(),
1962 hex_control: "#bb9af7".to_string(),
1963 hex_high: "#e0af68".to_string(),
1964 hex_ff: "#f7768e".to_string(),
1965 }
1966 }
1967
1968 pub fn light() -> Self {
1971 Self {
1974 chip_key: "#2e7de9".to_string(),
1975 chip_label: "#3760bf".to_string(),
1976 throbber: "#2e7de9".to_string(),
1977 success: "#587539".to_string(),
1978 error: "#f52a65".to_string(),
1979 warning: "#8c6c3e".to_string(),
1980 dimmed: "#848cb5".to_string(),
1981 background: "default".to_string(),
1982 surface: "default".to_string(),
1983 controls_bg: "#d0d5e3".to_string(),
1984 text_primary: "default".to_string(),
1985 text_secondary: "#6172b0".to_string(),
1986 text_inverse: "#e1e2e7".to_string(),
1987 table_header: "#3760bf".to_string(),
1988 table_header_bg: "#c4c8da".to_string(),
1989 table_row_numbers: "#848cb5".to_string(),
1990 table_column_separator: "#a8aecb".to_string(),
1991 table_selected: "#b6bfe2".to_string(),
1992 table_column_cursor: "#cbd3f2".to_string(),
1993 table_cell_cursor: "#a0aef0".to_string(),
1994 sidebar_border: "#6172b0".to_string(),
1995 modal_border_active: "#2e7de9".to_string(),
1996 modal_border_error: "#f52a65".to_string(),
1997 distribution_normal: "#587539".to_string(),
1998 distribution_skewed: "#8c6c3e".to_string(),
1999 distribution_other: "#3760bf".to_string(),
2000 outlier_marker: "#f52a65".to_string(),
2001 input_cursor: "default".to_string(),
2002 input_cursor_text: "default".to_string(),
2003 table_alternate_row: "#dcdfea".to_string(),
2004 type_str: "#587539".to_string(),
2005 type_int: "#2e7de9".to_string(),
2006 type_float: "#007197".to_string(),
2007 type_bool: "#8c6c3e".to_string(),
2008 type_temporal: "#9854f1".to_string(),
2009 type_binary: "#848cb5".to_string(),
2010 chart_1: "#2e7de9".to_string(),
2011 chart_2: "#9854f1".to_string(),
2012 chart_3: "#587539".to_string(),
2013 chart_4: "#8c6c3e".to_string(),
2014 chart_5: "#007197".to_string(),
2015 chart_6: "#f52a65".to_string(),
2016 chart_7: "#b15c00".to_string(),
2017 chart_8: "#118c74".to_string(),
2019 chart_9: "#d1188c".to_string(),
2020 chart_10: "#24357a".to_string(),
2021 chart_grid: "#70aabf".to_string(),
2023 accent: "#2e7de9".to_string(),
2024 accent_bright: "#1a6cd0".to_string(),
2025 gradient_start: "#2e7de9".to_string(),
2026 gradient_end: "#9854f1".to_string(),
2027 find_match: "#f0c35a".to_string(),
2028 hex_null: "#848cb5".to_string(),
2029 hex_printable: "#007197".to_string(),
2030 hex_whitespace: "#587539".to_string(),
2031 hex_control: "#9854f1".to_string(),
2032 hex_high: "#8c6c3e".to_string(),
2033 hex_ff: "#f52a65".to_string(),
2034 }
2035 }
2036}
2037
2038impl Default for QueryConfig {
2039 fn default() -> Self {
2040 Self {
2041 history_limit: 1000,
2042 history: true,
2043 default_mode: QueryMode::default(),
2044 }
2045 }
2046}
2047
2048const MAX_IMPORT_DEPTH: usize = 8;
2050
2051pub fn expand_config_path(raw: &str) -> PathBuf {
2054 expand_path(raw)
2055}
2056
2057pub fn expand_home(path: &Path) -> PathBuf {
2062 expand_home_unless(path, |p| p.symlink_metadata().is_ok())
2063}
2064
2065fn expand_home_unless(path: &Path, there: impl FnOnce(&Path) -> bool) -> PathBuf {
2066 path.to_str()
2067 .and_then(home_path)
2068 .filter(|_| !there(path))
2069 .unwrap_or_else(|| path.to_path_buf())
2070}
2071
2072fn home_path(text: &str) -> Option<PathBuf> {
2075 if text == "~" {
2076 return dirs::home_dir();
2077 }
2078 let rest = text
2079 .strip_prefix("~/")
2080 .or_else(|| text.strip_prefix("~\\").filter(|_| cfg!(windows)))?;
2082 dirs::home_dir().map(|home| home.join(rest))
2083}
2084
2085pub(crate) fn path_place(path: &Path) -> PathBuf {
2089 use std::path::{Component, Prefix};
2090 let mut place = PathBuf::new();
2091 for component in path.components() {
2092 match component {
2093 Component::CurDir => {}
2094 Component::Prefix(prefix) => match prefix.kind() {
2095 Prefix::Disk(drive) => {
2096 place.push(format!("{}:", char::from(drive.to_ascii_uppercase())));
2097 }
2098 Prefix::UNC(server, share) => {
2099 let mut unc = std::ffi::OsString::from(r"\\");
2100 unc.push(server);
2101 unc.push(r"\");
2102 unc.push(share);
2103 place.push(unc);
2104 }
2105 _ => place.push(prefix.as_os_str()),
2106 },
2107 other => place.push(other),
2108 }
2109 }
2110 place
2111}
2112
2113pub(crate) fn expand_path(raw: &str) -> PathBuf {
2114 let mut expanded = String::with_capacity(raw.len());
2115 let mut chars = raw.chars().peekable();
2116
2117 while let Some(c) = chars.next() {
2118 if c != '$' {
2119 expanded.push(c);
2120 continue;
2121 }
2122
2123 let braced = chars.peek() == Some(&'{');
2124 if braced {
2125 chars.next();
2126 }
2127
2128 let mut name = String::new();
2129 while let Some(&next) = chars.peek() {
2130 if braced && next == '}' {
2131 chars.next();
2132 break;
2133 }
2134 if !next.is_ascii_alphanumeric() && next != '_' {
2135 break;
2136 }
2137 name.push(next);
2138 chars.next();
2139 }
2140
2141 if name.is_empty() {
2142 expanded.push('$');
2144 } else if let Ok(value) = std::env::var(&name) {
2145 expanded.push_str(&value);
2146 }
2147 }
2148
2149 home_path(&expanded).unwrap_or_else(|| PathBuf::from(expanded))
2151}
2152
2153#[derive(Debug, Clone, Default, PartialEq)]
2157pub struct ConfigLayer {
2158 table: toml::Table,
2159 imports: Vec<String>,
2161}
2162
2163#[derive(Debug, Clone, PartialEq, Eq)]
2165pub enum LayerSource {
2166 File(PathBuf),
2168 Override,
2170}
2171
2172impl std::fmt::Display for LayerSource {
2173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2174 match self {
2175 Self::File(path) => write!(f, "{}", path.display()),
2176 Self::Override => f.write_str("-c"),
2177 }
2178 }
2179}
2180
2181#[derive(Debug, Clone, Copy)]
2183enum Combine {
2184 ByName,
2187 Union,
2189}
2190
2191const COMBINED_KEYS: &[(&str, Combine)] = &[
2194 ("formats.path", Combine::Union),
2195 ("catalogs", Combine::Union),
2196 ("cloud.connections", Combine::ByName),
2197 ("cloud.hide", Combine::Union),
2198 ("cloud.env_files", Combine::Union),
2199 ("home.hide", Combine::Union),
2200];
2201
2202pub fn way_out(imported: bool, what: &str) -> String {
2205 if imported {
2206 format!("Fix that {what}, or move the file aside: a missing import is skipped.")
2207 } else {
2208 format!(
2209 "Fix that {what}, or move the file aside to start from the defaults; \
2210 `datui config init` then writes a fresh one."
2211 )
2212 }
2213}
2214
2215impl ConfigLayer {
2216 pub fn parse(text: &str) -> Result<Self> {
2218 let typed: AppConfig = toml::from_str(text)?;
2219 let table: toml::Table = toml::from_str(text)?;
2220 Ok(Self::from_table(table, typed.import))
2221 }
2222
2223 pub fn from_overrides(overrides: &[datui_cli::settings::Override]) -> Result<Self> {
2226 let mut table = toml::Table::new();
2227 for o in overrides {
2228 let mut at = &mut table;
2229 let mut parts: Vec<&str> = o.key.split('.').collect();
2230 let last = parts.pop().unwrap_or_default();
2231 for part in parts {
2232 let entry = at
2233 .entry(part.to_string())
2234 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2235 if !entry.is_table() {
2236 *entry = toml::Value::Table(toml::Table::new());
2237 }
2238 at = entry.as_table_mut().expect("just made a table");
2239 }
2240 at.insert(last.to_string(), o.value.clone());
2241 }
2242 toml::Value::Table(table.clone())
2243 .try_into::<AppConfig>()
2244 .map_err(|e| eyre!("-c: {}", e.message().trim_end()))?;
2245 Ok(Self::from_table(table, Vec::new()))
2246 }
2247
2248 fn from_table(mut table: toml::Table, imports: Vec<String>) -> Self {
2249 table.remove("import");
2250 Self { table, imports }
2251 }
2252
2253 fn read(path: &Path, importer: Option<&Path>) -> Result<Option<Self>> {
2256 let named = match importer {
2258 Some(importer) => format!("{} (imported by {})", path.display(), importer.display()),
2259 None => path.display().to_string(),
2260 };
2261 let content = match std::fs::read_to_string(path) {
2262 Ok(content) => content,
2263 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2264 Err(e) => return Err(eyre!("Failed to read config file at {named}: {e}")),
2265 };
2266 let mut layer = Self::parse(&content).map_err(|e| {
2267 eyre!(
2268 "Failed to parse config file at {named}: {}\n{}",
2269 parse_reason(&e),
2270 way_out(importer.is_some(), "line")
2271 )
2272 })?;
2273 for unknown in unknown_keys_in(&layer.table) {
2275 eprintln!("datui: warning: {}: {unknown}", path.display());
2276 }
2277 layer.anchor_paths(path.parent().unwrap_or_else(|| Path::new(".")));
2278 Ok(Some(layer))
2279 }
2280
2281 fn anchor_paths(&mut self, dir: &Path) {
2284 if let Some(toml::Value::Array(entries)) = self
2285 .table
2286 .get_mut("formats")
2287 .and_then(|f| f.get_mut("path"))
2288 {
2289 for entry in entries {
2290 if let toml::Value::String(path) = entry
2291 && !path.trim().is_empty()
2292 && expand_path(path).is_relative()
2293 {
2294 *path = dir.join(expand_path(path)).to_string_lossy().into_owned();
2295 }
2296 }
2297 }
2298 if let Some(toml::Value::Array(files)) = self.table.get_mut("catalogs") {
2299 for entry in files {
2300 let path = match entry {
2302 toml::Value::Table(table) => table.get_mut("path"),
2303 other => Some(other),
2304 };
2305 if let Some(toml::Value::String(path)) = path
2306 && !path.trim().is_empty()
2307 && expand_path(path).is_relative()
2308 {
2309 *path = dir.join(expand_path(path)).to_string_lossy().into_owned();
2310 }
2311 }
2312 }
2313 }
2314
2315 pub fn get(&self, key: &str) -> Option<&toml::Value> {
2317 let mut parts = key.split('.');
2318 let mut value = self.table.get(parts.next()?)?;
2319 for part in parts {
2320 value = value.as_table()?.get(part)?;
2321 }
2322 Some(value)
2323 }
2324
2325 pub fn merge(&mut self, upper: ConfigLayer) {
2328 merge_tables(&mut self.table, upper.table, "");
2329 }
2330}
2331
2332const RETIRED_KEYS: &[(&str, &str)] = &[
2334 (
2335 "sources",
2336 "a collection is a catalog file now; put its datasets in catalog.toml as [id] \
2337 tables, or list the file in catalogs = [...] (datui catalog check FILE)",
2338 ),
2339 (
2340 "home.directories",
2341 "a directory is a catalog entry now; Ctrl+D on its row adds it to catalog.toml",
2342 ),
2343 (
2344 "home.builtin_catalog",
2345 "home.hide = [\"examples\"] hides the example datasets",
2346 ),
2347];
2348
2349fn unknown_keys_in(table: &toml::Table) -> Vec<String> {
2353 fn walk(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
2354 for (key, value) in table {
2355 let path = if prefix.is_empty() {
2356 key.clone()
2357 } else {
2358 format!("{prefix}.{key}")
2359 };
2360 if datui_cli::settings::find(&path).is_some() {
2361 continue;
2362 }
2363 if let Some((_, moved)) = RETIRED_KEYS.iter().find(|(key, _)| *key == path) {
2364 out.push(format!("{path} is not read any more: {moved}"));
2365 continue;
2366 }
2367 match value {
2368 toml::Value::Table(inner) => walk(inner, &path, out),
2371 _ => {
2372 let near = datui_cli::settings::suggestions(&path);
2373 let mut said = format!("{path} is not a config key, and is not read");
2374 if !near.is_empty() {
2375 said.push_str(&format!("; did you mean {}?", near.join(" or ")));
2376 }
2377 out.push(said);
2378 }
2379 }
2380 }
2381 }
2382 let mut out = Vec::new();
2383 walk(table, "", &mut out);
2384 out.sort();
2385 out
2386}
2387
2388fn parse_reason(error: &color_eyre::eyre::Report) -> String {
2391 let Some(toml_error) = error.downcast_ref::<toml::de::Error>() else {
2392 return error.to_string();
2393 };
2394 let message = toml_error.message().trim_end();
2395 let full = toml_error.to_string();
2396 let body = full.trim_end().strip_suffix(message).unwrap_or(&full);
2397 match body.split_once('\n') {
2398 Some((head, excerpt)) if head.starts_with("TOML parse error at ") => format!(
2399 "{message} ({})\n{}",
2400 head.trim_start_matches("TOML parse error at "),
2401 excerpt.trim_end()
2402 ),
2403 _ => message.to_string(),
2404 }
2405}
2406
2407fn merge_tables(lower: &mut toml::Table, upper: toml::Table, prefix: &str) {
2408 for (key, value) in upper {
2409 let path = if prefix.is_empty() {
2410 key.clone()
2411 } else {
2412 format!("{prefix}.{key}")
2413 };
2414 let combine = COMBINED_KEYS
2415 .iter()
2416 .find(|(p, _)| *p == path)
2417 .map(|(_, c)| *c);
2418 match (combine, value) {
2419 (Some(combine), toml::Value::Array(upper)) => {
2420 let mut combined = match lower.remove(&key) {
2421 Some(toml::Value::Array(lower)) => lower,
2422 _ => Vec::new(),
2423 };
2424 match combine {
2425 Combine::ByName => merge_by_name(&mut combined, upper),
2426 Combine::Union => {
2427 for item in upper {
2428 if !combined.contains(&item) {
2429 combined.push(item);
2430 }
2431 }
2432 }
2433 }
2434 lower.insert(key, toml::Value::Array(combined));
2435 }
2436 (None, toml::Value::Table(upper)) => match lower.get_mut(&key) {
2437 Some(toml::Value::Table(lower)) => merge_tables(lower, upper, &path),
2438 _ => {
2439 lower.insert(key, toml::Value::Table(upper));
2440 }
2441 },
2442 (_, value) => {
2443 lower.insert(key, value);
2444 }
2445 }
2446 }
2447}
2448
2449fn merge_by_name(lower: &mut Vec<toml::Value>, upper: Vec<toml::Value>) {
2450 let name_of = |entry: &toml::Value| {
2451 entry
2452 .get("name")
2453 .and_then(toml::Value::as_str)
2454 .map(str::to_owned)
2455 };
2456 let mut seen = std::collections::HashSet::new();
2457 for entry in upper {
2458 let name = name_of(&entry);
2459 let first = name.clone().is_some_and(|n| seen.insert(n));
2460 match lower
2461 .iter()
2462 .position(|e| name.is_some() && name_of(e) == name)
2463 {
2464 Some(i) if first => lower[i] = entry,
2465 _ => lower.push(entry),
2466 }
2467 }
2468}
2469
2470impl AppConfig {
2472 pub fn load_with(app_name: &str, overrides: &[datui_cli::settings::Override]) -> Result<Self> {
2475 match ConfigManager::new(app_name) {
2476 Ok(manager) => {
2477 Self::load_from_file_with(&manager.config_path("config.toml"), overrides)
2478 }
2479 Err(_) => {
2481 let layers = vec![ConfigLayer::from_overrides(overrides)?];
2482 let mut config =
2483 Self::from_layers(layers).map_err(|e| eyre!("Invalid configuration: {}", e))?;
2484 config.read_theme_files(None)?;
2485 config
2486 .validate()
2487 .map_err(|e| eyre!("Invalid configuration: {}", e))?;
2488 Ok(config)
2489 }
2490 }
2491 }
2492
2493 pub fn load_from_file(config_path: &Path) -> Result<Self> {
2499 Self::load_from_file_with(config_path, &[])
2500 }
2501
2502 pub fn load_from_file_with(
2504 config_path: &Path,
2505 overrides: &[datui_cli::settings::Override],
2506 ) -> Result<Self> {
2507 let layers = Self::read_layers(config_path, overrides)?;
2508 Self::from_read_layers(config_path, overrides, &layers)
2509 }
2510
2511 pub fn read_layers(
2515 config_path: &Path,
2516 overrides: &[datui_cli::settings::Override],
2517 ) -> Result<Vec<(LayerSource, ConfigLayer)>> {
2518 let mut layers = Vec::new();
2519 if let Some(root) = ConfigLayer::read(config_path, None)? {
2520 let canonical = crate::canonical::canonicalize(config_path)
2521 .unwrap_or_else(|_| config_path.to_path_buf());
2522 let mut stack = vec![canonical];
2523 Self::collect_imports(&root.imports, config_path, &mut stack, &mut layers)?;
2524 layers.push((LayerSource::File(config_path.to_path_buf()), root));
2525 }
2526 if !overrides.is_empty() {
2527 layers.push((
2528 LayerSource::Override,
2529 ConfigLayer::from_overrides(overrides)?,
2530 ));
2531 }
2532 Ok(layers)
2533 }
2534
2535 pub fn from_read_layers(
2538 config_path: &Path,
2539 overrides: &[datui_cli::settings::Override],
2540 layers: &[(LayerSource, ConfigLayer)],
2541 ) -> Result<Self> {
2542 let place = if overrides.is_empty() {
2544 config_path.display().to_string()
2545 } else {
2546 format!("{} with -c", config_path.display())
2547 };
2548 let imports = layers
2549 .iter()
2550 .find(|(source, _)| *source == LayerSource::File(config_path.to_path_buf()))
2551 .map(|(_, root)| root.imports.clone())
2552 .unwrap_or_default();
2553
2554 let mut config = Self::from_layers(layers.iter().map(|(_, layer)| layer.clone()))
2555 .map_err(|e| eyre!("Invalid configuration in {place}: {e}"))?;
2556 config.import = imports;
2558 config.read_theme_files(config_path.parent())?;
2559 config.read_catalog_files(config_path.parent())?;
2561 for broken in &config.broken_catalogs {
2562 eprintln!("datui: warning: catalog left out: {}", broken.full());
2563 log::warn!(target: "datui", "catalog left out: {}", broken.full());
2564 }
2565 for name in config.unknown_hidden() {
2567 eprintln!("datui: warning: home.hide: {}", Self::hides_nothing(&name));
2568 }
2569
2570 config.validate().map_err(|e| {
2571 eyre!(
2572 "Invalid configuration in {place}: {e}\n{}",
2573 way_out(false, "setting")
2574 )
2575 })?;
2576
2577 Ok(config)
2578 }
2579
2580 fn collect_imports(
2583 imports: &[String],
2584 origin: &Path,
2585 stack: &mut Vec<PathBuf>,
2586 out: &mut Vec<(LayerSource, ConfigLayer)>,
2587 ) -> Result<()> {
2588 if imports.is_empty() {
2589 return Ok(());
2590 }
2591
2592 if stack.len() >= MAX_IMPORT_DEPTH {
2593 return Err(eyre!(
2594 "config import chain is more than {} files deep (at {}); \
2595 flatten the chain or remove the extra levels",
2596 MAX_IMPORT_DEPTH,
2597 origin.display()
2598 ));
2599 }
2600
2601 let origin_dir = origin.parent().unwrap_or_else(|| Path::new("."));
2602
2603 for entry in imports {
2604 let expanded = expand_path(entry);
2605 let path = if expanded.is_absolute() {
2606 expanded
2607 } else {
2608 origin_dir.join(expanded)
2609 };
2610
2611 let canonical = crate::canonical::canonicalize(&path).unwrap_or_else(|_| path.clone());
2612 if stack.contains(&canonical) {
2613 return Err(eyre!(
2614 "circular config import: {} is already being loaded (imported by {})",
2615 canonical.display(),
2616 origin.display()
2617 ));
2618 }
2619
2620 let Some(layer) = ConfigLayer::read(&path, Some(origin))? else {
2621 eprintln!(
2622 "datui: warning: config import not found, skipping: {} (imported by {})",
2623 path.display(),
2624 origin.display()
2625 );
2626 continue;
2627 };
2628
2629 stack.push(canonical);
2630 Self::collect_imports(&layer.imports, &path, stack, out)?;
2631 stack.pop();
2632
2633 out.push((LayerSource::File(path), layer));
2634 }
2635
2636 Ok(())
2637 }
2638
2639 pub fn from_layers(layers: impl IntoIterator<Item = ConfigLayer>) -> Result<Self> {
2644 let mut merged = ConfigLayer::default();
2645 for layer in layers {
2646 merged.merge(layer);
2647 }
2648 let mut table = merged.table;
2649
2650 let theme = table.get_mut("theme").and_then(toml::Value::as_table_mut);
2651 let mode: ThemeMode = match theme.as_ref().and_then(|t| t.get("mode")) {
2652 Some(mode) => mode.clone().try_into()?,
2653 None => ThemeMode::default(),
2654 };
2655 let resolved = mode.resolve();
2656 let colors = theme.and_then(|t| t.remove("colors"));
2657
2658 let mut config: AppConfig = toml::Value::Table(table).try_into()?;
2659
2660 if let Some(toml::Value::Table(colors)) = colors {
2661 config.theme.overrides = colors;
2662 }
2663 config.theme.follow = mode == ThemeMode::Auto;
2664 config.theme.mode = Some(resolved);
2665 config
2667 .theme
2668 .use_library(crate::config::themes::Library::default(), resolved);
2669 config.theme.colors = config.theme.palette_for(resolved)?;
2670 config.sync_dataset_access();
2671 Ok(config)
2672 }
2673
2674 pub fn read_theme_files(&mut self, config_dir: Option<&Path>) -> Result<()> {
2678 let library = crate::config::themes::Library::read(config_dir);
2679 let active = self.theme.mode.unwrap_or_default().resolve();
2680 self.theme.use_library(library, active);
2681 for warning in self.theme.warnings() {
2682 eprintln!("datui: {warning}");
2683 log::warn!(target: "datui", "{warning}");
2684 }
2685 self.theme.colors = self.theme.palette_for(active)?;
2686 Ok(())
2687 }
2688
2689 pub fn catalogs(&self) -> Vec<crate::home::catalog::Catalog> {
2692 let mut all = self.read_catalogs.clone();
2693 if !all.iter().any(|c| c.id == crate::home::catalog::EXAMPLES) {
2694 all.push(crate::home::catalog::bundled());
2695 }
2696 all
2697 }
2698
2699 pub fn shown_catalogs(&self) -> Vec<crate::home::catalog::Catalog> {
2702 self.catalogs()
2703 .into_iter()
2704 .filter(|c| !self.home.hide.contains(&c.id))
2705 .map(|mut c| {
2706 c.datasets.retain(|d| {
2707 !self
2708 .home
2709 .hide
2710 .iter()
2711 .any(|h| h.split_once('/') == Some((c.id.as_str(), d.id.as_str())))
2712 });
2713 c
2714 })
2715 .collect()
2716 }
2717
2718 pub fn hides_nothing(name: &str) -> String {
2721 let old = crate::home::catalog::OLD_EXAMPLES_ID;
2722 let renamed = match name.split_once('/') {
2723 None if name == old => Some(crate::home::catalog::EXAMPLES.to_string()),
2724 Some((catalog, id)) if catalog == old => {
2725 Some(format!("{}/{id}", crate::home::catalog::EXAMPLES))
2726 }
2727 _ => None,
2728 };
2729 match renamed {
2730 Some(new) => format!("`{name}` is now `{new}`: hide = [\"{new}\"]"),
2731 None => format!("no catalog or entry is named {name}"),
2732 }
2733 }
2734
2735 pub fn unknown_hidden(&self) -> Vec<String> {
2737 let catalogs = self.catalogs();
2738 let mut out: Vec<String> = Vec::new();
2739 for name in &self.home.hide {
2740 let known = match name.split_once('/') {
2741 None => {
2742 catalogs.iter().any(|c| c.id == *name)
2743 || self.broken_catalogs.iter().any(|b| b.id == *name)
2744 }
2745 Some((catalog, _)) if self.broken_catalogs.iter().any(|b| b.id == catalog) => true,
2746 Some((catalog, id)) => catalogs
2747 .iter()
2748 .any(|c| c.id == catalog && c.datasets.iter().any(|d| d.id == id)),
2749 };
2750 if !known && !out.contains(name) {
2751 out.push(name.clone());
2752 }
2753 }
2754 out
2755 }
2756
2757 pub fn read_catalog_files(&mut self, config_dir: Option<&Path>) -> Result<()> {
2761 use crate::home::catalog::{self, Origin};
2762 let mut read: Vec<catalog::Catalog> = Vec::new();
2763 let mut broken: Vec<catalog::Broken> = Vec::new();
2764 let connections = self.cloud.connections.clone();
2765 let take = |found: std::result::Result<Option<catalog::Catalog>, catalog::Broken>,
2767 read: &mut Vec<catalog::Catalog>,
2768 broken: &mut Vec<catalog::Broken>|
2769 -> bool {
2770 match found {
2771 Ok(Some(c)) => match c.check_connections(&connections) {
2772 Ok(()) => {
2773 read.push(c);
2774 true
2775 }
2776 Err(e) => {
2777 broken.push(catalog::Broken {
2778 id: c.id.clone(),
2779 origin: c.origin,
2780 file: c.file.clone().unwrap_or_default(),
2781 line: e.line,
2782 message: e.message,
2783 });
2784 true
2785 }
2786 },
2787 Ok(None) => false,
2788 Err(b) => {
2789 broken.push(b);
2790 true
2791 }
2792 }
2793 };
2794 let mut files: Vec<(PathBuf, Origin, Option<String>, Option<String>)> = Vec::new();
2796 if let Some(dir) = config_dir {
2797 take(
2798 catalog::load(&dir.join(catalog::MINE_FILE), catalog::MINE, Origin::Mine),
2799 &mut read,
2800 &mut broken,
2801 );
2802 let folder = dir.join(catalog::FOLDER);
2803 let mut found: Vec<PathBuf> = match std::fs::read_dir(&folder) {
2804 Ok(entries) => entries
2805 .filter_map(|e| e.ok().map(|e| e.path()))
2806 .filter(|p| {
2807 p.extension().is_some_and(|x| x == "toml")
2808 && std::fs::metadata(p).is_ok_and(|m| m.is_file())
2809 })
2810 .collect(),
2811 Err(_) => Vec::new(),
2812 };
2813 found.sort();
2814 files.extend(found.into_iter().map(|p| (p, Origin::Folder, None, None)));
2815 }
2816 files.extend(self.catalogs.iter().map(|entry| {
2817 (
2818 expand_path(entry.path()),
2819 Origin::Listed,
2820 entry.id().map(str::to_string),
2821 entry.label().map(str::to_string),
2822 )
2823 }));
2824 for (path, origin, given_id, label) in files {
2825 let id = given_id
2826 .clone()
2827 .unwrap_or_else(|| catalog::id_of_file(&path));
2828 let refuse = |message: String| catalog::Broken {
2829 id: id.clone(),
2830 origin,
2831 file: path.clone(),
2832 line: None,
2833 message,
2834 };
2835 if !is_valid_source_id(&id) || id == catalog::MINE {
2836 broken.push(refuse(format!(
2837 "\"{id}\" cannot be a catalog's id: lowercase letters, digits and '-', \
2838 and not \"{}\", which is catalog.toml's. {}",
2839 catalog::MINE,
2840 if given_id.is_some() {
2841 "Give another id = \"...\""
2842 } else {
2843 "Rename the file, or list it as { path = \"...\", id = \"...\" }"
2844 }
2845 )));
2846 continue;
2847 }
2848 if let Some(first) = read.iter().find(|c| c.id == id) {
2849 let first = first.file_name();
2850 broken.push(refuse(format!(
2851 "{first} and this file are both the catalog \"{id}\". Rename one, or \
2852 list one as {{ path = \"...\", id = \"...\" }}"
2853 )));
2854 continue;
2855 }
2856 let found = catalog::load(&path, &id, origin).map(|found| {
2857 found.map(|mut listed| {
2858 if let Some(label) = &label {
2859 listed.label = label.clone();
2860 }
2861 listed
2862 })
2863 });
2864 if !take(found, &mut read, &mut broken) {
2865 eprintln!(
2866 "datui: warning: catalog not found, skipping: {}",
2867 path.display()
2868 );
2869 }
2870 }
2871 self.read_catalogs = read;
2872 self.broken_catalogs = broken;
2873 self.catalog_dir = config_dir.map(Path::to_path_buf);
2874 self.sync_dataset_access();
2875 Ok(())
2876 }
2877
2878 pub fn sync_dataset_access(&mut self) {
2881 self.cloud.dataset_access = self
2882 .catalogs()
2883 .iter()
2884 .flat_map(|catalog| {
2885 catalog.datasets.iter().filter_map(|dataset| {
2886 Some(DatasetAccess {
2887 url: dataset.url.clone()?,
2888 catalog: catalog.id.clone(),
2889 auth: dataset.object_store_auth()?,
2890 })
2891 })
2892 })
2893 .collect();
2894 }
2895
2896 pub fn validate(&self) -> Result<()> {
2898 let rows = self.analysis.chart_rows;
2899 if rows == 0 || rows > MAX_CHART_ROW_LIMIT {
2900 return Err(eyre!(
2901 "analysis.chart_rows must be between 1 and {MAX_CHART_ROW_LIMIT}, got {rows}"
2902 ));
2903 }
2904
2905 self.display
2907 .number_format
2908 .resolve(self.display.right_align_numbers)?;
2909
2910 let interval = self.read.follow_interval;
2911 if !FOLLOW_INTERVAL.contains(&interval.duration()) {
2912 return Err(eyre!(
2913 "read.follow_interval must be between 10ms and 1m, got {interval}"
2914 ));
2915 }
2916
2917 if let Some(c) = &self.csv.comment {
2918 crate::formats::csv_dialect::check_comment_char(c)
2919 .map_err(|e| eyre!("csv.comment: {e}"))?;
2920 }
2921
2922 if let Some(level) = &self.log.level
2923 && !datui_cli::LOG_LEVELS.contains(&level.as_str())
2924 {
2925 return Err(eyre!(
2926 "log.level must be one of {}, got {level:?}",
2927 datui_cli::LOG_LEVELS.join(", ")
2928 ));
2929 }
2930
2931 self.cloud.validate()?;
2932 for catalog in &self.read_catalogs {
2933 catalog
2934 .check_connections(&self.cloud.connections)
2935 .map_err(|e| eyre!("{}", e.in_file(&catalog.file_name())))?;
2936 }
2937 let hide_name = |name: &str| match name.split_once('/') {
2938 Some((catalog, id)) => is_valid_source_id(catalog) && is_valid_source_id(id),
2939 None => is_valid_source_id(name),
2940 };
2941 if let Some(name) = self.home.hide.iter().find(|name| !hide_name(name)) {
2942 return Err(eyre!(
2943 "home.hide: \"{name}\" is not a catalog id or catalog/id. Use the ids (mine, \
2944 examples, a listed file's name; examples/nyc-taxis for one entry), not the labels"
2945 ));
2946 }
2947
2948 let parser = ColorParser::new();
2949 self.theme.colors.validate(&parser)?;
2950
2951 crate::glyphs::validate_overrides(&self.glyphs.overrides)
2952 .map_err(|e| eyre!("[glyphs]: {e}"))?;
2953
2954 if crate::clipboard::BackendChoice::parse(&self.clipboard.backend).is_none() {
2955 return Err(eyre!(
2956 "[clipboard] backend must be auto, native or osc52, got {:?}",
2957 self.clipboard.backend
2958 ));
2959 }
2960 if self.clipboard.osc52_limit.bytes() == 0 {
2961 return Err(eyre!("[clipboard] osc52_limit must be greater than 0"));
2962 }
2963 if !crate::cloud::user_agent::is_valid(&self.http.user_agent) {
2964 return Err(eyre!(
2965 "[http] user_agent must be printable ASCII, got {:?}",
2966 self.http.user_agent
2967 ));
2968 }
2969
2970 Ok(())
2971 }
2972}
2973
2974impl ColorConfig {
2975 pub(crate) fn slots(&self) -> Vec<(String, String)> {
2977 match toml::Value::try_from(self) {
2978 Ok(toml::Value::Table(table)) => table
2979 .into_iter()
2980 .map(|(name, value)| (name, value.as_str().unwrap_or_default().to_string()))
2981 .collect(),
2982 _ => Vec::new(),
2983 }
2984 }
2985
2986 fn validate(&self, parser: &ColorParser) -> Result<()> {
2988 for (name, value) in self.slots() {
2989 if name == "table_alternate_row" && value == "default" {
2991 continue;
2992 }
2993 parser.parse(&value).map_err(|e| {
2994 eyre!(
2995 "theme.colors.{name}: {e}. Use a valid color name (e.g. red, cyan, \
2996 bright_red), hex (#rrggbb), or indexed(0-255)"
2997 )
2998 })?;
2999 }
3000 Ok(())
3001 }
3002}
3003
3004pub struct ColorParser {
3006 supports_true_color: bool,
3007 supports_256: bool,
3008 no_color: bool,
3009}
3010
3011impl ColorParser {
3012 pub fn new() -> Self {
3014 let no_color = std::env::var("NO_COLOR").is_ok();
3015 let support = supports_color::on(Stream::Stdout);
3016 #[cfg(windows)]
3017 let console = windows_console_true_color(
3018 std::env::var_os("TERM").is_some() || std::env::var_os("FORCE_COLOR").is_some(),
3020 std::io::IsTerminal::is_terminal(&std::io::stdout()),
3021 crossterm::ansi_support::supports_ansi,
3022 );
3023 #[cfg(not(windows))]
3024 let console = false;
3025
3026 Self {
3027 supports_true_color: console || support.as_ref().is_some_and(|s| s.has_16m),
3028 supports_256: console || support.as_ref().is_some_and(|s| s.has_256),
3029 no_color,
3030 }
3031 }
3032
3033 pub fn parse(&self, s: &str) -> Result<Color> {
3035 if self.no_color {
3036 return Ok(Color::Reset);
3037 }
3038
3039 let trimmed = s.trim();
3040
3041 if trimmed.starts_with('#') && trimmed.len() == 7 {
3043 let (r, g, b) = parse_hex(trimmed)?;
3044 return Ok(self.convert_rgb_to_terminal_color(r, g, b));
3045 }
3046
3047 if trimmed.to_lowercase().starts_with("indexed(") && trimmed.ends_with(')') {
3049 let num_str = &trimmed[8..trimmed.len() - 1]; let num = num_str.parse::<u8>().map_err(|_| {
3051 eyre!(
3052 "Invalid indexed color: '{}'. Expected format: indexed(0-255)",
3053 trimmed
3054 )
3055 })?;
3056 return Ok(Color::Indexed(num));
3057 }
3058
3059 let lower = trimmed.to_lowercase();
3061 match lower.as_str() {
3062 "black" => Ok(Color::Black),
3064 "red" => Ok(Color::Red),
3065 "green" => Ok(Color::Green),
3066 "yellow" => Ok(Color::Yellow),
3067 "blue" => Ok(Color::Blue),
3068 "magenta" => Ok(Color::Magenta),
3069 "cyan" => Ok(Color::Cyan),
3070 "white" => Ok(Color::White),
3071
3072 "bright_black" | "bright black" => Ok(Color::Indexed(8)),
3074 "bright_red" | "bright red" => Ok(Color::Indexed(9)),
3075 "bright_green" | "bright green" => Ok(Color::Indexed(10)),
3076 "bright_yellow" | "bright yellow" => Ok(Color::Indexed(11)),
3077 "bright_blue" | "bright blue" => Ok(Color::Indexed(12)),
3078 "bright_magenta" | "bright magenta" => Ok(Color::Indexed(13)),
3079 "bright_cyan" | "bright cyan" => Ok(Color::Indexed(14)),
3080 "bright_white" | "bright white" => Ok(Color::Indexed(15)),
3081
3082 "gray" | "grey" => Ok(Color::Indexed(8)),
3084 "dark_gray" | "dark gray" | "dark_grey" | "dark grey" => Ok(Color::Indexed(8)),
3085 "light_gray" | "light gray" | "light_grey" | "light grey" => Ok(Color::Indexed(7)),
3086
3087 "reset" | "default" | "none" | "reversed" => Ok(Color::Reset),
3089
3090 _ => Err(eyre!(
3091 "Unknown color name: '{}'. Supported: basic ANSI colors (red, blue, etc.), \
3092 bright variants (bright_red, etc.), or hex colors (#ff0000)",
3093 trimmed
3094 )),
3095 }
3096 }
3097
3098 fn convert_rgb_to_terminal_color(&self, r: u8, g: u8, b: u8) -> Color {
3100 if self.supports_true_color {
3101 Color::Rgb(r, g, b)
3102 } else if self.supports_256 {
3103 Color::Indexed(rgb_to_256_color(r, g, b))
3104 } else {
3105 rgb_to_basic_ansi(r, g, b)
3106 }
3107 }
3108}
3109
3110#[cfg(windows)]
3115fn windows_console_true_color(env_says: bool, terminal: bool, vt: impl FnOnce() -> bool) -> bool {
3116 !env_says && terminal && vt()
3117}
3118
3119impl Default for ColorParser {
3120 fn default() -> Self {
3121 Self::new()
3122 }
3123}
3124
3125fn parse_hex(s: &str) -> Result<(u8, u8, u8)> {
3127 let hex = s
3130 .strip_prefix('#')
3131 .filter(|hex| hex.len() == 6 && hex.is_ascii())
3132 .ok_or_else(|| {
3133 eyre!(
3134 "Invalid hex color format: '{}'. Expected format: #rrggbb",
3135 s
3136 )
3137 })?;
3138
3139 let r = u8::from_str_radix(&hex[0..2], 16)
3140 .map_err(|_| eyre!("Invalid red component in hex color: {}", s))?;
3141 let g = u8::from_str_radix(&hex[2..4], 16)
3142 .map_err(|_| eyre!("Invalid green component in hex color: {}", s))?;
3143 let b = u8::from_str_radix(&hex[4..6], 16)
3144 .map_err(|_| eyre!("Invalid blue component in hex color: {}", s))?;
3145
3146 Ok((r, g, b))
3147}
3148
3149pub fn rgb_to_256_color(r: u8, g: u8, b: u8) -> u8 {
3151 let dist = |cr: i32, cg: i32, cb: i32| -> i32 {
3154 let (dr, dg, db) = (cr - r as i32, cg - g as i32, cb - b as i32);
3155 dr * dr + dg * dg + db * db
3156 };
3157 const LEVELS: [i32; 6] = [0, 95, 135, 175, 215, 255];
3158 let mut best = (i32::MAX, 16u8);
3159 for (ri, &cr) in LEVELS.iter().enumerate() {
3160 for (gi, &cg) in LEVELS.iter().enumerate() {
3161 for (bi, &cb) in LEVELS.iter().enumerate() {
3162 let d = dist(cr, cg, cb);
3163 if d < best.0 {
3164 best = (d, 16 + 36 * ri as u8 + 6 * gi as u8 + bi as u8);
3165 }
3166 }
3167 }
3168 }
3169 for i in 0..24u8 {
3170 let v = 8 + 10 * i as i32;
3171 let d = dist(v, v, v);
3172 if d < best.0 {
3173 best = (d, 232 + i);
3174 }
3175 }
3176 best.1
3177}
3178
3179pub fn rgb_to_basic_ansi(r: u8, g: u8, b: u8) -> Color {
3181 let r_bright = r > 128;
3183 let g_bright = g > 128;
3184 let b_bright = b > 128;
3185
3186 let max_diff = r.max(g).max(b) as i16 - r.min(g).min(b) as i16;
3188 if max_diff < 30 {
3189 let avg = (r as u16 + g as u16 + b as u16) / 3;
3190 return if avg < 64 { Color::Black } else { Color::White };
3191 }
3192
3193 match (r_bright, g_bright, b_bright) {
3194 (false, false, false) => Color::Black,
3195 (true, false, false) => Color::Red,
3196 (false, true, false) => Color::Green,
3197 (true, true, false) => Color::Yellow,
3198 (false, false, true) => Color::Blue,
3199 (true, false, true) => Color::Magenta,
3200 (false, true, true) => Color::Cyan,
3201 (true, true, true) => Color::White,
3202 }
3203}
3204
3205#[derive(Debug, Clone)]
3207pub struct Theme {
3208 pub colors: HashMap<String, Color>,
3209}
3210
3211pub const CHART_SERIES_SLOTS: usize = 10;
3213
3214macro_rules! color_slots {
3217 ($($slot:ident),* $(,)?) => {
3218 impl Theme {
3219 $(
3220 pub fn $slot(&self) -> Color {
3221 self.get(stringify!($slot))
3222 }
3223 )*
3224 }
3225
3226 #[cfg(test)]
3228 pub(crate) const COLOR_SLOTS: &[&str] = &[$(stringify!($slot)),*];
3229 };
3230}
3231
3232color_slots!(
3233 accent,
3234 accent_bright,
3235 background,
3236 chart_1,
3237 chart_grid,
3238 chip_key,
3239 chip_label,
3240 controls_bg,
3241 dimmed,
3242 distribution_normal,
3243 distribution_skewed,
3244 error,
3245 find_match,
3246 gradient_end,
3247 gradient_start,
3248 hex_control,
3249 hex_ff,
3250 hex_high,
3251 hex_null,
3252 hex_printable,
3253 hex_whitespace,
3254 input_cursor,
3255 label,
3256 modal_border,
3257 modal_border_active,
3258 modal_border_error,
3259 outlier_marker,
3260 sidebar_border,
3261 success,
3262 surface,
3263 table_column_separator,
3264 table_header,
3265 table_header_bg,
3266 table_row_numbers,
3267 text_inverse,
3268 text_primary,
3269 text_secondary,
3270 throbber,
3271 type_binary,
3272 type_bool,
3273 type_float,
3274 type_int,
3275 type_str,
3276 type_temporal,
3277 warning,
3278);
3279
3280impl Theme {
3281 pub fn from_config(config: &ThemeConfig) -> Result<Self> {
3283 let parser = ColorParser::new();
3284 let mut colors = HashMap::new();
3285 for (name, value) in config.colors.slots() {
3286 let absent = match name.as_str() {
3289 "table_selected" => value.trim().eq_ignore_ascii_case("reversed"),
3290 "table_alternate_row" => value == "default",
3291 _ => false,
3292 };
3293 if !absent {
3294 colors.insert(name, parser.parse(&value)?);
3295 }
3296 }
3297 colors.insert("modal_border".to_string(), colors["sidebar_border"]);
3300 colors.insert("label".to_string(), colors["text_secondary"]);
3301 Ok(Self { colors })
3302 }
3303
3304 fn get(&self, name: &str) -> Color {
3307 self.colors.get(name).copied().unwrap_or(Color::Reset)
3308 }
3309
3310 pub fn get_optional(&self, name: &str) -> Option<Color> {
3312 self.colors.get(name).copied()
3313 }
3314
3315 pub fn series_colors(&self) -> Vec<Color> {
3318 let mut colors: Vec<Color> = Vec::with_capacity(CHART_SERIES_SLOTS);
3319 for i in 1..=CHART_SERIES_SLOTS {
3320 let color = self.get(&format!("chart_{i}"));
3321 if !colors.contains(&color) {
3322 colors.push(color);
3323 }
3324 }
3325 colors
3326 }
3327
3328 pub fn highlight_style(&self) -> ratatui::style::Style {
3331 match self.get_optional("table_selected") {
3332 Some(bg) => ratatui::style::Style::default().bg(bg),
3333 None => {
3334 ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
3335 }
3336 }
3337 }
3338
3339 pub fn column_cursor_style(&self) -> ratatui::style::Style {
3341 column_cursor_style(self.get_optional("table_column_cursor"))
3342 }
3343
3344 pub fn cell_cursor_style(&self) -> ratatui::style::Style {
3347 cell_cursor_style(self.get_optional("table_cell_cursor"))
3348 }
3349
3350 pub fn text_selection_style(&self) -> ratatui::style::Style {
3353 match self.get_optional("table_selected") {
3354 Some(Color::Reset | Color::Black | Color::White) => {
3355 ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
3356 }
3357 _ => self.highlight_style(),
3358 }
3359 }
3360
3361 pub fn find_match_style(&self) -> ratatui::style::Style {
3364 use ratatui::style::{Modifier, Style};
3365 match self.get("find_match") {
3366 Color::Reset => Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
3367 bg => Style::default().bg(bg).fg(contrasting_text(bg)),
3368 }
3369 }
3370
3371 pub fn cursor_text_for(&self, cursor: Color) -> Color {
3374 match self.get("input_cursor_text") {
3375 Color::Reset => contrasting_text(cursor),
3376 configured => configured,
3377 }
3378 }
3379}
3380
3381pub fn tint_shows(tint: Option<Color>) -> Option<Color> {
3384 tint.filter(|c| !matches!(c, Color::Reset | Color::Black | Color::White))
3385}
3386
3387pub fn column_cursor_style(tint: Option<Color>) -> ratatui::style::Style {
3390 match tint_shows(tint) {
3391 Some(bg) => ratatui::style::Style::default().bg(bg),
3392 None => ratatui::style::Style::default(),
3393 }
3394}
3395
3396pub fn cell_cursor_style(tint: Option<Color>) -> ratatui::style::Style {
3400 use ratatui::style::{Modifier, Style};
3401 match tint_shows(tint) {
3402 Some(bg) => Style::default().bg(bg).add_modifier(Modifier::BOLD),
3403 None => Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
3404 }
3405}
3406
3407fn contrasting_text(bg: Color) -> Color {
3409 let (r, g, b) = approx_rgb(bg);
3410 let luma = 299 * r as u32 + 587 * g as u32 + 114 * b as u32;
3411 if luma >= 128_000 {
3412 Color::Black
3413 } else {
3414 Color::White
3415 }
3416}
3417
3418fn approx_rgb(color: Color) -> (u8, u8, u8) {
3421 match color {
3422 Color::Rgb(r, g, b) => (r, g, b),
3423 Color::Indexed(i) => xterm_rgb(i),
3424 Color::Black => (0, 0, 0),
3425 Color::Red => (205, 0, 0),
3426 Color::Green => (0, 205, 0),
3427 Color::Yellow => (205, 205, 0),
3428 Color::Blue => (0, 0, 238),
3429 Color::Magenta => (205, 0, 205),
3430 Color::Cyan => (0, 205, 205),
3431 Color::Gray => (229, 229, 229),
3432 Color::DarkGray => (127, 127, 127),
3433 Color::LightRed => (255, 0, 0),
3434 Color::LightGreen => (0, 255, 0),
3435 Color::LightYellow => (255, 255, 0),
3436 Color::LightBlue => (92, 92, 255),
3437 Color::LightMagenta => (255, 0, 255),
3438 Color::LightCyan => (0, 255, 255),
3439 Color::White => (255, 255, 255),
3440 Color::Reset => (0, 0, 0),
3441 }
3442}
3443
3444fn xterm_rgb(i: u8) -> (u8, u8, u8) {
3446 match i {
3447 0..=15 => approx_rgb(match i {
3448 0 => Color::Black,
3449 1 => Color::Red,
3450 2 => Color::Green,
3451 3 => Color::Yellow,
3452 4 => Color::Blue,
3453 5 => Color::Magenta,
3454 6 => Color::Cyan,
3455 7 => Color::Gray,
3456 8 => Color::DarkGray,
3457 9 => Color::LightRed,
3458 10 => Color::LightGreen,
3459 11 => Color::LightYellow,
3460 12 => Color::LightBlue,
3461 13 => Color::LightMagenta,
3462 14 => Color::LightCyan,
3463 _ => Color::White,
3464 }),
3465 16..=231 => {
3466 let level = |n: u8| if n == 0 { 0 } else { 55 + 40 * n };
3467 let c = i - 16;
3468 (level(c / 36), level(c / 6 % 6), level(c % 6))
3469 }
3470 232..=255 => {
3471 let v = 8 + 10 * (i - 232);
3472 (v, v, v)
3473 }
3474 }
3475}
3476
3477fn wrap(text: &str, width: usize) -> Vec<String> {
3479 let mut lines: Vec<String> = Vec::new();
3480 for word in text.split_whitespace() {
3481 match lines.last_mut() {
3482 Some(line) if line.len() + 1 + word.len() <= width => {
3483 line.push(' ');
3484 line.push_str(word);
3485 }
3486 _ => lines.push(word.to_string()),
3487 }
3488 }
3489 lines
3490}
3491
3492#[cfg(test)]
3493mod tests {
3494 use std::path::{Path, PathBuf};
3495
3496 #[test]
3499 fn a_command_line_path_expands_only_a_leading_tilde() {
3500 let home = dirs::home_dir().expect("a home directory");
3501 let expand = |p: &str| super::expand_home(Path::new(p));
3502 assert_eq!(expand("~"), home);
3503 assert_eq!(expand("~/data/a.csv"), home.join("data/a.csv"));
3504 for kept in [
3505 "a/~/b.csv",
3506 "~user/a.csv",
3507 "$HOME/a.csv",
3508 "-",
3509 "s3://b/~/a.csv",
3510 ] {
3511 assert_eq!(expand(kept), PathBuf::from(kept), "{kept}");
3512 }
3513 #[cfg(windows)]
3514 assert_eq!(expand(r"~\data\a.csv"), home.join(r"data\a.csv"));
3515 #[cfg(not(windows))]
3517 assert_eq!(expand(r"~\a.csv"), PathBuf::from(r"~\a.csv"));
3518 for there in ["~", "~/a.csv"] {
3520 let kept = super::expand_home_unless(Path::new(there), |_| true);
3521 assert_eq!(kept, PathBuf::from(there), "{there}");
3522 }
3523 }
3524
3525 #[test]
3528 fn no_color_parses_every_color_as_reset() {
3529 use ratatui::style::Color;
3530 let parser = super::ColorParser {
3531 supports_true_color: true,
3532 supports_256: true,
3533 no_color: true,
3534 };
3535 for name in ["red", "#ff0000", "cyan", "indexed(240)"] {
3536 assert_eq!(parser.parse(name).unwrap(), Color::Reset, "{name}");
3537 }
3538 }
3539
3540 #[cfg(windows)]
3544 #[test]
3545 fn a_windows_console_with_vt_takes_true_color() {
3546 use super::windows_console_true_color as rule;
3547 assert!(rule(false, true, || true));
3548 assert!(!rule(false, true, || false), "a legacy console");
3549 assert!(
3550 !rule(true, true, || true),
3551 "TERM or FORCE_COLOR set: supports_color decides"
3552 );
3553 assert!(!rule(false, false, || true), "not a terminal");
3554 }
3555
3556 #[test]
3557 fn a_path_place_ignores_spelling_but_not_meaning() {
3558 let place = |p: &str| super::path_place(std::path::Path::new(p));
3559 for (a, b) in [
3560 ("/d/a.csv", "/d//a.csv"),
3561 ("/d/a.csv", "/d/./a.csv"),
3562 ("/d/sub", "/d/sub/"),
3563 ("a.csv", "./a.csv"),
3564 ] {
3565 assert_eq!(place(a), place(b), "{a} and {b}");
3566 }
3567 for (a, b) in [
3568 ("/d/../a.csv", "/a.csv"),
3569 ("/d/a.csv", "/d/A.csv"),
3570 ("/d/a.csv", "d/a.csv"),
3571 ("/d/a.csv", "/d/a.csv.gz"),
3572 ] {
3573 assert_ne!(place(a), place(b), "{a} and {b}");
3574 }
3575 #[cfg(windows)]
3576 {
3577 for (a, b) in [
3578 (r"C:\d\a.csv", r"c:\d\a.csv"),
3579 (r"C:\d\a.csv", "C:/d/a.csv"),
3580 (r"\\srv\share\a.csv", "//srv/share/a.csv"),
3581 ] {
3582 assert_eq!(place(a), place(b), "{a} and {b}");
3583 }
3584 for (a, b) in [
3585 (r"C:\d\a.csv", r"D:\d\a.csv"),
3586 (r"C:\a.csv", "C:a.csv"),
3587 (r"\\srv\share\a.csv", r"\\srv\other\a.csv"),
3588 ] {
3589 assert_ne!(place(a), place(b), "{a} and {b}");
3590 }
3591 }
3592 }
3593
3594 use super::*;
3595
3596 #[test]
3599 fn every_color_accessor_names_a_slot() {
3600 let theme = Theme::from_config(&AppConfig::default().theme).unwrap();
3601 for slot in COLOR_SLOTS {
3602 assert!(theme.colors.contains_key(*slot), "no {slot} slot");
3603 }
3604 }
3605
3606 #[test]
3607 fn a_key_the_registry_does_not_know_is_named_with_the_nearest() {
3608 let found = |text: &str| unknown_keys_in(&toml::from_str(text).unwrap());
3609 let unknown = found(
3610 "[file_loading]\ncomment_char = \"#\"\n[display]\nmouse = false\nrow_numbr = true\n\
3611 number_format = { grouping = \"thousands\" }\n[glyphs]\nspinner = [\"a\"]\n\
3612 [theme.colors]\naccent = \"red\"\n[[cloud.connections]]\nname = \"x\"\n\
3613 [[sources]]\nname = \"x\"\n[home]\ndirectories = [\"/d\"]\n",
3614 );
3615 assert_eq!(unknown.len(), 4, "{unknown:?}");
3616 assert!(
3618 unknown[2].starts_with("home.directories is not read any more")
3619 && unknown[2].contains("Ctrl+D"),
3620 "{unknown:?}"
3621 );
3622 assert!(
3623 unknown[3].starts_with("sources is not read any more")
3624 && unknown[3].contains("catalog.toml"),
3625 "{unknown:?}"
3626 );
3627 assert!(
3628 unknown[0].starts_with("display.row_numbr")
3629 && unknown[0].contains("display.row_numbers")
3630 );
3631 assert!(
3632 unknown[1].starts_with("file_loading.comment_char")
3633 && unknown[1].contains("csv.comment")
3634 );
3635 }
3636
3637 #[test]
3638 fn a_field_selection_stays_visible_when_the_tint_degrades() {
3639 use ratatui::style::{Modifier, Style};
3640 let theme_with = |tint: Option<Color>| Theme {
3641 colors: tint
3642 .map(|c| HashMap::from([("table_selected".to_string(), c)]))
3643 .unwrap_or_default(),
3644 };
3645 let reversed = Style::default().add_modifier(Modifier::REVERSED);
3646 for tint in [Color::Black, Color::White, Color::Reset] {
3648 assert_eq!(
3649 theme_with(Some(tint)).text_selection_style(),
3650 reversed,
3651 "{tint:?}"
3652 );
3653 }
3654 assert_eq!(theme_with(None).text_selection_style(), reversed);
3655 for tint in [
3656 Color::Rgb(0x28, 0x34, 0x57),
3657 Color::Indexed(237),
3658 Color::Blue,
3659 ] {
3660 assert_eq!(
3661 theme_with(Some(tint)).text_selection_style(),
3662 Style::default().bg(tint)
3663 );
3664 }
3665 }
3666
3667 fn leaf_settings() -> Vec<(String, toml::Value)> {
3670 fn walk(table: &toml::Table, prefix: &str, out: &mut Vec<(String, toml::Value)>) {
3671 for (key, value) in table {
3672 let path = if prefix.is_empty() {
3673 key.clone()
3674 } else {
3675 format!("{prefix}.{key}")
3676 };
3677 match value {
3678 toml::Value::Table(inner) => walk(inner, &path, out),
3679 toml::Value::Array(items) if items.iter().any(toml::Value::is_table) => {}
3680 _ => out.push((path, value.clone())),
3681 }
3682 }
3683 }
3684 let toml::Value::Table(defaults) = toml::Value::try_from(AppConfig::default()).unwrap()
3685 else {
3686 unreachable!("a struct serializes to a table")
3687 };
3688 let mut out = Vec::new();
3689 walk(&defaults, "", &mut out);
3690 for setting in datui_cli::settings::SETTINGS {
3691 if let datui_cli::settings::DefaultValue::Unset(example) = setting.default
3692 && !setting.key.ends_with(".*")
3693 && setting.kind != datui_cli::settings::Kind::Tables
3694 {
3695 let value: toml::Table = toml::from_str(&format!("v = {example}")).unwrap();
3696 out.push((setting.key.to_string(), value["v"].clone()));
3697 }
3698 }
3699 out
3700 }
3701
3702 fn layer_at(path: &str, value: toml::Value) -> Result<ConfigLayer> {
3704 let table = path.rsplit('.').fold(value, |inner, key| {
3705 toml::Value::Table(toml::Table::from_iter([(key.to_string(), inner)]))
3706 });
3707 ConfigLayer::parse(&toml::to_string(&table)?)
3708 }
3709
3710 fn value_at<'a>(table: &'a toml::Table, path: &str) -> Option<&'a toml::Value> {
3711 let (parents, key) = path.rsplit_once('.').unwrap_or(("", path));
3712 let mut table = table;
3713 for part in parents.split('.').filter(|p| !p.is_empty()) {
3714 table = table.get(part)?.as_table()?;
3715 }
3716 table.get(key)
3717 }
3718
3719 #[test]
3723 fn the_registry_and_the_config_structs_agree() {
3724 use datui_cli::settings::{DefaultValue, Kind, SETTINGS, find};
3725 let toml::Value::Table(defaults) = toml::Value::try_from(AppConfig::default()).unwrap()
3726 else {
3727 unreachable!("a struct serializes to a table")
3728 };
3729 let light = toml::Value::try_from(ColorConfig::light()).unwrap();
3730 for (path, value) in leaf_settings() {
3731 let setting = find(&path).unwrap_or_else(|| panic!("{path} is not registered"));
3732 let registered = match setting.default {
3733 DefaultValue::Value(v) | DefaultValue::Unset(v) => {
3734 toml::from_str::<toml::Table>(&format!("v = {v}")).unwrap()["v"].clone()
3735 }
3736 DefaultValue::Color { dark, light: lit } => {
3737 let name = setting.name();
3738 assert_eq!(
3739 light.get(name).and_then(|v| v.as_str()),
3740 Some(lit),
3741 "{path} (light)"
3742 );
3743 toml::Value::String(dark.to_string())
3744 }
3745 };
3746 assert_eq!(value, registered, "{path}: the registry's default differs");
3747 }
3748 for setting in SETTINGS {
3749 if setting.key.ends_with(".*") || setting.kind == Kind::Tables {
3750 continue;
3751 }
3752 let serialized = value_at(&defaults, setting.key).is_some();
3753 let unset = matches!(setting.default, DefaultValue::Unset(_));
3754 assert_eq!(
3755 serialized,
3756 !unset,
3757 "{}: registered as {}set by default",
3758 setting.key,
3759 if unset { "un" } else { "" }
3760 );
3761 let example = match setting.default {
3763 DefaultValue::Value(v) | DefaultValue::Unset(v) => v.to_string(),
3764 DefaultValue::Color { dark, .. } => format!("\"{dark}\""),
3765 };
3766 let value =
3767 toml::from_str::<toml::Table>(&format!("v = {example}")).unwrap()["v"].clone();
3768 layer_at(setting.key, value).unwrap_or_else(|e| panic!("{}: {e}", setting.key));
3769 }
3770 }
3771
3772 #[test]
3773 fn the_generated_config_shows_every_key_and_parses_uncommented() {
3774 let generated = ConfigManager::with_dir(PathBuf::new()).generate_default_config();
3775 let mut shown = std::collections::HashSet::new();
3776 let mut section = String::new();
3777 let mut uncommented = String::new();
3778 for line in generated.lines() {
3779 let Some(line) = line.strip_prefix("# ") else {
3780 uncommented.push_str(line);
3781 uncommented.push('\n');
3782 continue;
3783 };
3784 if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
3785 section = name.to_string();
3786 uncommented.push_str(line);
3787 uncommented.push('\n');
3788 } else if let Some((key, _)) = line.split_once(" = ")
3789 && !key.contains(' ')
3790 {
3791 shown.insert(match section.as_str() {
3792 "" => key.to_string(),
3793 s => format!("{s}.{key}"),
3794 });
3795 uncommented.push_str(line);
3796 uncommented.push('\n');
3797 }
3798 }
3799 for (path, _) in leaf_settings() {
3800 assert!(
3801 shown.contains(&path),
3802 "{path} is not in the generated config"
3803 );
3804 }
3805 ConfigLayer::parse(&uncommented).unwrap();
3807 }
3808
3809 #[test]
3810 fn every_setting_written_as_its_default_overrides_an_import() {
3811 let defaults = leaf_settings();
3815 let mut import = ConfigLayer::default();
3816 let mut moved = Vec::new();
3817 for (path, value) in &defaults {
3818 if path == "import" || COMBINED_KEYS.iter().any(|(p, _)| p == path) {
3820 continue;
3821 }
3822 let other = match value {
3823 toml::Value::Boolean(b) => toml::Value::Boolean(!b),
3824 toml::Value::Integer(n) => toml::Value::Integer(n + 1),
3825 toml::Value::Array(items) if items.is_empty() => vec!["x"].into(),
3826 toml::Value::Array(_) => toml::Value::Array(Vec::new()),
3827 toml::Value::String(text) if text == "auto" && path == "display.row_numbers" => {
3829 true.into()
3830 }
3831 toml::Value::String(text) => format!("{text}0").into(),
3832 other => panic!("{path}: no rule to change {other}"),
3833 };
3834 if let Ok(layer) = layer_at(path, other.clone()) {
3836 import.merge(layer);
3837 moved.push((path.clone(), other));
3838 }
3839 }
3840 assert!(moved.len() > 100, "only {} settings moved", moved.len());
3841
3842 let serialized = |config: AppConfig| match toml::Value::try_from(config).unwrap() {
3843 toml::Value::Table(table) => table,
3844 _ => unreachable!("a struct serializes to a table"),
3845 };
3846 let kept = serialized(AppConfig::from_layers([import.clone()]).unwrap());
3847 for (path, other) in &moved {
3848 assert_eq!(value_at(&kept, path), Some(other), "{path} was not read");
3849 }
3850
3851 let mut own = ConfigLayer::default();
3852 for (path, value) in &defaults {
3853 own.merge(layer_at(path, value.clone()).unwrap());
3854 }
3855 let restored = serialized(AppConfig::from_layers([import, own]).unwrap());
3856 for (path, _) in &moved {
3857 let default = defaults.iter().find(|(p, _)| p == path).map(|(_, v)| v);
3858 assert_eq!(value_at(&restored, path), default, "{path} kept the import");
3859 }
3860 }
3861}