1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(default)]
9#[derive(Default)]
10pub struct Config {
11 pub display: DisplayConfig,
12 pub keybindings: KeybindingConfig,
13 pub behavior: BehaviorConfig,
14 pub theme: ThemeConfig,
15 pub redis_cache: RedisCacheConfig,
16 pub web: WebConfig,
17 pub tokens: TokenConfig,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(default)]
22pub struct DisplayConfig {
23 pub use_glyphs: bool,
25
26 pub show_row_numbers: bool,
28
29 pub compact_mode: bool,
31
32 pub icons: IconConfig,
34
35 pub show_key_indicator: bool,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(default)]
41pub struct IconConfig {
42 pub pin: String,
43 pub lock: String,
44 pub cache: String,
45 pub file: String,
46 pub database: String,
47 pub api: String,
48 pub case_insensitive: String,
49 pub warning: String,
50 pub error: String,
51 pub info: String,
52 pub success: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(default)]
57pub struct KeybindingConfig {
58 pub vim_mode: bool,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
64 pub custom_mappings: Option<std::collections::HashMap<String, String>>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(default)]
69pub struct BehaviorConfig {
70 pub auto_execute_on_load: bool,
72
73 pub case_insensitive_default: bool,
75
76 pub start_mode: String,
78
79 pub max_display_rows: usize,
81
82 pub cache_dir: Option<PathBuf>,
84
85 pub enable_history: bool,
87
88 pub max_history_entries: usize,
90
91 pub hide_empty_columns: bool,
93
94 pub default_date_notation: String,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100#[serde(default)]
101pub struct ThemeConfig {
102 pub color_scheme: String,
104
105 pub rainbow_parentheses: bool,
107
108 pub syntax_highlighting: bool,
110
111 pub cell_selection_style: CellSelectionStyle,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(default)]
117pub struct CellSelectionStyle {
118 pub mode: String,
120
121 pub foreground: String,
123
124 pub use_background: bool,
126
127 pub background: String,
129
130 pub bold: bool,
132
133 pub underline: bool,
135
136 pub border_style: String,
138
139 pub corner_chars: String, }
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144#[serde(default)]
145pub struct RedisCacheConfig {
146 pub enabled: bool,
148
149 pub redis_url: String,
151
152 pub default_duration: u64,
154
155 pub duration_rules: HashMap<String, u64>,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(default)]
161pub struct WebConfig {
162 pub timeout: u64,
164
165 pub max_response_size: usize,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(default)]
171pub struct TokenConfig {
172 pub tokens: HashMap<String, TokenDefinition>,
176
177 pub auto_refresh: bool,
179
180 pub default_lifetime: u64,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct TokenDefinition {
186 pub refresh_command: String,
188
189 pub description: Option<String>,
191
192 pub lifetime: Option<u64>,
194
195 #[serde(skip_serializing, skip_deserializing)]
197 pub last_refreshed: Option<std::time::SystemTime>,
198}
199
200impl Default for DisplayConfig {
201 fn default() -> Self {
202 Self {
203 use_glyphs: true, show_row_numbers: false,
205 compact_mode: false,
206 icons: IconConfig::default(),
207 show_key_indicator: true, }
209 }
210}
211
212impl Default for IconConfig {
213 fn default() -> Self {
214 Self {
215 pin: "📌".to_string(),
217 lock: "🔒".to_string(),
218 cache: "📦".to_string(),
219 file: "📁".to_string(),
220 database: "🗄️".to_string(),
221 api: "🌐".to_string(),
222 case_insensitive: "Ⓘ".to_string(),
223 warning: "⚠️".to_string(),
224 error: "❌".to_string(),
225 info: "ℹ️".to_string(),
226 success: "✅".to_string(),
227 }
228 }
229}
230
231impl IconConfig {
232 #[must_use]
234 pub fn simple() -> Self {
235 Self {
236 pin: "[P]".to_string(),
237 lock: "[L]".to_string(),
238 cache: "[C]".to_string(),
239 file: "[F]".to_string(),
240 database: "[DB]".to_string(),
241 api: "[API]".to_string(),
242 case_insensitive: "[i]".to_string(),
243 warning: "[!]".to_string(),
244 error: "[X]".to_string(),
245 info: "[i]".to_string(),
246 success: "[OK]".to_string(),
247 }
248 }
249}
250
251impl Default for KeybindingConfig {
252 fn default() -> Self {
253 Self {
254 vim_mode: true,
255 custom_mappings: None,
256 }
257 }
258}
259
260impl Default for BehaviorConfig {
261 fn default() -> Self {
262 Self {
263 auto_execute_on_load: true,
264 case_insensitive_default: true, start_mode: "results".to_string(), max_display_rows: 10000,
267 cache_dir: None,
268 enable_history: true,
269 max_history_entries: 1000,
270 hide_empty_columns: false, default_date_notation: "us".to_string(), }
273 }
274}
275
276impl Default for ThemeConfig {
277 fn default() -> Self {
278 Self {
279 color_scheme: "default".to_string(),
280 rainbow_parentheses: true,
281 syntax_highlighting: true,
282 cell_selection_style: CellSelectionStyle::default(),
283 }
284 }
285}
286
287impl Default for CellSelectionStyle {
288 fn default() -> Self {
289 Self {
290 mode: "underline".to_string(), foreground: "yellow".to_string(),
292 use_background: false,
293 background: "cyan".to_string(),
294 bold: true,
295 underline: true, border_style: "single".to_string(),
297 corner_chars: "┌┐└┘".to_string(),
298 }
299 }
300}
301
302impl Default for RedisCacheConfig {
303 fn default() -> Self {
304 Self {
305 enabled: false,
306 redis_url: "redis://127.0.0.1:6379".to_string(),
307 default_duration: 600, duration_rules: HashMap::new(),
309 }
310 }
311}
312
313impl Default for WebConfig {
314 fn default() -> Self {
315 Self {
316 timeout: 30,
317 max_response_size: 100,
318 }
319 }
320}
321
322impl Default for TokenConfig {
323 fn default() -> Self {
324 Self {
325 tokens: HashMap::new(),
326 auto_refresh: false,
327 default_lifetime: 3600, }
329 }
330}
331
332fn glob_match(pattern: &str, text: &str) -> bool {
334 if pattern.contains('*') {
336 let parts: Vec<&str> = pattern.split('*').collect();
337 if parts.is_empty() {
338 return true;
339 }
340
341 let mut pos = 0;
342 for (i, part) in parts.iter().enumerate() {
343 if part.is_empty() {
344 continue;
345 }
346
347 if i == 0 && !text.starts_with(part) {
349 return false;
350 }
351 else if i == parts.len() - 1 && !text.ends_with(part) {
353 return false;
354 }
355 else if let Some(idx) = text[pos..].find(part) {
357 pos += idx + part.len();
358 } else {
359 return false;
360 }
361 }
362 true
363 } else {
364 text.contains(pattern)
365 }
366}
367
368impl Config {
369 #[must_use]
371 pub fn debug_info(&self) -> String {
372 let mut info = String::new();
373 info.push_str("\n========== CONFIGURATION ==========\n");
374
375 info.push_str("[display]\n");
377 info.push_str(&format!(" use_glyphs = {}\n", self.display.use_glyphs));
378 info.push_str(&format!(
379 " show_row_numbers = {}\n",
380 self.display.show_row_numbers
381 ));
382 info.push_str(&format!(" compact_mode = {}\n", self.display.compact_mode));
383 info.push_str(&format!(
384 " show_key_indicator = {}\n",
385 self.display.show_key_indicator
386 ));
387
388 info.push_str("\n[behavior]\n");
390 info.push_str(&format!(
391 " auto_execute_on_load = {}\n",
392 self.behavior.auto_execute_on_load
393 ));
394 info.push_str(&format!(
395 " case_insensitive_default = {}\n",
396 self.behavior.case_insensitive_default
397 ));
398 info.push_str(&format!(
399 " start_mode = \"{}\"\n",
400 self.behavior.start_mode
401 ));
402 info.push_str(&format!(
403 " max_display_rows = {}\n",
404 self.behavior.max_display_rows
405 ));
406 info.push_str(&format!(
407 " enable_history = {}\n",
408 self.behavior.enable_history
409 ));
410 info.push_str(&format!(
411 " max_history_entries = {}\n",
412 self.behavior.max_history_entries
413 ));
414 info.push_str(&format!(
415 " hide_empty_columns = {}\n",
416 self.behavior.hide_empty_columns
417 ));
418 info.push_str(&format!(
419 " default_date_notation = \"{}\"\n",
420 self.behavior.default_date_notation
421 ));
422
423 info.push_str("\n[keybindings]\n");
425 info.push_str(&format!(" vim_mode = {}\n", self.keybindings.vim_mode));
426
427 info.push_str("\n[theme]\n");
429 info.push_str(&format!(" color_scheme = {}\n", self.theme.color_scheme));
430 info.push_str(&format!(
431 " rainbow_parentheses = {}\n",
432 self.theme.rainbow_parentheses
433 ));
434 info.push_str(&format!(
435 " syntax_highlighting = {}\n",
436 self.theme.syntax_highlighting
437 ));
438 info.push_str(&format!(
439 " cell_selection_style = {}\n",
440 self.theme.cell_selection_style.mode
441 ));
442
443 info.push_str("\n[redis_cache]\n");
445 info.push_str(&format!(" enabled = {}\n", self.redis_cache.enabled));
446 info.push_str(&format!(" redis_url = {}\n", self.redis_cache.redis_url));
447 info.push_str(&format!(
448 " default_duration = {}s\n",
449 self.redis_cache.default_duration
450 ));
451 info.push_str(&format!(
452 " duration_rules = {} patterns\n",
453 self.redis_cache.duration_rules.len()
454 ));
455
456 info.push_str("\n[web]\n");
458 info.push_str(&format!(" timeout = {}s\n", self.web.timeout));
459 info.push_str(&format!(
460 " max_response_size = {} MB\n",
461 self.web.max_response_size
462 ));
463
464 info.push_str("\n[tokens]\n");
466 info.push_str(&format!(" auto_refresh = {}\n", self.tokens.auto_refresh));
467 info.push_str(&format!(
468 " default_lifetime = {}s\n",
469 self.tokens.default_lifetime
470 ));
471 info.push_str(&format!(
472 " configured_tokens = {} tokens\n",
473 self.tokens.tokens.len()
474 ));
475 for (name, _) in &self.tokens.tokens {
476 info.push_str(&format!(" - {}\n", name));
477 }
478
479 info.push_str("==========================================\n");
480 info
481 }
482
483 pub fn load() -> Result<Self> {
485 let config_path = Self::get_config_path()?;
486
487 if !config_path.exists() {
488 let default_config = Self::default();
490 default_config.save()?;
491 return Ok(default_config.with_env_overrides());
492 }
493
494 let contents = fs::read_to_string(&config_path)?;
495 let config: Config = toml::from_str(&contents)?;
496
497 let mut config = config;
499 if !config.display.use_glyphs {
500 config.display.icons = IconConfig::simple();
501 }
502
503 Ok(config.with_env_overrides())
504 }
505
506 fn with_env_overrides(mut self) -> Self {
508 if let Ok(val) = std::env::var("SQL_CLI_CACHE") {
510 self.redis_cache.enabled =
511 val.eq_ignore_ascii_case("true") || val.eq_ignore_ascii_case("yes") || val == "1";
512 }
513
514 if let Ok(url) = std::env::var("SQL_CLI_REDIS_URL") {
516 self.redis_cache.redis_url = url;
517 }
518
519 if let Ok(duration) = std::env::var("SQL_CLI_CACHE_DEFAULT_DURATION") {
521 if let Ok(seconds) = duration.parse::<u64>() {
522 self.redis_cache.default_duration = seconds;
523 }
524 }
525
526 self
527 }
528
529 pub fn get_cache_duration(&self, url: &str) -> u64 {
531 for (pattern, duration) in &self.redis_cache.duration_rules {
533 if url.contains(pattern) || glob_match(pattern, url) {
534 return *duration;
535 }
536 }
537
538 self.redis_cache.default_duration
540 }
541
542 pub fn save(&self) -> Result<()> {
544 let config_path = Self::get_config_path()?;
545
546 if let Some(parent) = config_path.parent() {
548 fs::create_dir_all(parent)?;
549 }
550
551 let contents = toml::to_string_pretty(self)?;
552 fs::write(&config_path, contents)?;
553
554 Ok(())
555 }
556
557 pub fn get_config_path() -> Result<PathBuf> {
559 let config_dir = dirs::config_dir()
560 .ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?;
561
562 Ok(config_dir.join("sql-cli").join("config.toml"))
563 }
564
565 #[must_use]
567 pub fn create_default_with_comments() -> String {
568 r#"# SQL CLI Configuration File
569# Location: ~/.config/sql-cli/config.toml (Linux/macOS)
570# %APPDATA%\sql-cli\config.toml (Windows)
571
572[display]
573# Use Unicode/Nerd Font glyphs for icons
574# Set to false for ASCII-only mode (better compatibility)
575use_glyphs = true
576
577# Show row numbers by default in results view
578show_row_numbers = false
579
580# Use compact mode by default (less padding, more data visible)
581compact_mode = false
582
583# Show key press indicator on status line (useful for debugging)
584show_key_indicator = true
585
586# Icon configuration
587# These are automatically set to ASCII when use_glyphs = false
588[display.icons]
589pin = "📌"
590lock = "🔒"
591cache = "📦"
592file = "📁"
593database = "🗄️"
594api = "🌐"
595case_insensitive = "Ⓘ"
596warning = "⚠️"
597error = "❌"
598info = "ℹ️"
599success = "✅"
600
601[keybindings]
602# Use vim-style keybindings (j/k navigation, yy to yank, etc.)
603vim_mode = true
604
605# Custom key mappings (future feature)
606# [keybindings.custom_mappings]
607# "copy_row" = "ctrl+c"
608# "paste" = "ctrl+v"
609
610[behavior]
611# Automatically execute SELECT * when loading CSV/JSON files
612auto_execute_on_load = true
613
614# Use case-insensitive string comparisons by default (recommended for practical use)
615case_insensitive_default = true
616
617# Start mode when loading files: "command" or "results"
618# - "command": Start in command mode (focus on SQL input)
619# - "results": Start in results mode (focus on data, press 'i' to edit query)
620start_mode = "results"
621
622# Maximum rows to display without warning
623max_display_rows = 10000
624
625# Cache directory (leave commented to use default)
626# cache_dir = "/path/to/cache"
627
628# Enable query history
629enable_history = true
630
631# Maximum number of history entries to keep
632max_history_entries = 1000
633
634# Automatically hide empty/null columns when data is loaded (can be toggled with 'E' key)
635hide_empty_columns = false
636
637# Default date notation for parsing ambiguous dates
638# "us" = MM/DD/YYYY format (e.g., 04/09/2025 = April 9, 2025)
639# "european" = DD/MM/YYYY format (e.g., 04/09/2025 = September 4, 2025)
640default_date_notation = "us"
641
642[theme]
643# Color scheme: "default", "dark", "light", "solarized"
644color_scheme = "default"
645
646# Enable rainbow parentheses in SQL queries
647rainbow_parentheses = true
648
649# Enable syntax highlighting
650syntax_highlighting = true
651
652# Cell selection highlighting style (for cell mode)
653[theme.cell_selection_style]
654# Foreground color: "yellow", "red", "green", "blue", "magenta", "cyan", "white"
655foreground = "yellow"
656
657# Whether to change background color (can be hard to read with some color schemes)
658use_background = false
659
660# Background color if use_background is true
661background = "cyan"
662
663# Text styling
664bold = true
665underline = true
666
667[redis_cache]
668# Enable Redis cache (can be overridden by SQL_CLI_CACHE env var)
669enabled = false
670
671# Redis connection URL (can be overridden by SQL_CLI_REDIS_URL env var)
672redis_url = "redis://127.0.0.1:6379"
673
674# Default cache duration in seconds when CACHE is specified without a value
675# or when no CACHE directive is present in the query
676default_duration = 600 # 10 minutes
677
678# Cache duration rules based on URL patterns
679# Pattern matching uses simple glob syntax (* for wildcards)
680# These override the default_duration for matching URLs
681[redis_cache.duration_rules]
682# Production APIs - cache for 1 hour
683"*.bloomberg.com/*" = 3600
684"*prod*" = 3600
685"*production*" = 3600
686
687# Staging/UAT - cache for 5 minutes
688"*staging*" = 300
689"*uat*" = 300
690
691# Historical data endpoints - cache for 24 hours
692"*/historical/*" = 86400
693"*/archive/*" = 86400
694"*/trades/20*" = 43200 # Yesterday's trades - 12 hours
695
696# Real-time/volatile data - short cache
697"*/realtime/*" = 60
698"*/live/*" = 30
699"*/prices/*" = 120
700
701# Specific endpoints
702"api.barclays.com/trades" = 7200 # 2 hours for Barclays trades
703"api.jpmorgan.com/fx" = 1800 # 30 minutes for JPM FX
704
705[web]
706# Default timeout for web requests in seconds
707timeout = 30
708
709# Maximum response size in MB
710max_response_size = 100
711
712[tokens]
713# Auto-refresh tokens before they expire
714auto_refresh = false
715
716# Default token lifetime in seconds (1 hour)
717default_lifetime = 3600
718
719# Token definitions with their refresh commands
720# Each token needs a refresh_command that outputs the token to stdout
721[tokens.tokens.JWT_TOKEN]
722description = "UAT environment JWT token"
723refresh_command = "~/.config/sql-cli/get_uat_token.sh"
724lifetime = 3600 # 1 hour
725
726[tokens.tokens.JWT_TOKEN_PROD]
727description = "Production environment JWT token"
728refresh_command = "~/.config/sql-cli/get_prod_token.sh"
729lifetime = 7200 # 2 hours
730
731# Example: Azure CLI token
732# [tokens.tokens.AZURE_TOKEN]
733# description = "Azure access token"
734# refresh_command = "az account get-access-token --resource https://api.example.com --query accessToken -o tsv"
735# lifetime = 3600
736"#
737 .to_string()
738 }
739
740 pub fn init_wizard() -> Result<Self> {
742 println!("SQL CLI Configuration Setup");
743 println!("============================");
744
745 print!("Does your terminal support Unicode/Nerd Font icons? (y/n) [y]: ");
747 std::io::Write::flush(&mut std::io::stdout())?;
748 let mut input = String::new();
749 std::io::stdin().read_line(&mut input)?;
750 let use_glyphs = !input.trim().eq_ignore_ascii_case("n");
751
752 let mut config = Config::default();
753 config.display.use_glyphs = use_glyphs;
754 if !use_glyphs {
755 config.display.icons = IconConfig::simple();
756 }
757
758 print!("Enable vim-style keybindings? (y/n) [y]: ");
760 std::io::Write::flush(&mut std::io::stdout())?;
761 input.clear();
762 std::io::stdin().read_line(&mut input)?;
763 config.keybindings.vim_mode = !input.trim().eq_ignore_ascii_case("n");
764
765 config.save()?;
766
767 println!("\nConfiguration saved to: {:?}", Config::get_config_path()?);
768 println!("You can edit this file directly to customize further.");
769
770 Ok(config)
771 }
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777
778 #[test]
779 fn test_default_config() {
780 let config = Config::default();
781 assert!(config.display.use_glyphs);
782 assert!(config.keybindings.vim_mode);
783 }
784
785 #[test]
786 fn test_simple_icons() {
787 let icons = IconConfig::simple();
788 assert_eq!(icons.pin, "[P]");
789 assert_eq!(icons.lock, "[L]");
790 }
791
792 #[test]
793 fn test_config_serialization() {
794 let config = Config::default();
795 let toml_str = toml::to_string(&config).unwrap();
796 let parsed: Config = toml::from_str(&toml_str).unwrap();
797 assert_eq!(config.display.use_glyphs, parsed.display.use_glyphs);
798 }
799}