use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::{collections::hash_map::DefaultHasher, hash::Hash as _, hash::Hasher as _};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use fs2::FileExt as _;
use serde::{Deserialize, Serialize};
const CONFIG_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeName {
Dracula,
#[default]
DraculaSoft,
Monochrome,
}
impl ThemeName {
pub fn next(self) -> Self {
match self {
ThemeName::Dracula => ThemeName::DraculaSoft,
ThemeName::DraculaSoft => ThemeName::Monochrome,
ThemeName::Monochrome => ThemeName::Dracula,
}
}
pub fn label(self) -> &'static str {
match self {
ThemeName::Dracula => "dracula",
ThemeName::DraculaSoft => "dracula-soft",
ThemeName::Monochrome => "monochrome",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub version: u32,
pub row_limit: usize,
pub col_width: u16,
pub theme: ThemeName,
pub editor_visible: bool,
pub foreground_timeout_ms: u64,
pub query_history_enabled: bool,
pub shortcuts: Shortcuts,
pub profiles: Vec<ConnectionProfile>,
#[serde(skip)]
loaded_revision: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Shortcuts {
pub next_panel: String,
pub previous_panel: String,
pub next_tab: String,
pub previous_tab: String,
pub filter: String,
pub toggle_editor: String,
pub toggle_tables: String,
pub toggle_record: String,
pub settings: String,
pub advanced_copy: String,
pub help: String,
pub quit: String,
}
impl Default for Shortcuts {
fn default() -> Self {
Self {
next_panel: "tab".to_string(),
previous_panel: "shift+tab".to_string(),
next_tab: "]".to_string(),
previous_tab: "[".to_string(),
filter: "f".to_string(),
toggle_editor: "E".to_string(),
toggle_tables: "b".to_string(),
toggle_record: "B".to_string(),
settings: ",".to_string(),
advanced_copy: "Y".to_string(),
help: "?".to_string(),
quit: "q".to_string(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShortcutAction {
NextPanel,
PreviousPanel,
NextTab,
PreviousTab,
Filter,
ToggleEditor,
ToggleTables,
ToggleRecord,
Settings,
AdvancedCopy,
Help,
Quit,
}
impl Shortcuts {
pub fn binding(&self, action: ShortcutAction) -> &str {
match action {
ShortcutAction::NextPanel => &self.next_panel,
ShortcutAction::PreviousPanel => &self.previous_panel,
ShortcutAction::NextTab => &self.next_tab,
ShortcutAction::PreviousTab => &self.previous_tab,
ShortcutAction::Filter => &self.filter,
ShortcutAction::ToggleEditor => &self.toggle_editor,
ShortcutAction::ToggleTables => &self.toggle_tables,
ShortcutAction::ToggleRecord => &self.toggle_record,
ShortcutAction::Settings => &self.settings,
ShortcutAction::AdvancedCopy => &self.advanced_copy,
ShortcutAction::Help => &self.help,
ShortcutAction::Quit => &self.quit,
}
}
pub fn matches(&self, action: ShortcutAction, event: KeyEvent) -> bool {
parse_key_binding(self.binding(action)).is_some_and(|binding| binding.matches(event))
}
fn validate(&self) -> anyhow::Result<()> {
for action in [
ShortcutAction::NextPanel,
ShortcutAction::PreviousPanel,
ShortcutAction::NextTab,
ShortcutAction::PreviousTab,
ShortcutAction::Filter,
ShortcutAction::ToggleEditor,
ShortcutAction::ToggleTables,
ShortcutAction::ToggleRecord,
ShortcutAction::Settings,
ShortcutAction::AdvancedCopy,
ShortcutAction::Help,
ShortcutAction::Quit,
] {
let binding = self.binding(action);
anyhow::ensure!(
parse_key_binding(binding).is_some(),
"invalid shortcut `{binding}`"
);
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ConnectionProfile {
Sqlite {
name: String,
path: PathBuf,
#[serde(default)]
write: bool,
},
Dynamodb {
name: String,
#[serde(default)]
aws_profile: Option<String>,
#[serde(default)]
region: Option<String>,
#[serde(default)]
endpoint_url: Option<String>,
#[serde(default)]
local: bool,
#[serde(default)]
write: bool,
},
}
impl ConnectionProfile {
pub fn name(&self) -> &str {
match self {
ConnectionProfile::Sqlite { name, .. } | ConnectionProfile::Dynamodb { name, .. } => {
name
}
}
}
pub fn kind_label(&self) -> &'static str {
match self {
ConnectionProfile::Sqlite { .. } => "SQLite",
ConnectionProfile::Dynamodb { .. } => "DynamoDB",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct KeyBinding {
code: KeyCode,
modifiers: KeyModifiers,
}
impl KeyBinding {
fn matches(&self, event: KeyEvent) -> bool {
let code_matches = self.code == event.code
|| matches!(
(self.code, event.code),
(KeyCode::Char(expected), KeyCode::Char(actual))
if (self.modifiers.contains(KeyModifiers::SHIFT)
|| event.modifiers.contains(KeyModifiers::SHIFT))
&& expected.eq_ignore_ascii_case(&actual)
)
|| (self.code == KeyCode::BackTab && event.code == KeyCode::Tab)
|| (self.code == KeyCode::Tab && event.code == KeyCode::BackTab);
let shifted_character = matches!(self.code, KeyCode::Char(character) if !character.is_ascii_lowercase())
&& self.modifiers == KeyModifiers::NONE
&& event.modifiers == KeyModifiers::SHIFT;
code_matches && (event.modifiers == self.modifiers || shifted_character)
}
}
impl Default for Config {
fn default() -> Self {
Self {
version: CONFIG_VERSION,
row_limit: 200,
col_width: 16,
theme: ThemeName::DraculaSoft,
editor_visible: true,
foreground_timeout_ms: 30_000,
query_history_enabled: true,
shortcuts: Shortcuts::default(),
profiles: Vec::new(),
loaded_revision: None,
}
}
}
impl Config {
pub fn load() -> anyhow::Result<Self> {
Self::load_from(&config_path()?)
}
pub fn save(&mut self) -> anyhow::Result<()> {
self.save_to(&config_path()?)
}
fn load_from(path: &Path) -> anyhow::Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(path)?;
let mut config: Self = toml::from_str(&content)?;
anyhow::ensure!(
config.version <= CONFIG_VERSION,
"config version {} is newer than supported version {CONFIG_VERSION}",
config.version
);
config.normalize();
config.shortcuts.validate()?;
config.loaded_revision = Some(content_revision(&content));
Ok(config)
}
fn save_to(&mut self, path: &Path) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("config path has no parent"))?;
let lock_path = parent.join("config.lock");
let lock = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(lock_path)?;
lock.lock_exclusive()?;
let current_revision = if path.exists() {
Some(content_revision(&std::fs::read_to_string(path)?))
} else {
None
};
anyhow::ensure!(
current_revision == self.loaded_revision,
"configuration changed in another process; reload before saving"
);
let serialized = toml::to_string_pretty(self)?;
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
temporary.write_all(serialized.as_bytes())?;
temporary.as_file().sync_all()?;
temporary
.persist(path)
.map_err(|error| anyhow::Error::new(error.error))?;
#[cfg(unix)]
std::fs::File::open(parent)?.sync_all()?;
self.loaded_revision = Some(content_revision(&serialized));
Ok(())
}
pub fn normalize(&mut self) {
self.version = CONFIG_VERSION;
self.row_limit = self.row_limit.clamp(1, 1_000);
self.col_width = self.col_width.clamp(4, 80);
self.foreground_timeout_ms = self.foreground_timeout_ms.clamp(100, 300_000);
self.profiles
.sort_by_key(|profile| profile.name().to_lowercase());
self.profiles
.dedup_by(|left, right| left.name().eq_ignore_ascii_case(right.name()));
}
pub fn profile(&self, name: &str) -> Option<&ConnectionProfile> {
self.profiles
.iter()
.find(|profile| profile.name().eq_ignore_ascii_case(name))
}
pub fn upsert_profile(&mut self, profile: ConnectionProfile) {
if let Some(existing) = self
.profiles
.iter_mut()
.find(|existing| existing.name().eq_ignore_ascii_case(profile.name()))
{
*existing = profile;
} else {
self.profiles.push(profile);
}
self.normalize();
}
}
fn content_revision(content: &str) -> u64 {
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
hasher.finish()
}
fn parse_key_binding(value: &str) -> Option<KeyBinding> {
let parts: Vec<&str> = value.split('+').collect();
let key = parts.last()?.trim();
let mut modifiers = KeyModifiers::NONE;
for modifier in &parts[..parts.len().saturating_sub(1)] {
match modifier.trim().to_ascii_lowercase().as_str() {
"ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
"alt" | "option" => modifiers |= KeyModifiers::ALT,
"shift" => modifiers |= KeyModifiers::SHIFT,
_ => return None,
}
}
let normalized = key.to_ascii_lowercase();
let code = match normalized.as_str() {
"tab" if modifiers.contains(KeyModifiers::SHIFT) => KeyCode::BackTab,
"tab" => KeyCode::Tab,
"backtab" => KeyCode::BackTab,
"enter" => KeyCode::Enter,
"esc" | "escape" => KeyCode::Esc,
"space" => KeyCode::Char(' '),
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"pageup" | "page-up" => KeyCode::PageUp,
"pagedown" | "page-down" => KeyCode::PageDown,
_ if key.chars().count() == 1 => KeyCode::Char(key.chars().next()?),
_ => return None,
};
Some(KeyBinding { code, modifiers })
}
pub fn config_path() -> anyhow::Result<PathBuf> {
config_path_from(
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from),
std::env::var_os("HOME").map(PathBuf::from),
)
.or_else(|_| {
dirs::config_dir()
.map(|path| path.join("tuible").join("config.toml"))
.ok_or_else(|| anyhow::anyhow!("config directory unavailable"))
})
}
pub fn data_dir() -> anyhow::Result<PathBuf> {
data_dir_from(
std::env::var_os("XDG_DATA_HOME").map(PathBuf::from),
std::env::var_os("HOME").map(PathBuf::from),
)
.or_else(|_| {
dirs::data_dir()
.map(|path| path.join("tuible"))
.ok_or_else(|| anyhow::anyhow!("data directory unavailable"))
})
}
pub fn state_dir() -> anyhow::Result<PathBuf> {
state_dir_from(
std::env::var_os("XDG_STATE_HOME").map(PathBuf::from),
std::env::var_os("HOME").map(PathBuf::from),
)
}
fn config_path_from(
xdg_config_home: Option<PathBuf>,
home: Option<PathBuf>,
) -> anyhow::Result<PathBuf> {
let root = xdg_config_home
.filter(|path| path.is_absolute())
.or_else(|| home.map(|path| path.join(".config")))
.ok_or_else(|| anyhow::anyhow!("XDG config directory unavailable"))?;
Ok(root.join("tuible").join("config.toml"))
}
fn data_dir_from(xdg_data_home: Option<PathBuf>, home: Option<PathBuf>) -> anyhow::Result<PathBuf> {
let root = xdg_data_home
.filter(|path| path.is_absolute())
.or_else(|| home.map(|path| path.join(".local/share")))
.ok_or_else(|| anyhow::anyhow!("XDG data directory unavailable"))?;
Ok(root.join("tuible"))
}
fn state_dir_from(
xdg_state_home: Option<PathBuf>,
home: Option<PathBuf>,
) -> anyhow::Result<PathBuf> {
let root = xdg_state_home
.filter(|path| path.is_absolute())
.or_else(|| home.map(|path| path.join(".local/state")))
.ok_or_else(|| anyhow::anyhow!("XDG state directory unavailable"))?;
Ok(root.join("tuible"))
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::*;
use tempfile::tempdir;
#[test]
fn missing_config_uses_defaults() {
let dir = tempdir().unwrap();
let config = Config::load_from(&dir.path().join("missing.toml")).unwrap();
assert_eq!(config, Config::default());
}
#[test]
fn config_round_trips_and_normalizes_limits() {
let dir = tempdir().unwrap();
let path = dir.path().join("tuible/config.toml");
let mut config = Config {
row_limit: 0,
col_width: 200,
query_history_enabled: false,
..Config::default()
};
config.save_to(&path).unwrap();
let loaded = Config::load_from(&path).unwrap();
assert_eq!(loaded.row_limit, 1);
assert_eq!(loaded.col_width, 80);
assert!(!loaded.query_history_enabled);
let mut maximum = Config {
row_limit: 10_000,
col_width: 16,
..Config::default()
};
maximum.normalize();
assert_eq!(maximum.row_limit, 1_000);
}
#[test]
fn foreground_timeout_has_a_safe_default_and_bounded_configuration() {
assert_eq!(Config::default().foreground_timeout_ms, 30_000);
let mut config = Config {
foreground_timeout_ms: 0,
..Config::default()
};
config.normalize();
assert_eq!(config.foreground_timeout_ms, 100);
config.foreground_timeout_ms = u64::MAX;
config.normalize();
assert_eq!(config.foreground_timeout_ms, 300_000);
}
#[test]
fn xdg_config_home_takes_precedence() {
let path = config_path_from(
Some(PathBuf::from("/tmp/xdg")),
Some(PathBuf::from("/home/marcin")),
)
.unwrap();
assert_eq!(path, PathBuf::from("/tmp/xdg/tuible/config.toml"));
}
#[test]
fn xdg_default_uses_home_dot_config() {
let path = config_path_from(None, Some(PathBuf::from("/home/marcin"))).unwrap();
assert_eq!(
path,
PathBuf::from("/home/marcin/.config/tuible/config.toml")
);
}
#[test]
fn relative_xdg_path_is_ignored() {
let path = config_path_from(
Some(PathBuf::from("relative")),
Some(PathBuf::from("/home/marcin")),
)
.unwrap();
assert_eq!(
path,
PathBuf::from("/home/marcin/.config/tuible/config.toml")
);
}
#[test]
fn xdg_data_home_and_default_are_respected() {
assert_eq!(
data_dir_from(
Some(PathBuf::from("/tmp/data")),
Some(PathBuf::from("/home/marcin"))
)
.unwrap(),
PathBuf::from("/tmp/data/tuible")
);
assert_eq!(
data_dir_from(None, Some(PathBuf::from("/home/marcin"))).unwrap(),
PathBuf::from("/home/marcin/.local/share/tuible")
);
}
#[test]
fn xdg_state_home_and_home_fallback_are_respected() {
assert_eq!(
state_dir_from(
Some(PathBuf::from("/tmp/state")),
Some(PathBuf::from("/home/marcin"))
)
.unwrap(),
PathBuf::from("/tmp/state/tuible")
);
assert_eq!(
state_dir_from(None, Some(PathBuf::from("/home/marcin"))).unwrap(),
PathBuf::from("/home/marcin/.local/state/tuible")
);
assert_eq!(
state_dir_from(
Some(PathBuf::from("relative")),
Some(PathBuf::from("/home/marcin"))
)
.unwrap(),
PathBuf::from("/home/marcin/.local/state/tuible")
);
}
#[test]
fn shortcuts_parse_named_modified_and_shifted_keys() {
let mut shortcuts = Shortcuts::default();
assert!(shortcuts.matches(
ShortcutAction::NextPanel,
KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)
));
assert!(shortcuts.matches(
ShortcutAction::PreviousPanel,
KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)
));
assert!(shortcuts.matches(
ShortcutAction::AdvancedCopy,
KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::SHIFT)
));
shortcuts.advanced_copy = "shift+y".to_string();
assert!(shortcuts.matches(
ShortcutAction::AdvancedCopy,
KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::SHIFT)
));
}
#[test]
fn profiles_round_trip_without_credentials() {
let dir = tempdir().unwrap();
let path = dir.path().join("tuible/config.toml");
let mut config = Config::default();
config.upsert_profile(ConnectionProfile::Dynamodb {
name: "work".to_string(),
aws_profile: Some("company-sso".to_string()),
region: Some("eu-west-1".to_string()),
endpoint_url: None,
local: false,
write: false,
});
config.save_to(&path).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
let loaded = Config::load_from(&path).unwrap();
assert!(text.contains("[[profiles]]"));
assert!(text.contains("aws_profile = \"company-sso\""));
assert!(!text.to_ascii_lowercase().contains("secret"));
assert_eq!(loaded.profile("WORK"), config.profile("work"));
assert!(!path.with_extension("toml.tmp").exists());
}
#[test]
fn newer_config_versions_are_rejected_without_rewriting() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "version = 99\nrow_limit = 42\n").unwrap();
let error = Config::load_from(&path).unwrap_err();
assert!(error.to_string().contains("newer"));
assert!(
std::fs::read_to_string(path)
.unwrap()
.contains("version = 99")
);
}
#[test]
fn concurrent_config_writes_never_produce_partial_toml() {
let dir = tempdir().unwrap();
let path = std::sync::Arc::new(dir.path().join("tuible/config.toml"));
let writers: Vec<_> = (1..=6)
.map(|row_limit| {
let path = path.clone();
std::thread::spawn(move || {
let mut config = Config {
row_limit,
..Config::default()
};
config.save_to(&path).map_err(|error| error.to_string())
})
})
.collect();
let results: Vec<_> = writers
.into_iter()
.map(|writer| writer.join().unwrap())
.collect();
let loaded = Config::load_from(&path).unwrap();
assert!((1..=6).contains(&loaded.row_limit));
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
assert!(
results
.iter()
.filter_map(|result| result.as_ref().err())
.all(|error| error.contains("another process"))
);
}
}