pub mod config_command;
pub mod themes;
pub use crate::home::catalog::is_object_store_dataset;
use crate::numfmt::{self, Glob, Grouping, NumberFormat, NumberFormatSettings};
use color_eyre::Result;
use color_eyre::eyre::eyre;
pub use datui_cli::units::{ByteSize, Interval};
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use supports_color::Stream;
#[derive(Clone)]
pub struct ConfigManager {
pub(crate) config_dir: PathBuf,
}
impl ConfigManager {
pub fn with_dir(config_dir: PathBuf) -> Self {
Self { config_dir }
}
pub fn new(app_name: &str) -> Result<Self> {
#[cfg(test)]
crate::cache::isolate_cache();
if let Some(dir) = std::env::var_os("DATUI_CONFIG_DIR") {
return Ok(Self {
config_dir: PathBuf::from(dir),
});
}
if crate::cache::running_as_a_cargo_test() {
panic!(
"DATUI_CONFIG_DIR is not set: a test would read and write the real \
config (saved views included). Call common::isolate_cache() before \
building an App or a ConfigManager."
);
}
let config_dir = dirs::config_dir()
.ok_or_else(|| eyre!("Could not determine config directory"))?
.join(app_name);
Ok(Self { config_dir })
}
pub fn config_dir(&self) -> &Path {
&self.config_dir
}
pub fn config_path(&self, path: &str) -> PathBuf {
self.config_dir.join(path)
}
pub fn ensure_config_dir(&self) -> Result<()> {
if !self.config_dir.exists() {
std::fs::create_dir_all(&self.config_dir)?;
}
Ok(())
}
pub fn ensure_subdir(&self, subdir: &str) -> Result<PathBuf> {
let subdir_path = self.config_dir.join(subdir);
if !subdir_path.exists() {
std::fs::create_dir_all(&subdir_path)?;
}
Ok(subdir_path)
}
pub fn generate_default_config(&self) -> String {
use datui_cli::settings::{DefaultValue, Kind, SECTIONS, in_section};
let mut out = String::from(
"# datui configuration file (TOML: https://toml.io).\n\
# Every setting is commented out at its default; remove the # to change one.\n\
# `datui config keys` lists them with the values in effect.\n",
);
for section in SECTIONS {
let settings: Vec<_> = in_section(section.name)
.filter(|s| s.kind != Kind::Tables)
.collect();
if settings.is_empty() {
continue;
}
out.push('\n');
if !section.name.is_empty() {
out.push_str(&format!(
"# {rule}\n# {}\n# {rule}\n# [{}]\n",
section.title,
section.name,
rule = "=".repeat(76)
));
}
for setting in settings {
for line in wrap(setting.doc, 86) {
out.push_str(&format!("# {line}\n"));
}
let value = match setting.default {
DefaultValue::Value(v) | DefaultValue::Unset(v) => v.to_string(),
DefaultValue::Color { dark, .. } => format!("\"{dark}\""),
};
if setting.key.ends_with(".*") {
out.push_str(&format!("# {value}\n"));
} else {
out.push_str(&format!("# {} = {value}\n", setting.name()));
}
}
}
out
}
pub fn write_default_config(&self, force: bool) -> Result<PathBuf> {
let config_path = self.config_path("config.toml");
if config_path.exists() && !force {
return Err(eyre!(
"Config file already exists at {}. Use --force to overwrite.",
config_path.display()
));
}
self.ensure_config_dir()?;
let template = self.generate_default_config();
write_private(&config_path, &template)?;
let catalog = self.config_path(crate::home::catalog::MINE_FILE);
if !catalog.exists() {
std::fs::write(&catalog, crate::home::catalog::MINE_TEMPLATE)?;
}
self.ensure_subdir(crate::home::catalog::FOLDER)?;
self.ensure_subdir(crate::config::themes::FOLDER)?;
Ok(config_path)
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(untagged)]
pub enum CatalogRef {
Path(String),
Table {
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
label: Option<String>,
},
}
impl CatalogRef {
pub fn path(&self) -> &str {
match self {
Self::Path(path) | Self::Table { path, .. } => path,
}
}
pub fn id(&self) -> Option<&str> {
match self {
Self::Table { id: Some(id), .. } => Some(id),
_ => None,
}
}
pub fn label(&self) -> Option<&str> {
match self {
Self::Table {
label: Some(label), ..
} => Some(label),
_ => None,
}
}
}
impl<'de> Deserialize<'de> for CatalogRef {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error;
match toml::Value::deserialize(deserializer)? {
toml::Value::String(path) => Ok(Self::Path(path)),
toml::Value::Table(table) => {
let text = |key: &str| -> Result<Option<String>, D::Error> {
match table.get(key) {
None => Ok(None),
Some(toml::Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(D::Error::custom(format!(
"catalogs: {key} must be a string"
))),
}
};
if let Some(key) = table
.keys()
.find(|k| !matches!(k.as_str(), "path" | "id" | "label"))
{
return Err(D::Error::custom(format!(
"catalogs: unknown key '{key}'. Expected one of: path, id, label"
)));
}
let path = text("path")?
.ok_or_else(|| D::Error::custom("catalogs: a table needs path = \"...\""))?;
Ok(Self::Table {
path,
id: text("id")?,
label: text("label")?,
})
}
_ => Err(D::Error::custom(
"catalogs: each entry is a path, or { path, id, label }",
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AppConfig {
pub import: Vec<String>,
pub catalogs: Vec<CatalogRef>,
#[serde(skip)]
pub read_catalogs: Vec<crate::home::catalog::Catalog>,
#[serde(skip)]
pub catalog_dir: Option<PathBuf>,
#[serde(skip)]
pub broken_catalogs: Vec<crate::home::catalog::Broken>,
pub read: ReadConfig,
pub csv: CsvConfig,
pub display: DisplayConfig,
pub performance: PerformanceConfig,
pub analysis: AnalysisConfig,
pub chart: ChartConfig,
pub home: HomeConfig,
pub cloud: CloudConfig,
pub http: HttpConfig,
pub query: QueryConfig,
pub views: ViewsConfig,
pub clipboard: ClipboardConfig,
pub formats: FormatsConfig,
pub limits: LimitsConfig,
pub log: LogConfig,
pub theme: ThemeConfig,
pub glyphs: GlyphsConfig,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CloudConfig {
#[serde(skip)]
pub s3_endpoint_url: Option<String>,
#[serde(skip)]
pub s3_access_key_id: Option<String>,
#[serde(skip)]
pub s3_secret_access_key: Option<String>,
#[serde(skip)]
pub s3_region: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub connections: Vec<CloudConnectionConfig>,
pub hide: Vec<String>,
pub use_azure_account_keys: bool,
pub env_files: Vec<String>,
pub instance_identity: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub discover: Option<CloudDiscover>,
pub list_on_start: bool,
#[serde(skip)]
pub dataset_access: Vec<DatasetAccess>,
}
impl Default for CloudConfig {
fn default() -> Self {
Self {
s3_endpoint_url: None,
s3_access_key_id: None,
s3_secret_access_key: None,
s3_region: None,
connections: Vec::new(),
hide: Vec::new(),
use_azure_account_keys: true,
env_files: Vec::new(),
instance_identity: false,
discover: None,
list_on_start: false,
dataset_access: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DatasetAccess {
pub url: String,
pub catalog: String,
pub auth: DatasetAuth,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DatasetAuth {
Auto,
Anonymous,
Connection(String),
}
pub const CLOUD_DISCOVER_KINDS: [&str; 3] = ["s3", "gcs", "azure"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "CloudDiscoverValue", into = "CloudDiscoverValue")]
pub enum CloudDiscover {
All,
None,
Kinds(Vec<String>),
}
impl CloudDiscover {
pub fn allows(&self, kind: &str) -> bool {
match self {
CloudDiscover::All => true,
CloudDiscover::None => false,
CloudDiscover::Kinds(kinds) => kinds.iter().any(|k| k == kind),
}
}
fn from_kinds<S: AsRef<str>>(kinds: &[S]) -> std::result::Result<Self, String> {
let mut out: Vec<String> = Vec::new();
for kind in kinds {
let kind = kind.as_ref().trim().to_ascii_lowercase();
if !CLOUD_DISCOVER_KINDS.contains(&kind.as_str()) {
return Err(format!(
"cloud.discover: unknown kind \"{kind}\"; use {}",
CLOUD_DISCOVER_KINDS.join(", ")
));
}
if !out.contains(&kind) {
out.push(kind);
}
}
Ok(CloudDiscover::Kinds(out))
}
}
impl std::str::FromStr for CloudDiscover {
type Err = String;
fn from_str(text: &str) -> std::result::Result<Self, String> {
match text.trim().to_ascii_lowercase().as_str() {
"all" => Ok(CloudDiscover::All),
"none" => Ok(CloudDiscover::None),
_ => CloudDiscover::from_kinds(&text.split(',').collect::<Vec<_>>()).map_err(|_| {
format!(
"cloud.discover: \"{text}\" is not \"all\", \"none\", or kinds from {}",
CLOUD_DISCOVER_KINDS.join(", ")
)
}),
}
}
}
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum CloudDiscoverValue {
Switch(bool),
Word(String),
Kinds(Vec<String>),
}
impl TryFrom<CloudDiscoverValue> for CloudDiscover {
type Error = String;
fn try_from(value: CloudDiscoverValue) -> std::result::Result<Self, String> {
match value {
CloudDiscoverValue::Switch(true) => Ok(CloudDiscover::All),
CloudDiscoverValue::Switch(false) => Ok(CloudDiscover::None),
CloudDiscoverValue::Word(word) => word.parse(),
CloudDiscoverValue::Kinds(kinds) => CloudDiscover::from_kinds(&kinds),
}
}
}
impl From<CloudDiscover> for CloudDiscoverValue {
fn from(discover: CloudDiscover) -> Self {
match discover {
CloudDiscover::All => CloudDiscoverValue::Switch(true),
CloudDiscover::None => CloudDiscoverValue::Switch(false),
CloudDiscover::Kinds(kinds) => CloudDiscoverValue::Kinds(kinds),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct CloudConnectionConfig {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub buckets: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub addressing: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub access_key_id_env: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secret_access_key_env: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_token_env: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub configuration: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub account_key_env: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sas_env: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub connection_string_env: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secret_command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub credentials_file: Option<String>,
#[serde(flatten)]
pub unknown: std::collections::BTreeMap<String, toml::Value>,
}
const CLOUD_SOURCE_KEYS: &str = "name, label, kind, buckets, endpoint_url, region, \
addressing, access_key_id_env, secret_access_key_env, session_token_env, profile, \
configuration, project, account, account_key_env, sas_env, connection_string_env, \
secret_command, credentials_file";
pub fn is_valid_source_id(id: &str) -> bool {
let bytes = id.as_bytes();
!bytes.is_empty()
&& bytes.len() <= 40
&& bytes[0] != b'-'
&& bytes
.iter()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
}
impl CloudConnectionConfig {
fn validate(&self) -> Result<()> {
let name = &self.name;
if name.is_empty() {
return Err(eyre!("cloud.connections: every source needs a name"));
}
if !is_valid_source_id(name) {
return Err(eyre!(
"cloud.connections: \"{name}\" is not a valid name. Use lowercase letters, digits \
and '-', up to 40 characters"
));
}
for secret in ["access_key_id", "secret_access_key", "session_token"] {
if self.unknown.contains_key(secret) {
return Err(eyre!(
"cloud.connections \"{name}\": {secret} cannot be written in the config. Put it \
in an environment variable and name that with {secret}_env"
));
}
}
if !self.unknown.is_empty() {
let keys: Vec<String> = self.unknown.keys().map(|k| format!("'{k}'")).collect();
return Err(eyre!(
"cloud.connections \"{name}\": unknown key{} {}. Expected one of: {}",
if keys.len() > 1 { "s" } else { "" },
keys.join(", "),
CLOUD_SOURCE_KEYS
));
}
for secret in ["account_key", "sas", "sas_token", "connection_string"] {
if self.unknown.contains_key(secret) {
return Err(eyre!(
"cloud.connections \"{name}\": {secret} cannot be written in the config. Put it \
in an environment variable and name that with {}_env",
secret.trim_end_matches("_token")
));
}
}
let kind = match self.kind.as_deref() {
Some(kind @ ("s3" | "gcs" | "azure")) => kind,
Some(other) => {
return Err(eyre!(
"cloud.connections \"{name}\": kind \"{other}\" is not supported. Expected s3, gcs or azure"
));
}
None => {
return Err(eyre!(
"cloud.connections \"{name}\": kind is required (s3, gcs or azure)"
));
}
};
if let Some(command) = &self.secret_command {
if !matches!(kind, "s3" | "azure") {
return Err(eyre!(
"cloud.connections \"{name}\": secret_command applies only to kind = \"s3\" or \"azure\""
));
}
if command.trim().is_empty() {
return Err(eyre!(
"cloud.connections \"{name}\": secret_command is not a command line"
));
}
let clash = if kind == "s3" {
[
(
"secret_access_key_env",
self.secret_access_key_env.is_some(),
),
("profile", self.profile.is_some()),
("", false),
]
} else {
[
("account_key_env", self.account_key_env.is_some()),
("sas_env", self.sas_env.is_some()),
(
"connection_string_env",
self.connection_string_env.is_some(),
),
]
};
if let Some((field, _)) = clash.iter().find(|(_, set)| *set) {
return Err(eyre!(
"cloud.connections \"{name}\": secret_command and {field} both say where the \
secret comes from. Use one"
));
}
if kind == "s3" && self.access_key_id_env.is_none() {
return Err(eyre!(
"cloud.connections \"{name}\": secret_command prints the secret; name the key \
ID with access_key_id_env"
));
}
}
if let Some(_file) = &self.credentials_file {
if kind != "gcs" {
return Err(eyre!(
"cloud.connections \"{name}\": credentials_file applies only to kind = \"gcs\""
));
}
if self.configuration.is_some() {
return Err(eyre!(
"cloud.connections \"{name}\": credentials_file and configuration both say how \
to log in. Use one"
));
}
}
if kind != "azure" {
let azure_only = [
("account", self.account.is_some()),
("account_key_env", self.account_key_env.is_some()),
("sas_env", self.sas_env.is_some()),
(
"connection_string_env",
self.connection_string_env.is_some(),
),
];
if let Some((field, _)) = azure_only.iter().find(|(_, set)| *set) {
return Err(eyre!(
"cloud.connections \"{name}\": {field} applies only to kind = \"azure\""
));
}
} else {
let secrets = [
self.account_key_env.is_some(),
self.sas_env.is_some(),
self.connection_string_env.is_some(),
];
if secrets.iter().filter(|set| **set).count() > 1 {
return Err(eyre!(
"cloud.connections \"{name}\": account_key_env, sas_env and \
connection_string_env each say how to sign in. Use one"
));
}
if self.account.is_none() && self.connection_string_env.is_none() {
return Err(eyre!(
"cloud.connections \"{name}\": an azure source needs account, or \
connection_string_env"
));
}
if !self.buckets.is_empty() {
return Err(eyre!(
"cloud.connections \"{name}\": buckets does not apply to kind = \"azure\""
));
}
}
if kind != "s3" {
let s3_only = [
("endpoint_url", self.endpoint_url.is_some()),
("region", self.region.is_some()),
("addressing", self.addressing.is_some()),
("access_key_id_env", self.access_key_id_env.is_some()),
(
"secret_access_key_env",
self.secret_access_key_env.is_some(),
),
("session_token_env", self.session_token_env.is_some()),
("profile", self.profile.is_some()),
];
if let Some((field, _)) = s3_only.iter().find(|(_, set)| *set) {
return Err(eyre!(
"cloud.connections \"{name}\": {field} applies only to kind = \"s3\""
));
}
}
if kind != "gcs" {
let gcs_only = [
("configuration", self.configuration.is_some()),
("project", self.project.is_some()),
];
if let Some((field, _)) = gcs_only.iter().find(|(_, set)| *set) {
return Err(eyre!(
"cloud.connections \"{name}\": {field} applies only to kind = \"gcs\""
));
}
}
if self.profile.is_some()
&& (self.access_key_id_env.is_some()
|| self.secret_access_key_env.is_some()
|| self.session_token_env.is_some())
{
return Err(eyre!(
"cloud.connections \"{name}\": profile and the *_env keys both say where the keys \
come from. Use one"
));
}
if let Some(addressing) = self.addressing.as_deref()
&& !matches!(addressing, "path" | "virtual")
{
return Err(eyre!(
"cloud.connections \"{name}\": addressing \"{addressing}\" is not valid. Expected path \
or virtual"
));
}
if let Some(bucket) = self.buckets.iter().find(|b| b.contains(['/', '@'])) {
return Err(eyre!(
"cloud.connections \"{name}\": \"{bucket}\" is not a bucket name{}",
if bucket.contains("://") {
". A dataset URL goes in a catalog"
} else {
""
}
));
}
Ok(())
}
}
fn write_private(path: &Path, contents: &str) -> Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(contents.as_bytes())?;
file.sync_all()?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
#[cfg(not(unix))]
{
std::fs::write(path, contents)?;
}
Ok(())
}
pub const S3_ENDPOINT_VARS: [&str; 3] = ["AWS_ENDPOINT_URL_S3", "AWS_ENDPOINT_URL", "AWS_ENDPOINT"];
fn non_blank(value: String) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
impl CloudConfig {
pub fn from_env(var: &dyn Fn(&str) -> Option<String>) -> Self {
let first = |keys: &[&str]| keys.iter().find_map(|key| var(key).and_then(non_blank));
Self {
s3_endpoint_url: first(&S3_ENDPOINT_VARS),
s3_access_key_id: first(&["AWS_ACCESS_KEY_ID"]),
s3_secret_access_key: first(&["AWS_SECRET_ACCESS_KEY"]),
s3_region: first(&["AWS_REGION", "AWS_DEFAULT_REGION"]),
..Default::default()
}
}
pub fn overlay(&mut self, over: Self) {
for (slot, value) in [
(&mut self.s3_endpoint_url, over.s3_endpoint_url),
(&mut self.s3_access_key_id, over.s3_access_key_id),
(&mut self.s3_secret_access_key, over.s3_secret_access_key),
(&mut self.s3_region, over.s3_region),
] {
if let Some(value) = value.and_then(non_blank) {
*slot = Some(value);
}
}
}
pub fn validate(&self) -> Result<()> {
for (i, connection) in self.connections.iter().enumerate() {
connection.validate()?;
if self.connections[..i]
.iter()
.any(|c| c.name == connection.name)
{
return Err(eyre!(
"cloud.connections: the name \"{}\" is used twice",
connection.name
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ReadConfig {
pub infer_types: InferTypes,
pub parquet_schema: ParquetSchema,
pub decompress_in_memory: bool,
pub temp_dir: Option<String>,
pub follow_interval: Interval,
pub exact_count_files: usize,
pub memory_warning: ByteSize,
pub audio_float: bool,
}
impl ReadConfig {
pub fn memory_warning(&self) -> Option<u64> {
let bytes = self.memory_warning.bytes();
(bytes > 0).then_some(bytes)
}
}
impl Default for ReadConfig {
fn default() -> Self {
Self {
infer_types: InferTypes::Switch(true),
parquet_schema: ParquetSchema::Union,
decompress_in_memory: false,
temp_dir: None,
follow_interval: Interval(crate::loading::follow::DEFAULT_INTERVAL),
exact_count_files: 50_000,
memory_warning: ByteSize::mib(1024),
audio_float: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum InferTypes {
Switch(bool),
Columns(Vec<String>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ParquetSchema {
Union,
First,
}
const FOLLOW_INTERVAL: std::ops::RangeInclusive<std::time::Duration> =
std::time::Duration::from_millis(10)..=std::time::Duration::from_secs(60);
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CsvConfig {
pub comment: Option<String>,
pub header_join: String,
pub skip_initial_space: bool,
pub null_values: Vec<String>,
pub infer_rows: usize,
pub ignore_errors: bool,
}
impl Default for CsvConfig {
fn default() -> Self {
Self {
comment: None,
header_join: crate::formats::csv_dialect::DEFAULT_HEADER_JOIN.to_string(),
skip_initial_space: false,
null_values: Vec::new(),
infer_rows: 1000,
ignore_errors: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DisplayConfig {
pub unicode: crate::glyphs::UnicodeMode,
pub row_numbers: RowNumbers,
pub row_numbers_start: usize,
pub cell_padding: CellPadding,
pub column_colors: bool,
pub type_row: bool,
pub notes_accent: bool,
pub mouse: bool,
pub scroll_region: bool,
pub sidebar_width: Option<u16>,
pub right_align_numbers: bool,
pub number_format: NumberFormatConfig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RowNumbers {
#[default]
Auto,
On,
Off,
}
impl RowNumbers {}
impl From<bool> for RowNumbers {
fn from(on: bool) -> Self {
if on { Self::On } else { Self::Off }
}
}
impl Serialize for RowNumbers {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Auto => serializer.serialize_str("auto"),
Self::On => serializer.serialize_bool(true),
Self::Off => serializer.serialize_bool(false),
}
}
}
impl<'de> Deserialize<'de> for RowNumbers {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error;
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Bool(bool),
Name(String),
}
const EXPECTED: &str = "row_numbers is \"auto\", true or false";
match Raw::deserialize(deserializer).map_err(|_| D::Error::custom(EXPECTED))? {
Raw::Bool(on) => Ok(on.into()),
Raw::Name(name) if name == "auto" => Ok(Self::Auto),
Raw::Name(other) => Err(D::Error::custom(format!("{EXPECTED}, not {other:?}"))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(untagged)]
pub enum CellPadding {
Cells(usize),
Density(Density),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Density {
Compact,
Comfortable,
}
impl Default for CellPadding {
fn default() -> Self {
Self::Density(Density::Comfortable)
}
}
impl CellPadding {
pub fn cells(self) -> u16 {
match self {
Self::Cells(n) => u16::try_from(n).unwrap_or(u16::MAX),
Self::Density(Density::Compact) => 1,
Self::Density(Density::Comfortable) => 2,
}
}
}
impl<'de> Deserialize<'de> for CellPadding {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error;
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Cells(usize),
Name(String),
}
const EXPECTED: &str = "cell_padding is \"compact\", \"comfortable\" or a number of cells";
match Raw::deserialize(deserializer).map_err(|_| D::Error::custom(EXPECTED))? {
Raw::Cells(n) => Ok(Self::Cells(n)),
Raw::Name(name) => match name.as_str() {
"compact" => Ok(Self::Density(Density::Compact)),
"comfortable" => Ok(Self::Density(Density::Comfortable)),
other => Err(D::Error::custom(format!("{EXPECTED}, not {other:?}"))),
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum NumberFormatConfig {
Preset(String),
Custom(Box<NumberFormatTable>),
}
impl Default for NumberFormatConfig {
fn default() -> Self {
NumberFormatConfig::Preset("none".to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct NumberFormatTable {
pub grouping: Option<String>,
pub group_separator: Option<String>,
pub decimal_separator: Option<String>,
pub floats: Option<bool>,
pub float_precision: Option<u8>,
pub exclude_columns: Vec<String>,
#[serde(flatten)]
pub unknown: std::collections::BTreeMap<String, toml::Value>,
}
const NUMBER_FORMAT_KEYS: &str =
"grouping, group_separator, decimal_separator, floats, float_precision, exclude_columns";
impl NumberFormatConfig {
pub fn resolve(&self, align_numeric_right: bool) -> Result<NumberFormatSettings> {
let (format, exclude) = match self {
NumberFormatConfig::Preset(name) => (Self::lookup_preset(name)?, Vec::new()),
NumberFormatConfig::Custom(table) => {
if !table.unknown.is_empty() {
let keys: Vec<&str> = table.unknown.keys().map(String::as_str).collect();
return Err(eyre!(
"display.number_format: unknown key{} {}. Expected one of: {}",
if keys.len() > 1 { "s" } else { "" },
keys.iter()
.map(|k| format!("'{}'", k))
.collect::<Vec<_>>()
.join(", "),
NUMBER_FORMAT_KEYS
));
}
let base = match table.grouping.as_deref() {
Some(name) => Self::lookup_preset(name)?,
None => NumberFormat::PLAIN,
};
let mut fmt = base;
if let Some(sep) = table.group_separator.as_deref() {
fmt.group_sep = Self::single_char(sep, "group_separator")?;
}
if let Some(sep) = table.decimal_separator.as_deref() {
fmt.decimal_sep = Self::single_char(sep, "decimal_separator")?;
}
if let Some(v) = table.floats {
fmt.floats = v;
}
if table.float_precision.is_some() {
fmt.float_precision = table.float_precision;
}
(fmt, table.exclude_columns.iter().map(Glob::new).collect())
}
};
if format.grouping != Grouping::None && format.group_sep == format.decimal_sep {
return Err(eyre!(
"display.number_format: group_separator and decimal_separator are both '{}'; \
they must differ or numbers become ambiguous",
format.group_sep
));
}
let enabled = !format.is_noop();
let format = if enabled {
format
} else {
NumberFormat {
grouping: Grouping::Thousands,
..format
}
};
Ok(NumberFormatSettings {
format,
enabled,
exclude,
align_numeric_right,
})
}
pub fn with_grouping_override(&self, name: &str) -> Self {
match self {
NumberFormatConfig::Preset(_) => NumberFormatConfig::Preset(name.to_string()),
NumberFormatConfig::Custom(table) => {
let mut table = table.clone();
table.grouping = Some(name.to_string());
NumberFormatConfig::Custom(table)
}
}
}
fn lookup_preset(name: &str) -> Result<NumberFormat> {
let name = if name == "system" {
match numfmt::system_locale_tag() {
Some(tag) => numfmt::preset_for_locale_tag(&tag),
None => "thousands",
}
} else {
name
};
NumberFormat::preset(name).ok_or_else(|| {
eyre!(
"display.number_format: unknown value '{}'. Expected one of: {}, system",
name,
NumberFormat::PRESET_NAMES.join(", ")
)
})
}
fn single_char(s: &str, field: &str) -> Result<char> {
let mut chars = s.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => Ok(c),
_ => Err(eyre!(
"display.number_format.{}: expected a single character, got {:?}",
field,
s
)),
}
}
}
pub const DEFAULT_ANALYSIS_SAMPLE_ROWS: usize = 100_000;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PerformanceConfig {
pub pages_ahead: usize,
pub pages_behind: usize,
pub max_buffered_rows: usize,
pub max_buffered: ByteSize,
pub streaming: bool,
pub threads: usize,
}
impl PerformanceConfig {
pub fn max_buffered_mb(&self) -> usize {
usize::try_from(self.max_buffered.bytes().div_ceil(1 << 20)).unwrap_or(usize::MAX)
}
}
pub const DEFAULT_QUALITY_LOCAL_COPY: ByteSize = ByteSize::mib(2048);
pub const DEFAULT_CHART_ROW_LIMIT: usize = 10_000;
pub const MAX_CHART_ROW_LIMIT: usize = u32::MAX as usize;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AnalysisConfig {
pub sample_rows: usize,
pub chart_rows: usize,
pub chart_grid: bool,
pub quality_local_copy: ByteSize,
pub sample_memory_limit: Option<ByteSize>,
}
impl Default for AnalysisConfig {
fn default() -> Self {
Self {
sample_rows: DEFAULT_ANALYSIS_SAMPLE_ROWS,
chart_rows: DEFAULT_CHART_ROW_LIMIT,
chart_grid: false,
quality_local_copy: DEFAULT_QUALITY_LOCAL_COPY,
sample_memory_limit: None,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ThemeMode {
#[default]
Auto,
Dark,
Light,
}
impl ThemeMode {
pub fn resolve(self) -> Self {
match self {
Self::Auto => detect_terminal_mode(),
other => other,
}
}
}
fn detect_terminal_mode() -> ThemeMode {
let Ok(raw) = std::env::var("COLORFGBG") else {
return ThemeMode::Dark;
};
match raw
.rsplit(';')
.next()
.and_then(|b| b.trim().parse::<u8>().ok())
{
Some(7) | Some(15) => ThemeMode::Light,
_ => ThemeMode::Dark,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HomeConfig {
pub desktop_recents: bool,
pub show_unreadable: bool,
pub wordmark: bool,
pub hide: Vec<String>,
pub preview_max: ByteSize,
pub search: SearchConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SearchConfig {
pub enabled: bool,
pub max_depth: usize,
pub max_results: usize,
pub time_budget: Interval,
pub cross_filesystems: bool,
pub follow_gitignore: bool,
pub skip: Vec<String>,
pub skip_extra: Vec<String>,
pub extensions: Vec<String>,
}
pub const DEFAULT_SEARCH_SKIP: &[&str] = &[
"node_modules",
"target",
"build",
"dist",
"vendor",
"site-packages",
"__pycache__",
"venv",
"env",
];
impl Default for SearchConfig {
fn default() -> Self {
Self {
enabled: true,
max_depth: 8,
max_results: 1_000,
time_budget: Interval::ms(1_500),
cross_filesystems: false,
follow_gitignore: false,
skip: DEFAULT_SEARCH_SKIP.iter().map(|s| s.to_string()).collect(),
skip_extra: Vec::new(),
extensions: Vec::new(),
}
}
}
impl SearchConfig {
pub fn skipped_dirs(&self) -> Vec<String> {
let mut out = self.skip.clone();
out.extend(self.skip_extra.iter().cloned());
out
}
}
impl Default for HomeConfig {
fn default() -> Self {
Self {
desktop_recents: true,
show_unreadable: false,
wordmark: true,
hide: Vec::new(),
preview_max: ByteSize::mib(64),
search: SearchConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ThemeConfig {
pub mode: Option<ThemeMode>,
pub dark: String,
pub light: String,
pub colors: ColorConfig,
#[serde(skip)]
pub follow: bool,
#[serde(skip)]
pub overrides: toml::Table,
#[serde(skip)]
pub library: crate::config::themes::Library,
#[serde(skip)]
pub dark_theme: String,
#[serde(skip)]
pub light_theme: String,
#[serde(skip)]
pub dark_palette: ColorConfig,
#[serde(skip)]
pub light_palette: ColorConfig,
#[serde(skip)]
pub problems: Vec<String>,
#[serde(skip)]
pub fallbacks: Vec<String>,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
mode: None,
dark: crate::config::themes::NIGHT_MARKET.to_string(),
light: crate::config::themes::DAY_MARKET.to_string(),
colors: ColorConfig::default(),
follow: false,
overrides: toml::Table::new(),
library: crate::config::themes::Library::default(),
dark_theme: crate::config::themes::NIGHT_MARKET.to_string(),
light_theme: crate::config::themes::DAY_MARKET.to_string(),
dark_palette: ColorConfig::dark(),
light_palette: ColorConfig::light(),
problems: Vec::new(),
fallbacks: Vec::new(),
}
}
}
impl ThemeConfig {
pub fn palette_for(&self, mode: ThemeMode) -> Result<ColorConfig> {
let base = match mode.resolve() {
ThemeMode::Light => &self.light_palette,
_ => &self.dark_palette,
};
let mut palette = crate::config::themes::slots(base);
palette.extend(self.overrides.clone());
Ok(toml::Value::Table(palette).try_into()?)
}
pub fn warnings(&self) -> Vec<String> {
let broken = self
.library
.broken
.iter()
.map(|b| format!("warning: theme left out: {}", b.full()));
let problems = self.problems.iter().map(|p| format!("warning: {p}"));
broken.chain(problems).collect()
}
pub fn use_library(&mut self, library: crate::config::themes::Library, active: ThemeMode) {
self.problems.clear();
self.fallbacks.clear();
for mode in [ThemeMode::Dark, ThemeMode::Light] {
let (key, name) = match mode {
ThemeMode::Light => ("theme.light", self.light.clone()),
_ => ("theme.dark", self.dark.clone()),
};
let (used, palette) = match library.resolve(&name, mode) {
Ok(palette) => (name, palette),
Err(why) => {
let fallback = crate::config::themes::built_in_name(mode);
if self.follow || active == mode {
let short = format!("{key}: using {fallback}, not {name}");
self.problems.push(format!("{short}: {why}"));
self.fallbacks.push(short);
}
(fallback.to_string(), ColorConfig::for_mode(mode))
}
};
match mode {
ThemeMode::Light => (self.light_theme, self.light_palette) = (used, palette),
_ => (self.dark_theme, self.dark_palette) = (used, palette),
}
}
self.library = library;
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ColorConfig {
pub chip_key: String,
pub chip_label: String,
pub throbber: String,
pub success: String,
pub error: String,
pub warning: String,
pub dimmed: String,
pub background: String,
pub surface: String,
pub controls_bg: String,
pub text_primary: String,
pub text_secondary: String,
pub text_inverse: String,
pub table_header: String,
pub table_header_bg: String,
pub table_row_numbers: String,
pub table_column_separator: String,
pub table_selected: String,
pub table_column_cursor: String,
pub table_cell_cursor: String,
pub table_alternate_row: String,
pub sidebar_border: String,
pub modal_border_active: String,
pub modal_border_error: String,
pub distribution_normal: String,
pub distribution_skewed: String,
pub distribution_other: String,
pub outlier_marker: String,
pub input_cursor: String,
pub input_cursor_text: String,
pub type_str: String,
pub type_int: String,
pub type_float: String,
pub type_bool: String,
pub type_temporal: String,
pub type_binary: String,
pub chart_1: String,
pub chart_2: String,
pub chart_3: String,
pub chart_4: String,
pub chart_5: String,
pub chart_6: String,
pub chart_7: String,
pub chart_8: String,
pub chart_9: String,
pub chart_10: String,
pub chart_grid: String,
pub accent: String,
pub accent_bright: String,
pub gradient_start: String,
pub gradient_end: String,
pub find_match: String,
pub hex_null: String,
pub hex_printable: String,
pub hex_whitespace: String,
pub hex_control: String,
pub hex_high: String,
pub hex_ff: String,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct HttpConfig {
pub user_agent: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct QueryConfig {
pub history_limit: usize,
pub history: bool,
pub default_mode: QueryMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum QueryMode {
#[default]
Sql,
Q,
}
impl QueryMode {
pub fn available() -> &'static [QueryMode] {
#[cfg(feature = "sql")]
{
&[QueryMode::Sql, QueryMode::Q]
}
#[cfg(not(feature = "sql"))]
{
&[QueryMode::Q]
}
}
pub fn resolve(self) -> QueryMode {
if Self::available().contains(&self) {
self
} else {
QueryMode::Q
}
}
pub fn prefix_colon(self) -> &'static str {
match self {
QueryMode::Sql => "sql:",
QueryMode::Q => "q:",
}
}
pub fn next(self) -> QueryMode {
let modes = Self::available();
let at = modes.iter().position(|&m| m == self).unwrap_or(0);
modes[(at + 1) % modes.len()]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ChartConfig {
pub export_recipe: bool,
}
impl Default for ChartConfig {
fn default() -> Self {
Self {
export_recipe: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ViewsConfig {
pub auto_apply: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct LogConfig {
pub file: Option<String>,
pub level: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct FormatsConfig {
pub path: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ClipboardConfig {
pub backend: String,
pub osc52_limit: ByteSize,
}
impl Default for ClipboardConfig {
fn default() -> Self {
Self {
backend: "auto".to_string(),
osc52_limit: ByteSize::kib(100),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct GlyphsConfig {
#[serde(flatten)]
pub overrides: std::collections::BTreeMap<String, crate::glyphs::SlotOverride>,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct LimitsConfig {
pub indexed_records: usize,
pub elf_symbols: usize,
pub midi_bytes: ByteSize,
pub midi_events: usize,
pub detail_rows: usize,
pub journal_bytes: ByteSize,
pub sdf_fields: usize,
pub vcd_signals: usize,
pub fix_tags: usize,
pub fix_fields: usize,
pub gpx_fields: usize,
pub npy_header_bytes: ByteSize,
}
impl LimitsConfig {
pub const DEFAULT: Self = Self {
indexed_records: 64 << 20,
elf_symbols: 10_000_000,
midi_bytes: ByteSize::mib(64),
midi_events: 10_000_000,
detail_rows: 10_000,
journal_bytes: ByteSize::mib(1024),
sdf_fields: 4096,
vcd_signals: 1 << 20,
fix_tags: 4096,
fix_fields: 4096,
gpx_fields: 256,
npy_header_bytes: ByteSize::mib(4),
};
}
impl Default for LimitsConfig {
fn default() -> Self {
Self::DEFAULT
}
}
impl Default for AppConfig {
fn default() -> Self {
let mut config = Self {
import: Vec::new(),
catalogs: Vec::new(),
read_catalogs: Vec::new(),
catalog_dir: None,
broken_catalogs: Vec::new(),
read: ReadConfig::default(),
csv: CsvConfig::default(),
display: DisplayConfig::default(),
performance: PerformanceConfig::default(),
analysis: AnalysisConfig::default(),
chart: ChartConfig::default(),
home: HomeConfig::default(),
cloud: CloudConfig::default(),
http: HttpConfig::default(),
query: QueryConfig::default(),
views: ViewsConfig::default(),
clipboard: ClipboardConfig::default(),
formats: FormatsConfig::default(),
limits: LimitsConfig::DEFAULT,
log: LogConfig::default(),
theme: ThemeConfig::default(),
glyphs: GlyphsConfig::default(),
};
config.sync_dataset_access();
config
}
}
impl Default for DisplayConfig {
fn default() -> Self {
Self {
unicode: crate::glyphs::UnicodeMode::default(),
row_numbers: RowNumbers::Auto,
row_numbers_start: 1,
cell_padding: CellPadding::default(),
column_colors: true,
type_row: true,
notes_accent: true,
mouse: true,
scroll_region: true,
sidebar_width: None,
right_align_numbers: true,
number_format: NumberFormatConfig::default(),
}
}
}
impl Default for PerformanceConfig {
fn default() -> Self {
Self {
pages_ahead: 3,
pages_behind: 3,
max_buffered_rows: crate::table::DEFAULT_MAX_BUFFERED_ROWS,
max_buffered: ByteSize::mib(512),
streaming: true,
threads: 0,
}
}
}
impl Default for ColorConfig {
fn default() -> Self {
Self::dark()
}
}
impl ColorConfig {
pub fn for_mode(mode: ThemeMode) -> Self {
match mode.resolve() {
ThemeMode::Light => Self::light(),
_ => Self::dark(),
}
}
pub fn dark() -> Self {
Self {
chip_key: "#7dcfff".to_string(),
chip_label: "#a9b1d6".to_string(),
throbber: "#7dcfff".to_string(),
success: "#9ece6a".to_string(),
error: "#f7768e".to_string(),
warning: "#e0af68".to_string(),
dimmed: "#565f89".to_string(),
background: "default".to_string(),
surface: "default".to_string(),
controls_bg: "#262a3f".to_string(),
text_primary: "default".to_string(),
text_secondary: "#737aa2".to_string(),
text_inverse: "#1a1b26".to_string(),
table_header: "#c0caf5".to_string(),
table_header_bg: "#2b3047".to_string(),
table_row_numbers: "#565f89".to_string(),
table_column_separator: "#3b4261".to_string(),
table_selected: "#283457".to_string(),
table_column_cursor: "#292e42".to_string(),
table_cell_cursor: "#3b4261".to_string(),
sidebar_border: "#565f89".to_string(),
modal_border_active: "#7dcfff".to_string(),
modal_border_error: "#f7768e".to_string(),
distribution_normal: "#9ece6a".to_string(),
distribution_skewed: "#e0af68".to_string(),
distribution_other: "#c0caf5".to_string(),
outlier_marker: "#f7768e".to_string(),
input_cursor: "default".to_string(),
input_cursor_text: "default".to_string(),
table_alternate_row: "#1e2030".to_string(),
type_str: "#9ece6a".to_string(),
type_int: "#7aa2f7".to_string(),
type_float: "#2ac3de".to_string(),
type_bool: "#e0af68".to_string(),
type_temporal: "#bb9af7".to_string(),
type_binary: "#565f89".to_string(),
chart_1: "#7dcfff".to_string(),
chart_2: "#bb9af7".to_string(),
chart_3: "#9ece6a".to_string(),
chart_4: "#e0af68".to_string(),
chart_5: "#7aa2f7".to_string(),
chart_6: "#f7768e".to_string(),
chart_7: "#ff9e64".to_string(),
chart_8: "#1abc9c".to_string(),
chart_9: "#ff5fd2".to_string(),
chart_10: "#f4ef8a".to_string(),
chart_grid: "#3d4785".to_string(),
accent: "#7dcfff".to_string(),
accent_bright: "#a4daff".to_string(),
gradient_start: "#7aa2f7".to_string(),
gradient_end: "#bb9af7".to_string(),
find_match: "#e0af68".to_string(),
hex_null: "#565f89".to_string(),
hex_printable: "#7dcfff".to_string(),
hex_whitespace: "#9ece6a".to_string(),
hex_control: "#bb9af7".to_string(),
hex_high: "#e0af68".to_string(),
hex_ff: "#f7768e".to_string(),
}
}
pub fn light() -> Self {
Self {
chip_key: "#2e7de9".to_string(),
chip_label: "#3760bf".to_string(),
throbber: "#2e7de9".to_string(),
success: "#587539".to_string(),
error: "#f52a65".to_string(),
warning: "#8c6c3e".to_string(),
dimmed: "#848cb5".to_string(),
background: "default".to_string(),
surface: "default".to_string(),
controls_bg: "#d0d5e3".to_string(),
text_primary: "default".to_string(),
text_secondary: "#6172b0".to_string(),
text_inverse: "#e1e2e7".to_string(),
table_header: "#3760bf".to_string(),
table_header_bg: "#c4c8da".to_string(),
table_row_numbers: "#848cb5".to_string(),
table_column_separator: "#a8aecb".to_string(),
table_selected: "#b6bfe2".to_string(),
table_column_cursor: "#cbd3f2".to_string(),
table_cell_cursor: "#a0aef0".to_string(),
sidebar_border: "#6172b0".to_string(),
modal_border_active: "#2e7de9".to_string(),
modal_border_error: "#f52a65".to_string(),
distribution_normal: "#587539".to_string(),
distribution_skewed: "#8c6c3e".to_string(),
distribution_other: "#3760bf".to_string(),
outlier_marker: "#f52a65".to_string(),
input_cursor: "default".to_string(),
input_cursor_text: "default".to_string(),
table_alternate_row: "#dcdfea".to_string(),
type_str: "#587539".to_string(),
type_int: "#2e7de9".to_string(),
type_float: "#007197".to_string(),
type_bool: "#8c6c3e".to_string(),
type_temporal: "#9854f1".to_string(),
type_binary: "#848cb5".to_string(),
chart_1: "#2e7de9".to_string(),
chart_2: "#9854f1".to_string(),
chart_3: "#587539".to_string(),
chart_4: "#8c6c3e".to_string(),
chart_5: "#007197".to_string(),
chart_6: "#f52a65".to_string(),
chart_7: "#b15c00".to_string(),
chart_8: "#118c74".to_string(),
chart_9: "#d1188c".to_string(),
chart_10: "#24357a".to_string(),
chart_grid: "#70aabf".to_string(),
accent: "#2e7de9".to_string(),
accent_bright: "#1a6cd0".to_string(),
gradient_start: "#2e7de9".to_string(),
gradient_end: "#9854f1".to_string(),
find_match: "#f0c35a".to_string(),
hex_null: "#848cb5".to_string(),
hex_printable: "#007197".to_string(),
hex_whitespace: "#587539".to_string(),
hex_control: "#9854f1".to_string(),
hex_high: "#8c6c3e".to_string(),
hex_ff: "#f52a65".to_string(),
}
}
}
impl Default for QueryConfig {
fn default() -> Self {
Self {
history_limit: 1000,
history: true,
default_mode: QueryMode::default(),
}
}
}
const MAX_IMPORT_DEPTH: usize = 8;
pub fn expand_config_path(raw: &str) -> PathBuf {
expand_path(raw)
}
pub fn expand_home(path: &Path) -> PathBuf {
expand_home_unless(path, |p| p.symlink_metadata().is_ok())
}
fn expand_home_unless(path: &Path, there: impl FnOnce(&Path) -> bool) -> PathBuf {
path.to_str()
.and_then(home_path)
.filter(|_| !there(path))
.unwrap_or_else(|| path.to_path_buf())
}
fn home_path(text: &str) -> Option<PathBuf> {
if text == "~" {
return dirs::home_dir();
}
let rest = text
.strip_prefix("~/")
.or_else(|| text.strip_prefix("~\\").filter(|_| cfg!(windows)))?;
dirs::home_dir().map(|home| home.join(rest))
}
pub(crate) fn path_place(path: &Path) -> PathBuf {
use std::path::{Component, Prefix};
let mut place = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Prefix(prefix) => match prefix.kind() {
Prefix::Disk(drive) => {
place.push(format!("{}:", char::from(drive.to_ascii_uppercase())));
}
Prefix::UNC(server, share) => {
let mut unc = std::ffi::OsString::from(r"\\");
unc.push(server);
unc.push(r"\");
unc.push(share);
place.push(unc);
}
_ => place.push(prefix.as_os_str()),
},
other => place.push(other),
}
}
place
}
pub(crate) fn expand_path(raw: &str) -> PathBuf {
let mut expanded = String::with_capacity(raw.len());
let mut chars = raw.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' {
expanded.push(c);
continue;
}
let braced = chars.peek() == Some(&'{');
if braced {
chars.next();
}
let mut name = String::new();
while let Some(&next) = chars.peek() {
if braced && next == '}' {
chars.next();
break;
}
if !next.is_ascii_alphanumeric() && next != '_' {
break;
}
name.push(next);
chars.next();
}
if name.is_empty() {
expanded.push('$');
} else if let Ok(value) = std::env::var(&name) {
expanded.push_str(&value);
}
}
home_path(&expanded).unwrap_or_else(|| PathBuf::from(expanded))
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ConfigLayer {
table: toml::Table,
imports: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LayerSource {
File(PathBuf),
Override,
}
impl std::fmt::Display for LayerSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::File(path) => write!(f, "{}", path.display()),
Self::Override => f.write_str("-c"),
}
}
}
#[derive(Debug, Clone, Copy)]
enum Combine {
ByName,
Union,
}
const COMBINED_KEYS: &[(&str, Combine)] = &[
("formats.path", Combine::Union),
("catalogs", Combine::Union),
("cloud.connections", Combine::ByName),
("cloud.hide", Combine::Union),
("cloud.env_files", Combine::Union),
("home.hide", Combine::Union),
];
pub fn way_out(imported: bool, what: &str) -> String {
if imported {
format!("Fix that {what}, or move the file aside: a missing import is skipped.")
} else {
format!(
"Fix that {what}, or move the file aside to start from the defaults; \
`datui config init` then writes a fresh one."
)
}
}
impl ConfigLayer {
pub fn parse(text: &str) -> Result<Self> {
let typed: AppConfig = toml::from_str(text)?;
let table: toml::Table = toml::from_str(text)?;
Ok(Self::from_table(table, typed.import))
}
pub fn from_overrides(overrides: &[datui_cli::settings::Override]) -> Result<Self> {
let mut table = toml::Table::new();
for o in overrides {
let mut at = &mut table;
let mut parts: Vec<&str> = o.key.split('.').collect();
let last = parts.pop().unwrap_or_default();
for part in parts {
let entry = at
.entry(part.to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
if !entry.is_table() {
*entry = toml::Value::Table(toml::Table::new());
}
at = entry.as_table_mut().expect("just made a table");
}
at.insert(last.to_string(), o.value.clone());
}
toml::Value::Table(table.clone())
.try_into::<AppConfig>()
.map_err(|e| eyre!("-c: {}", e.message().trim_end()))?;
Ok(Self::from_table(table, Vec::new()))
}
fn from_table(mut table: toml::Table, imports: Vec<String>) -> Self {
table.remove("import");
Self { table, imports }
}
fn read(path: &Path, importer: Option<&Path>) -> Result<Option<Self>> {
let named = match importer {
Some(importer) => format!("{} (imported by {})", path.display(), importer.display()),
None => path.display().to_string(),
};
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(eyre!("Failed to read config file at {named}: {e}")),
};
let mut layer = Self::parse(&content).map_err(|e| {
eyre!(
"Failed to parse config file at {named}: {}\n{}",
parse_reason(&e),
way_out(importer.is_some(), "line")
)
})?;
for unknown in unknown_keys_in(&layer.table) {
eprintln!("datui: warning: {}: {unknown}", path.display());
}
layer.anchor_paths(path.parent().unwrap_or_else(|| Path::new(".")));
Ok(Some(layer))
}
fn anchor_paths(&mut self, dir: &Path) {
if let Some(toml::Value::Array(entries)) = self
.table
.get_mut("formats")
.and_then(|f| f.get_mut("path"))
{
for entry in entries {
if let toml::Value::String(path) = entry
&& !path.trim().is_empty()
&& expand_path(path).is_relative()
{
*path = dir.join(expand_path(path)).to_string_lossy().into_owned();
}
}
}
if let Some(toml::Value::Array(files)) = self.table.get_mut("catalogs") {
for entry in files {
let path = match entry {
toml::Value::Table(table) => table.get_mut("path"),
other => Some(other),
};
if let Some(toml::Value::String(path)) = path
&& !path.trim().is_empty()
&& expand_path(path).is_relative()
{
*path = dir.join(expand_path(path)).to_string_lossy().into_owned();
}
}
}
}
pub fn get(&self, key: &str) -> Option<&toml::Value> {
let mut parts = key.split('.');
let mut value = self.table.get(parts.next()?)?;
for part in parts {
value = value.as_table()?.get(part)?;
}
Some(value)
}
pub fn merge(&mut self, upper: ConfigLayer) {
merge_tables(&mut self.table, upper.table, "");
}
}
const RETIRED_KEYS: &[(&str, &str)] = &[
(
"sources",
"a collection is a catalog file now; put its datasets in catalog.toml as [id] \
tables, or list the file in catalogs = [...] (datui catalog check FILE)",
),
(
"home.directories",
"a directory is a catalog entry now; Ctrl+D on its row adds it to catalog.toml",
),
(
"home.builtin_catalog",
"home.hide = [\"examples\"] hides the example datasets",
),
];
fn unknown_keys_in(table: &toml::Table) -> Vec<String> {
fn walk(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
for (key, value) in table {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
if datui_cli::settings::find(&path).is_some() {
continue;
}
if let Some((_, moved)) = RETIRED_KEYS.iter().find(|(key, _)| *key == path) {
out.push(format!("{path} is not read any more: {moved}"));
continue;
}
match value {
toml::Value::Table(inner) => walk(inner, &path, out),
_ => {
let near = datui_cli::settings::suggestions(&path);
let mut said = format!("{path} is not a config key, and is not read");
if !near.is_empty() {
said.push_str(&format!("; did you mean {}?", near.join(" or ")));
}
out.push(said);
}
}
}
}
let mut out = Vec::new();
walk(table, "", &mut out);
out.sort();
out
}
fn parse_reason(error: &color_eyre::eyre::Report) -> String {
let Some(toml_error) = error.downcast_ref::<toml::de::Error>() else {
return error.to_string();
};
let message = toml_error.message().trim_end();
let full = toml_error.to_string();
let body = full.trim_end().strip_suffix(message).unwrap_or(&full);
match body.split_once('\n') {
Some((head, excerpt)) if head.starts_with("TOML parse error at ") => format!(
"{message} ({})\n{}",
head.trim_start_matches("TOML parse error at "),
excerpt.trim_end()
),
_ => message.to_string(),
}
}
fn merge_tables(lower: &mut toml::Table, upper: toml::Table, prefix: &str) {
for (key, value) in upper {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
let combine = COMBINED_KEYS
.iter()
.find(|(p, _)| *p == path)
.map(|(_, c)| *c);
match (combine, value) {
(Some(combine), toml::Value::Array(upper)) => {
let mut combined = match lower.remove(&key) {
Some(toml::Value::Array(lower)) => lower,
_ => Vec::new(),
};
match combine {
Combine::ByName => merge_by_name(&mut combined, upper),
Combine::Union => {
for item in upper {
if !combined.contains(&item) {
combined.push(item);
}
}
}
}
lower.insert(key, toml::Value::Array(combined));
}
(None, toml::Value::Table(upper)) => match lower.get_mut(&key) {
Some(toml::Value::Table(lower)) => merge_tables(lower, upper, &path),
_ => {
lower.insert(key, toml::Value::Table(upper));
}
},
(_, value) => {
lower.insert(key, value);
}
}
}
}
fn merge_by_name(lower: &mut Vec<toml::Value>, upper: Vec<toml::Value>) {
let name_of = |entry: &toml::Value| {
entry
.get("name")
.and_then(toml::Value::as_str)
.map(str::to_owned)
};
let mut seen = std::collections::HashSet::new();
for entry in upper {
let name = name_of(&entry);
let first = name.clone().is_some_and(|n| seen.insert(n));
match lower
.iter()
.position(|e| name.is_some() && name_of(e) == name)
{
Some(i) if first => lower[i] = entry,
_ => lower.push(entry),
}
}
}
impl AppConfig {
pub fn load_with(app_name: &str, overrides: &[datui_cli::settings::Override]) -> Result<Self> {
match ConfigManager::new(app_name) {
Ok(manager) => {
Self::load_from_file_with(&manager.config_path("config.toml"), overrides)
}
Err(_) => {
let layers = vec![ConfigLayer::from_overrides(overrides)?];
let mut config =
Self::from_layers(layers).map_err(|e| eyre!("Invalid configuration: {}", e))?;
config.read_theme_files(None)?;
config
.validate()
.map_err(|e| eyre!("Invalid configuration: {}", e))?;
Ok(config)
}
}
}
pub fn load_from_file(config_path: &Path) -> Result<Self> {
Self::load_from_file_with(config_path, &[])
}
pub fn load_from_file_with(
config_path: &Path,
overrides: &[datui_cli::settings::Override],
) -> Result<Self> {
let layers = Self::read_layers(config_path, overrides)?;
Self::from_read_layers(config_path, overrides, &layers)
}
pub fn read_layers(
config_path: &Path,
overrides: &[datui_cli::settings::Override],
) -> Result<Vec<(LayerSource, ConfigLayer)>> {
let mut layers = Vec::new();
if let Some(root) = ConfigLayer::read(config_path, None)? {
let canonical = crate::canonical::canonicalize(config_path)
.unwrap_or_else(|_| config_path.to_path_buf());
let mut stack = vec![canonical];
Self::collect_imports(&root.imports, config_path, &mut stack, &mut layers)?;
layers.push((LayerSource::File(config_path.to_path_buf()), root));
}
if !overrides.is_empty() {
layers.push((
LayerSource::Override,
ConfigLayer::from_overrides(overrides)?,
));
}
Ok(layers)
}
pub fn from_read_layers(
config_path: &Path,
overrides: &[datui_cli::settings::Override],
layers: &[(LayerSource, ConfigLayer)],
) -> Result<Self> {
let place = if overrides.is_empty() {
config_path.display().to_string()
} else {
format!("{} with -c", config_path.display())
};
let imports = layers
.iter()
.find(|(source, _)| *source == LayerSource::File(config_path.to_path_buf()))
.map(|(_, root)| root.imports.clone())
.unwrap_or_default();
let mut config = Self::from_layers(layers.iter().map(|(_, layer)| layer.clone()))
.map_err(|e| eyre!("Invalid configuration in {place}: {e}"))?;
config.import = imports;
config.read_theme_files(config_path.parent())?;
config.read_catalog_files(config_path.parent())?;
for broken in &config.broken_catalogs {
eprintln!("datui: warning: catalog left out: {}", broken.full());
log::warn!(target: "datui", "catalog left out: {}", broken.full());
}
for name in config.unknown_hidden() {
eprintln!("datui: warning: home.hide: {}", Self::hides_nothing(&name));
}
config.validate().map_err(|e| {
eyre!(
"Invalid configuration in {place}: {e}\n{}",
way_out(false, "setting")
)
})?;
Ok(config)
}
fn collect_imports(
imports: &[String],
origin: &Path,
stack: &mut Vec<PathBuf>,
out: &mut Vec<(LayerSource, ConfigLayer)>,
) -> Result<()> {
if imports.is_empty() {
return Ok(());
}
if stack.len() >= MAX_IMPORT_DEPTH {
return Err(eyre!(
"config import chain is more than {} files deep (at {}); \
flatten the chain or remove the extra levels",
MAX_IMPORT_DEPTH,
origin.display()
));
}
let origin_dir = origin.parent().unwrap_or_else(|| Path::new("."));
for entry in imports {
let expanded = expand_path(entry);
let path = if expanded.is_absolute() {
expanded
} else {
origin_dir.join(expanded)
};
let canonical = crate::canonical::canonicalize(&path).unwrap_or_else(|_| path.clone());
if stack.contains(&canonical) {
return Err(eyre!(
"circular config import: {} is already being loaded (imported by {})",
canonical.display(),
origin.display()
));
}
let Some(layer) = ConfigLayer::read(&path, Some(origin))? else {
eprintln!(
"datui: warning: config import not found, skipping: {} (imported by {})",
path.display(),
origin.display()
);
continue;
};
stack.push(canonical);
Self::collect_imports(&layer.imports, &path, stack, out)?;
stack.pop();
out.push((LayerSource::File(path), layer));
}
Ok(())
}
pub fn from_layers(layers: impl IntoIterator<Item = ConfigLayer>) -> Result<Self> {
let mut merged = ConfigLayer::default();
for layer in layers {
merged.merge(layer);
}
let mut table = merged.table;
let theme = table.get_mut("theme").and_then(toml::Value::as_table_mut);
let mode: ThemeMode = match theme.as_ref().and_then(|t| t.get("mode")) {
Some(mode) => mode.clone().try_into()?,
None => ThemeMode::default(),
};
let resolved = mode.resolve();
let colors = theme.and_then(|t| t.remove("colors"));
let mut config: AppConfig = toml::Value::Table(table).try_into()?;
if let Some(toml::Value::Table(colors)) = colors {
config.theme.overrides = colors;
}
config.theme.follow = mode == ThemeMode::Auto;
config.theme.mode = Some(resolved);
config
.theme
.use_library(crate::config::themes::Library::default(), resolved);
config.theme.colors = config.theme.palette_for(resolved)?;
config.sync_dataset_access();
Ok(config)
}
pub fn read_theme_files(&mut self, config_dir: Option<&Path>) -> Result<()> {
let library = crate::config::themes::Library::read(config_dir);
let active = self.theme.mode.unwrap_or_default().resolve();
self.theme.use_library(library, active);
for warning in self.theme.warnings() {
eprintln!("datui: {warning}");
log::warn!(target: "datui", "{warning}");
}
self.theme.colors = self.theme.palette_for(active)?;
Ok(())
}
pub fn catalogs(&self) -> Vec<crate::home::catalog::Catalog> {
let mut all = self.read_catalogs.clone();
if !all.iter().any(|c| c.id == crate::home::catalog::EXAMPLES) {
all.push(crate::home::catalog::bundled());
}
all
}
pub fn shown_catalogs(&self) -> Vec<crate::home::catalog::Catalog> {
self.catalogs()
.into_iter()
.filter(|c| !self.home.hide.contains(&c.id))
.map(|mut c| {
c.datasets.retain(|d| {
!self
.home
.hide
.iter()
.any(|h| h.split_once('/') == Some((c.id.as_str(), d.id.as_str())))
});
c
})
.collect()
}
pub fn hides_nothing(name: &str) -> String {
let old = crate::home::catalog::OLD_EXAMPLES_ID;
let renamed = match name.split_once('/') {
None if name == old => Some(crate::home::catalog::EXAMPLES.to_string()),
Some((catalog, id)) if catalog == old => {
Some(format!("{}/{id}", crate::home::catalog::EXAMPLES))
}
_ => None,
};
match renamed {
Some(new) => format!("`{name}` is now `{new}`: hide = [\"{new}\"]"),
None => format!("no catalog or entry is named {name}"),
}
}
pub fn unknown_hidden(&self) -> Vec<String> {
let catalogs = self.catalogs();
let mut out: Vec<String> = Vec::new();
for name in &self.home.hide {
let known = match name.split_once('/') {
None => {
catalogs.iter().any(|c| c.id == *name)
|| self.broken_catalogs.iter().any(|b| b.id == *name)
}
Some((catalog, _)) if self.broken_catalogs.iter().any(|b| b.id == catalog) => true,
Some((catalog, id)) => catalogs
.iter()
.any(|c| c.id == catalog && c.datasets.iter().any(|d| d.id == id)),
};
if !known && !out.contains(name) {
out.push(name.clone());
}
}
out
}
pub fn read_catalog_files(&mut self, config_dir: Option<&Path>) -> Result<()> {
use crate::home::catalog::{self, Origin};
let mut read: Vec<catalog::Catalog> = Vec::new();
let mut broken: Vec<catalog::Broken> = Vec::new();
let connections = self.cloud.connections.clone();
let take = |found: std::result::Result<Option<catalog::Catalog>, catalog::Broken>,
read: &mut Vec<catalog::Catalog>,
broken: &mut Vec<catalog::Broken>|
-> bool {
match found {
Ok(Some(c)) => match c.check_connections(&connections) {
Ok(()) => {
read.push(c);
true
}
Err(e) => {
broken.push(catalog::Broken {
id: c.id.clone(),
origin: c.origin,
file: c.file.clone().unwrap_or_default(),
line: e.line,
message: e.message,
});
true
}
},
Ok(None) => false,
Err(b) => {
broken.push(b);
true
}
}
};
let mut files: Vec<(PathBuf, Origin, Option<String>, Option<String>)> = Vec::new();
if let Some(dir) = config_dir {
take(
catalog::load(&dir.join(catalog::MINE_FILE), catalog::MINE, Origin::Mine),
&mut read,
&mut broken,
);
let folder = dir.join(catalog::FOLDER);
let mut found: Vec<PathBuf> = match std::fs::read_dir(&folder) {
Ok(entries) => entries
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| {
p.extension().is_some_and(|x| x == "toml")
&& std::fs::metadata(p).is_ok_and(|m| m.is_file())
})
.collect(),
Err(_) => Vec::new(),
};
found.sort();
files.extend(found.into_iter().map(|p| (p, Origin::Folder, None, None)));
}
files.extend(self.catalogs.iter().map(|entry| {
(
expand_path(entry.path()),
Origin::Listed,
entry.id().map(str::to_string),
entry.label().map(str::to_string),
)
}));
for (path, origin, given_id, label) in files {
let id = given_id
.clone()
.unwrap_or_else(|| catalog::id_of_file(&path));
let refuse = |message: String| catalog::Broken {
id: id.clone(),
origin,
file: path.clone(),
line: None,
message,
};
if !is_valid_source_id(&id) || id == catalog::MINE {
broken.push(refuse(format!(
"\"{id}\" cannot be a catalog's id: lowercase letters, digits and '-', \
and not \"{}\", which is catalog.toml's. {}",
catalog::MINE,
if given_id.is_some() {
"Give another id = \"...\""
} else {
"Rename the file, or list it as { path = \"...\", id = \"...\" }"
}
)));
continue;
}
if let Some(first) = read.iter().find(|c| c.id == id) {
let first = first.file_name();
broken.push(refuse(format!(
"{first} and this file are both the catalog \"{id}\". Rename one, or \
list one as {{ path = \"...\", id = \"...\" }}"
)));
continue;
}
let found = catalog::load(&path, &id, origin).map(|found| {
found.map(|mut listed| {
if let Some(label) = &label {
listed.label = label.clone();
}
listed
})
});
if !take(found, &mut read, &mut broken) {
eprintln!(
"datui: warning: catalog not found, skipping: {}",
path.display()
);
}
}
self.read_catalogs = read;
self.broken_catalogs = broken;
self.catalog_dir = config_dir.map(Path::to_path_buf);
self.sync_dataset_access();
Ok(())
}
pub fn sync_dataset_access(&mut self) {
self.cloud.dataset_access = self
.catalogs()
.iter()
.flat_map(|catalog| {
catalog.datasets.iter().filter_map(|dataset| {
Some(DatasetAccess {
url: dataset.url.clone()?,
catalog: catalog.id.clone(),
auth: dataset.object_store_auth()?,
})
})
})
.collect();
}
pub fn validate(&self) -> Result<()> {
let rows = self.analysis.chart_rows;
if rows == 0 || rows > MAX_CHART_ROW_LIMIT {
return Err(eyre!(
"analysis.chart_rows must be between 1 and {MAX_CHART_ROW_LIMIT}, got {rows}"
));
}
self.display
.number_format
.resolve(self.display.right_align_numbers)?;
let interval = self.read.follow_interval;
if !FOLLOW_INTERVAL.contains(&interval.duration()) {
return Err(eyre!(
"read.follow_interval must be between 10ms and 1m, got {interval}"
));
}
if let Some(c) = &self.csv.comment {
crate::formats::csv_dialect::check_comment_char(c)
.map_err(|e| eyre!("csv.comment: {e}"))?;
}
if let Some(level) = &self.log.level
&& !datui_cli::LOG_LEVELS.contains(&level.as_str())
{
return Err(eyre!(
"log.level must be one of {}, got {level:?}",
datui_cli::LOG_LEVELS.join(", ")
));
}
self.cloud.validate()?;
for catalog in &self.read_catalogs {
catalog
.check_connections(&self.cloud.connections)
.map_err(|e| eyre!("{}", e.in_file(&catalog.file_name())))?;
}
let hide_name = |name: &str| match name.split_once('/') {
Some((catalog, id)) => is_valid_source_id(catalog) && is_valid_source_id(id),
None => is_valid_source_id(name),
};
if let Some(name) = self.home.hide.iter().find(|name| !hide_name(name)) {
return Err(eyre!(
"home.hide: \"{name}\" is not a catalog id or catalog/id. Use the ids (mine, \
examples, a listed file's name; examples/nyc-taxis for one entry), not the labels"
));
}
let parser = ColorParser::new();
self.theme.colors.validate(&parser)?;
crate::glyphs::validate_overrides(&self.glyphs.overrides)
.map_err(|e| eyre!("[glyphs]: {e}"))?;
if crate::clipboard::BackendChoice::parse(&self.clipboard.backend).is_none() {
return Err(eyre!(
"[clipboard] backend must be auto, native or osc52, got {:?}",
self.clipboard.backend
));
}
if self.clipboard.osc52_limit.bytes() == 0 {
return Err(eyre!("[clipboard] osc52_limit must be greater than 0"));
}
if !crate::cloud::user_agent::is_valid(&self.http.user_agent) {
return Err(eyre!(
"[http] user_agent must be printable ASCII, got {:?}",
self.http.user_agent
));
}
Ok(())
}
}
impl ColorConfig {
pub(crate) fn slots(&self) -> Vec<(String, String)> {
match toml::Value::try_from(self) {
Ok(toml::Value::Table(table)) => table
.into_iter()
.map(|(name, value)| (name, value.as_str().unwrap_or_default().to_string()))
.collect(),
_ => Vec::new(),
}
}
fn validate(&self, parser: &ColorParser) -> Result<()> {
for (name, value) in self.slots() {
if name == "table_alternate_row" && value == "default" {
continue;
}
parser.parse(&value).map_err(|e| {
eyre!(
"theme.colors.{name}: {e}. Use a valid color name (e.g. red, cyan, \
bright_red), hex (#rrggbb), or indexed(0-255)"
)
})?;
}
Ok(())
}
}
pub struct ColorParser {
supports_true_color: bool,
supports_256: bool,
no_color: bool,
}
impl ColorParser {
pub fn new() -> Self {
let no_color = std::env::var("NO_COLOR").is_ok();
let support = supports_color::on(Stream::Stdout);
#[cfg(windows)]
let console = windows_console_true_color(
std::env::var_os("TERM").is_some() || std::env::var_os("FORCE_COLOR").is_some(),
std::io::IsTerminal::is_terminal(&std::io::stdout()),
crossterm::ansi_support::supports_ansi,
);
#[cfg(not(windows))]
let console = false;
Self {
supports_true_color: console || support.as_ref().is_some_and(|s| s.has_16m),
supports_256: console || support.as_ref().is_some_and(|s| s.has_256),
no_color,
}
}
pub fn parse(&self, s: &str) -> Result<Color> {
if self.no_color {
return Ok(Color::Reset);
}
let trimmed = s.trim();
if trimmed.starts_with('#') && trimmed.len() == 7 {
let (r, g, b) = parse_hex(trimmed)?;
return Ok(self.convert_rgb_to_terminal_color(r, g, b));
}
if trimmed.to_lowercase().starts_with("indexed(") && trimmed.ends_with(')') {
let num_str = &trimmed[8..trimmed.len() - 1]; let num = num_str.parse::<u8>().map_err(|_| {
eyre!(
"Invalid indexed color: '{}'. Expected format: indexed(0-255)",
trimmed
)
})?;
return Ok(Color::Indexed(num));
}
let lower = trimmed.to_lowercase();
match lower.as_str() {
"black" => Ok(Color::Black),
"red" => Ok(Color::Red),
"green" => Ok(Color::Green),
"yellow" => Ok(Color::Yellow),
"blue" => Ok(Color::Blue),
"magenta" => Ok(Color::Magenta),
"cyan" => Ok(Color::Cyan),
"white" => Ok(Color::White),
"bright_black" | "bright black" => Ok(Color::Indexed(8)),
"bright_red" | "bright red" => Ok(Color::Indexed(9)),
"bright_green" | "bright green" => Ok(Color::Indexed(10)),
"bright_yellow" | "bright yellow" => Ok(Color::Indexed(11)),
"bright_blue" | "bright blue" => Ok(Color::Indexed(12)),
"bright_magenta" | "bright magenta" => Ok(Color::Indexed(13)),
"bright_cyan" | "bright cyan" => Ok(Color::Indexed(14)),
"bright_white" | "bright white" => Ok(Color::Indexed(15)),
"gray" | "grey" => Ok(Color::Indexed(8)),
"dark_gray" | "dark gray" | "dark_grey" | "dark grey" => Ok(Color::Indexed(8)),
"light_gray" | "light gray" | "light_grey" | "light grey" => Ok(Color::Indexed(7)),
"reset" | "default" | "none" | "reversed" => Ok(Color::Reset),
_ => Err(eyre!(
"Unknown color name: '{}'. Supported: basic ANSI colors (red, blue, etc.), \
bright variants (bright_red, etc.), or hex colors (#ff0000)",
trimmed
)),
}
}
fn convert_rgb_to_terminal_color(&self, r: u8, g: u8, b: u8) -> Color {
if self.supports_true_color {
Color::Rgb(r, g, b)
} else if self.supports_256 {
Color::Indexed(rgb_to_256_color(r, g, b))
} else {
rgb_to_basic_ansi(r, g, b)
}
}
}
#[cfg(windows)]
fn windows_console_true_color(env_says: bool, terminal: bool, vt: impl FnOnce() -> bool) -> bool {
!env_says && terminal && vt()
}
impl Default for ColorParser {
fn default() -> Self {
Self::new()
}
}
fn parse_hex(s: &str) -> Result<(u8, u8, u8)> {
let hex = s
.strip_prefix('#')
.filter(|hex| hex.len() == 6 && hex.is_ascii())
.ok_or_else(|| {
eyre!(
"Invalid hex color format: '{}'. Expected format: #rrggbb",
s
)
})?;
let r = u8::from_str_radix(&hex[0..2], 16)
.map_err(|_| eyre!("Invalid red component in hex color: {}", s))?;
let g = u8::from_str_radix(&hex[2..4], 16)
.map_err(|_| eyre!("Invalid green component in hex color: {}", s))?;
let b = u8::from_str_radix(&hex[4..6], 16)
.map_err(|_| eyre!("Invalid blue component in hex color: {}", s))?;
Ok((r, g, b))
}
pub fn rgb_to_256_color(r: u8, g: u8, b: u8) -> u8 {
let dist = |cr: i32, cg: i32, cb: i32| -> i32 {
let (dr, dg, db) = (cr - r as i32, cg - g as i32, cb - b as i32);
dr * dr + dg * dg + db * db
};
const LEVELS: [i32; 6] = [0, 95, 135, 175, 215, 255];
let mut best = (i32::MAX, 16u8);
for (ri, &cr) in LEVELS.iter().enumerate() {
for (gi, &cg) in LEVELS.iter().enumerate() {
for (bi, &cb) in LEVELS.iter().enumerate() {
let d = dist(cr, cg, cb);
if d < best.0 {
best = (d, 16 + 36 * ri as u8 + 6 * gi as u8 + bi as u8);
}
}
}
}
for i in 0..24u8 {
let v = 8 + 10 * i as i32;
let d = dist(v, v, v);
if d < best.0 {
best = (d, 232 + i);
}
}
best.1
}
pub fn rgb_to_basic_ansi(r: u8, g: u8, b: u8) -> Color {
let r_bright = r > 128;
let g_bright = g > 128;
let b_bright = b > 128;
let max_diff = r.max(g).max(b) as i16 - r.min(g).min(b) as i16;
if max_diff < 30 {
let avg = (r as u16 + g as u16 + b as u16) / 3;
return if avg < 64 { Color::Black } else { Color::White };
}
match (r_bright, g_bright, b_bright) {
(false, false, false) => Color::Black,
(true, false, false) => Color::Red,
(false, true, false) => Color::Green,
(true, true, false) => Color::Yellow,
(false, false, true) => Color::Blue,
(true, false, true) => Color::Magenta,
(false, true, true) => Color::Cyan,
(true, true, true) => Color::White,
}
}
#[derive(Debug, Clone)]
pub struct Theme {
pub colors: HashMap<String, Color>,
}
pub const CHART_SERIES_SLOTS: usize = 10;
macro_rules! color_slots {
($($slot:ident),* $(,)?) => {
impl Theme {
$(
pub fn $slot(&self) -> Color {
self.get(stringify!($slot))
}
)*
}
#[cfg(test)]
pub(crate) const COLOR_SLOTS: &[&str] = &[$(stringify!($slot)),*];
};
}
color_slots!(
accent,
accent_bright,
background,
chart_1,
chart_grid,
chip_key,
chip_label,
controls_bg,
dimmed,
distribution_normal,
distribution_skewed,
error,
find_match,
gradient_end,
gradient_start,
hex_control,
hex_ff,
hex_high,
hex_null,
hex_printable,
hex_whitespace,
input_cursor,
label,
modal_border,
modal_border_active,
modal_border_error,
outlier_marker,
sidebar_border,
success,
surface,
table_column_separator,
table_header,
table_header_bg,
table_row_numbers,
text_inverse,
text_primary,
text_secondary,
throbber,
type_binary,
type_bool,
type_float,
type_int,
type_str,
type_temporal,
warning,
);
impl Theme {
pub fn from_config(config: &ThemeConfig) -> Result<Self> {
let parser = ColorParser::new();
let mut colors = HashMap::new();
for (name, value) in config.colors.slots() {
let absent = match name.as_str() {
"table_selected" => value.trim().eq_ignore_ascii_case("reversed"),
"table_alternate_row" => value == "default",
_ => false,
};
if !absent {
colors.insert(name, parser.parse(&value)?);
}
}
colors.insert("modal_border".to_string(), colors["sidebar_border"]);
colors.insert("label".to_string(), colors["text_secondary"]);
Ok(Self { colors })
}
fn get(&self, name: &str) -> Color {
self.colors.get(name).copied().unwrap_or(Color::Reset)
}
pub fn get_optional(&self, name: &str) -> Option<Color> {
self.colors.get(name).copied()
}
pub fn series_colors(&self) -> Vec<Color> {
let mut colors: Vec<Color> = Vec::with_capacity(CHART_SERIES_SLOTS);
for i in 1..=CHART_SERIES_SLOTS {
let color = self.get(&format!("chart_{i}"));
if !colors.contains(&color) {
colors.push(color);
}
}
colors
}
pub fn highlight_style(&self) -> ratatui::style::Style {
match self.get_optional("table_selected") {
Some(bg) => ratatui::style::Style::default().bg(bg),
None => {
ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
}
}
}
pub fn column_cursor_style(&self) -> ratatui::style::Style {
column_cursor_style(self.get_optional("table_column_cursor"))
}
pub fn cell_cursor_style(&self) -> ratatui::style::Style {
cell_cursor_style(self.get_optional("table_cell_cursor"))
}
pub fn text_selection_style(&self) -> ratatui::style::Style {
match self.get_optional("table_selected") {
Some(Color::Reset | Color::Black | Color::White) => {
ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED)
}
_ => self.highlight_style(),
}
}
pub fn find_match_style(&self) -> ratatui::style::Style {
use ratatui::style::{Modifier, Style};
match self.get("find_match") {
Color::Reset => Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
bg => Style::default().bg(bg).fg(contrasting_text(bg)),
}
}
pub fn cursor_text_for(&self, cursor: Color) -> Color {
match self.get("input_cursor_text") {
Color::Reset => contrasting_text(cursor),
configured => configured,
}
}
}
pub fn tint_shows(tint: Option<Color>) -> Option<Color> {
tint.filter(|c| !matches!(c, Color::Reset | Color::Black | Color::White))
}
pub fn column_cursor_style(tint: Option<Color>) -> ratatui::style::Style {
match tint_shows(tint) {
Some(bg) => ratatui::style::Style::default().bg(bg),
None => ratatui::style::Style::default(),
}
}
pub fn cell_cursor_style(tint: Option<Color>) -> ratatui::style::Style {
use ratatui::style::{Modifier, Style};
match tint_shows(tint) {
Some(bg) => Style::default().bg(bg).add_modifier(Modifier::BOLD),
None => Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
}
}
fn contrasting_text(bg: Color) -> Color {
let (r, g, b) = approx_rgb(bg);
let luma = 299 * r as u32 + 587 * g as u32 + 114 * b as u32;
if luma >= 128_000 {
Color::Black
} else {
Color::White
}
}
fn approx_rgb(color: Color) -> (u8, u8, u8) {
match color {
Color::Rgb(r, g, b) => (r, g, b),
Color::Indexed(i) => xterm_rgb(i),
Color::Black => (0, 0, 0),
Color::Red => (205, 0, 0),
Color::Green => (0, 205, 0),
Color::Yellow => (205, 205, 0),
Color::Blue => (0, 0, 238),
Color::Magenta => (205, 0, 205),
Color::Cyan => (0, 205, 205),
Color::Gray => (229, 229, 229),
Color::DarkGray => (127, 127, 127),
Color::LightRed => (255, 0, 0),
Color::LightGreen => (0, 255, 0),
Color::LightYellow => (255, 255, 0),
Color::LightBlue => (92, 92, 255),
Color::LightMagenta => (255, 0, 255),
Color::LightCyan => (0, 255, 255),
Color::White => (255, 255, 255),
Color::Reset => (0, 0, 0),
}
}
fn xterm_rgb(i: u8) -> (u8, u8, u8) {
match i {
0..=15 => approx_rgb(match i {
0 => Color::Black,
1 => Color::Red,
2 => Color::Green,
3 => Color::Yellow,
4 => Color::Blue,
5 => Color::Magenta,
6 => Color::Cyan,
7 => Color::Gray,
8 => Color::DarkGray,
9 => Color::LightRed,
10 => Color::LightGreen,
11 => Color::LightYellow,
12 => Color::LightBlue,
13 => Color::LightMagenta,
14 => Color::LightCyan,
_ => Color::White,
}),
16..=231 => {
let level = |n: u8| if n == 0 { 0 } else { 55 + 40 * n };
let c = i - 16;
(level(c / 36), level(c / 6 % 6), level(c % 6))
}
232..=255 => {
let v = 8 + 10 * (i - 232);
(v, v, v)
}
}
}
fn wrap(text: &str, width: usize) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
for word in text.split_whitespace() {
match lines.last_mut() {
Some(line) if line.len() + 1 + word.len() <= width => {
line.push(' ');
line.push_str(word);
}
_ => lines.push(word.to_string()),
}
}
lines
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
#[test]
fn a_command_line_path_expands_only_a_leading_tilde() {
let home = dirs::home_dir().expect("a home directory");
let expand = |p: &str| super::expand_home(Path::new(p));
assert_eq!(expand("~"), home);
assert_eq!(expand("~/data/a.csv"), home.join("data/a.csv"));
for kept in [
"a/~/b.csv",
"~user/a.csv",
"$HOME/a.csv",
"-",
"s3://b/~/a.csv",
] {
assert_eq!(expand(kept), PathBuf::from(kept), "{kept}");
}
#[cfg(windows)]
assert_eq!(expand(r"~\data\a.csv"), home.join(r"data\a.csv"));
#[cfg(not(windows))]
assert_eq!(expand(r"~\a.csv"), PathBuf::from(r"~\a.csv"));
for there in ["~", "~/a.csv"] {
let kept = super::expand_home_unless(Path::new(there), |_| true);
assert_eq!(kept, PathBuf::from(there), "{there}");
}
}
#[test]
fn no_color_parses_every_color_as_reset() {
use ratatui::style::Color;
let parser = super::ColorParser {
supports_true_color: true,
supports_256: true,
no_color: true,
};
for name in ["red", "#ff0000", "cyan", "indexed(240)"] {
assert_eq!(parser.parse(name).unwrap(), Color::Reset, "{name}");
}
}
#[cfg(windows)]
#[test]
fn a_windows_console_with_vt_takes_true_color() {
use super::windows_console_true_color as rule;
assert!(rule(false, true, || true));
assert!(!rule(false, true, || false), "a legacy console");
assert!(
!rule(true, true, || true),
"TERM or FORCE_COLOR set: supports_color decides"
);
assert!(!rule(false, false, || true), "not a terminal");
}
#[test]
fn a_path_place_ignores_spelling_but_not_meaning() {
let place = |p: &str| super::path_place(std::path::Path::new(p));
for (a, b) in [
("/d/a.csv", "/d//a.csv"),
("/d/a.csv", "/d/./a.csv"),
("/d/sub", "/d/sub/"),
("a.csv", "./a.csv"),
] {
assert_eq!(place(a), place(b), "{a} and {b}");
}
for (a, b) in [
("/d/../a.csv", "/a.csv"),
("/d/a.csv", "/d/A.csv"),
("/d/a.csv", "d/a.csv"),
("/d/a.csv", "/d/a.csv.gz"),
] {
assert_ne!(place(a), place(b), "{a} and {b}");
}
#[cfg(windows)]
{
for (a, b) in [
(r"C:\d\a.csv", r"c:\d\a.csv"),
(r"C:\d\a.csv", "C:/d/a.csv"),
(r"\\srv\share\a.csv", "//srv/share/a.csv"),
] {
assert_eq!(place(a), place(b), "{a} and {b}");
}
for (a, b) in [
(r"C:\d\a.csv", r"D:\d\a.csv"),
(r"C:\a.csv", "C:a.csv"),
(r"\\srv\share\a.csv", r"\\srv\other\a.csv"),
] {
assert_ne!(place(a), place(b), "{a} and {b}");
}
}
}
use super::*;
#[test]
fn every_color_accessor_names_a_slot() {
let theme = Theme::from_config(&AppConfig::default().theme).unwrap();
for slot in COLOR_SLOTS {
assert!(theme.colors.contains_key(*slot), "no {slot} slot");
}
}
#[test]
fn a_key_the_registry_does_not_know_is_named_with_the_nearest() {
let found = |text: &str| unknown_keys_in(&toml::from_str(text).unwrap());
let unknown = found(
"[file_loading]\ncomment_char = \"#\"\n[display]\nmouse = false\nrow_numbr = true\n\
number_format = { grouping = \"thousands\" }\n[glyphs]\nspinner = [\"a\"]\n\
[theme.colors]\naccent = \"red\"\n[[cloud.connections]]\nname = \"x\"\n\
[[sources]]\nname = \"x\"\n[home]\ndirectories = [\"/d\"]\n",
);
assert_eq!(unknown.len(), 4, "{unknown:?}");
assert!(
unknown[2].starts_with("home.directories is not read any more")
&& unknown[2].contains("Ctrl+D"),
"{unknown:?}"
);
assert!(
unknown[3].starts_with("sources is not read any more")
&& unknown[3].contains("catalog.toml"),
"{unknown:?}"
);
assert!(
unknown[0].starts_with("display.row_numbr")
&& unknown[0].contains("display.row_numbers")
);
assert!(
unknown[1].starts_with("file_loading.comment_char")
&& unknown[1].contains("csv.comment")
);
}
#[test]
fn a_field_selection_stays_visible_when_the_tint_degrades() {
use ratatui::style::{Modifier, Style};
let theme_with = |tint: Option<Color>| Theme {
colors: tint
.map(|c| HashMap::from([("table_selected".to_string(), c)]))
.unwrap_or_default(),
};
let reversed = Style::default().add_modifier(Modifier::REVERSED);
for tint in [Color::Black, Color::White, Color::Reset] {
assert_eq!(
theme_with(Some(tint)).text_selection_style(),
reversed,
"{tint:?}"
);
}
assert_eq!(theme_with(None).text_selection_style(), reversed);
for tint in [
Color::Rgb(0x28, 0x34, 0x57),
Color::Indexed(237),
Color::Blue,
] {
assert_eq!(
theme_with(Some(tint)).text_selection_style(),
Style::default().bg(tint)
);
}
}
fn leaf_settings() -> Vec<(String, toml::Value)> {
fn walk(table: &toml::Table, prefix: &str, out: &mut Vec<(String, toml::Value)>) {
for (key, value) in table {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
match value {
toml::Value::Table(inner) => walk(inner, &path, out),
toml::Value::Array(items) if items.iter().any(toml::Value::is_table) => {}
_ => out.push((path, value.clone())),
}
}
}
let toml::Value::Table(defaults) = toml::Value::try_from(AppConfig::default()).unwrap()
else {
unreachable!("a struct serializes to a table")
};
let mut out = Vec::new();
walk(&defaults, "", &mut out);
for setting in datui_cli::settings::SETTINGS {
if let datui_cli::settings::DefaultValue::Unset(example) = setting.default
&& !setting.key.ends_with(".*")
&& setting.kind != datui_cli::settings::Kind::Tables
{
let value: toml::Table = toml::from_str(&format!("v = {example}")).unwrap();
out.push((setting.key.to_string(), value["v"].clone()));
}
}
out
}
fn layer_at(path: &str, value: toml::Value) -> Result<ConfigLayer> {
let table = path.rsplit('.').fold(value, |inner, key| {
toml::Value::Table(toml::Table::from_iter([(key.to_string(), inner)]))
});
ConfigLayer::parse(&toml::to_string(&table)?)
}
fn value_at<'a>(table: &'a toml::Table, path: &str) -> Option<&'a toml::Value> {
let (parents, key) = path.rsplit_once('.').unwrap_or(("", path));
let mut table = table;
for part in parents.split('.').filter(|p| !p.is_empty()) {
table = table.get(part)?.as_table()?;
}
table.get(key)
}
#[test]
fn the_registry_and_the_config_structs_agree() {
use datui_cli::settings::{DefaultValue, Kind, SETTINGS, find};
let toml::Value::Table(defaults) = toml::Value::try_from(AppConfig::default()).unwrap()
else {
unreachable!("a struct serializes to a table")
};
let light = toml::Value::try_from(ColorConfig::light()).unwrap();
for (path, value) in leaf_settings() {
let setting = find(&path).unwrap_or_else(|| panic!("{path} is not registered"));
let registered = match setting.default {
DefaultValue::Value(v) | DefaultValue::Unset(v) => {
toml::from_str::<toml::Table>(&format!("v = {v}")).unwrap()["v"].clone()
}
DefaultValue::Color { dark, light: lit } => {
let name = setting.name();
assert_eq!(
light.get(name).and_then(|v| v.as_str()),
Some(lit),
"{path} (light)"
);
toml::Value::String(dark.to_string())
}
};
assert_eq!(value, registered, "{path}: the registry's default differs");
}
for setting in SETTINGS {
if setting.key.ends_with(".*") || setting.kind == Kind::Tables {
continue;
}
let serialized = value_at(&defaults, setting.key).is_some();
let unset = matches!(setting.default, DefaultValue::Unset(_));
assert_eq!(
serialized,
!unset,
"{}: registered as {}set by default",
setting.key,
if unset { "un" } else { "" }
);
let example = match setting.default {
DefaultValue::Value(v) | DefaultValue::Unset(v) => v.to_string(),
DefaultValue::Color { dark, .. } => format!("\"{dark}\""),
};
let value =
toml::from_str::<toml::Table>(&format!("v = {example}")).unwrap()["v"].clone();
layer_at(setting.key, value).unwrap_or_else(|e| panic!("{}: {e}", setting.key));
}
}
#[test]
fn the_generated_config_shows_every_key_and_parses_uncommented() {
let generated = ConfigManager::with_dir(PathBuf::new()).generate_default_config();
let mut shown = std::collections::HashSet::new();
let mut section = String::new();
let mut uncommented = String::new();
for line in generated.lines() {
let Some(line) = line.strip_prefix("# ") else {
uncommented.push_str(line);
uncommented.push('\n');
continue;
};
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
section = name.to_string();
uncommented.push_str(line);
uncommented.push('\n');
} else if let Some((key, _)) = line.split_once(" = ")
&& !key.contains(' ')
{
shown.insert(match section.as_str() {
"" => key.to_string(),
s => format!("{s}.{key}"),
});
uncommented.push_str(line);
uncommented.push('\n');
}
}
for (path, _) in leaf_settings() {
assert!(
shown.contains(&path),
"{path} is not in the generated config"
);
}
ConfigLayer::parse(&uncommented).unwrap();
}
#[test]
fn every_setting_written_as_its_default_overrides_an_import() {
let defaults = leaf_settings();
let mut import = ConfigLayer::default();
let mut moved = Vec::new();
for (path, value) in &defaults {
if path == "import" || COMBINED_KEYS.iter().any(|(p, _)| p == path) {
continue;
}
let other = match value {
toml::Value::Boolean(b) => toml::Value::Boolean(!b),
toml::Value::Integer(n) => toml::Value::Integer(n + 1),
toml::Value::Array(items) if items.is_empty() => vec!["x"].into(),
toml::Value::Array(_) => toml::Value::Array(Vec::new()),
toml::Value::String(text) if text == "auto" && path == "display.row_numbers" => {
true.into()
}
toml::Value::String(text) => format!("{text}0").into(),
other => panic!("{path}: no rule to change {other}"),
};
if let Ok(layer) = layer_at(path, other.clone()) {
import.merge(layer);
moved.push((path.clone(), other));
}
}
assert!(moved.len() > 100, "only {} settings moved", moved.len());
let serialized = |config: AppConfig| match toml::Value::try_from(config).unwrap() {
toml::Value::Table(table) => table,
_ => unreachable!("a struct serializes to a table"),
};
let kept = serialized(AppConfig::from_layers([import.clone()]).unwrap());
for (path, other) in &moved {
assert_eq!(value_at(&kept, path), Some(other), "{path} was not read");
}
let mut own = ConfigLayer::default();
for (path, value) in &defaults {
own.merge(layer_at(path, value.clone()).unwrap());
}
let restored = serialized(AppConfig::from_layers([import, own]).unwrap());
for (path, _) in &moved {
let default = defaults.iter().find(|(p, _)| p == path).map(|(_, v)| v);
assert_eq!(value_at(&restored, path), default, "{path} kept the import");
}
}
}