sql_cli/config/
config.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(default)]
8pub struct Config {
9    pub display: DisplayConfig,
10    pub keybindings: KeybindingConfig,
11    pub behavior: BehaviorConfig,
12    pub theme: ThemeConfig,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(default)]
17pub struct DisplayConfig {
18    /// Use Unicode/Nerd Font glyphs for icons
19    pub use_glyphs: bool,
20
21    /// Show row numbers by default
22    pub show_row_numbers: bool,
23
24    /// Compact mode by default
25    pub compact_mode: bool,
26
27    /// Icons for different states (can be overridden)
28    pub icons: IconConfig,
29
30    /// Show key press indicator by default
31    pub show_key_indicator: bool,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(default)]
36pub struct IconConfig {
37    pub pin: String,
38    pub lock: String,
39    pub cache: String,
40    pub file: String,
41    pub database: String,
42    pub api: String,
43    pub case_insensitive: String,
44    pub warning: String,
45    pub error: String,
46    pub info: String,
47    pub success: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(default)]
52pub struct KeybindingConfig {
53    /// Whether to use vim-style keybindings
54    pub vim_mode: bool,
55
56    /// Custom key mappings (future expansion)
57    /// Format: "action" -> "key_sequence"
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub custom_mappings: Option<std::collections::HashMap<String, String>>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(default)]
64pub struct BehaviorConfig {
65    /// Auto-execute SELECT * when loading CSV/JSON
66    pub auto_execute_on_load: bool,
67
68    /// Case-insensitive by default
69    pub case_insensitive_default: bool,
70
71    /// Start mode when loading files: "command" or "results"
72    pub start_mode: String,
73
74    /// Maximum rows to display without pagination warning
75    pub max_display_rows: usize,
76
77    /// Default cache directory
78    pub cache_dir: Option<PathBuf>,
79
80    /// Enable query history
81    pub enable_history: bool,
82
83    /// Maximum history entries
84    pub max_history_entries: usize,
85
86    /// Automatically hide empty/null columns on data load
87    pub hide_empty_columns: bool,
88
89    /// Default date notation: "us" (MM/DD/YYYY) or "european" (DD/MM/YYYY)
90    /// This determines how ambiguous dates like 04/09/2025 are interpreted
91    pub default_date_notation: String,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(default)]
96pub struct ThemeConfig {
97    /// Color scheme: "default", "dark", "light", "solarized"
98    pub color_scheme: String,
99
100    /// Rainbow parentheses
101    pub rainbow_parentheses: bool,
102
103    /// Syntax highlighting
104    pub syntax_highlighting: bool,
105
106    /// Cell selection style
107    pub cell_selection_style: CellSelectionStyle,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(default)]
112pub struct CellSelectionStyle {
113    /// Style mode: "underline", "block", "border", "corners", "subtle"
114    pub mode: String,
115
116    /// Foreground color for selected cell (e.g., "yellow", "orange", "cyan")
117    pub foreground: String,
118
119    /// Whether to use background color
120    pub use_background: bool,
121
122    /// Background color if use_background is true
123    pub background: String,
124
125    /// Whether to bold the text
126    pub bold: bool,
127
128    /// Whether to underline the text (legacy, use mode instead)
129    pub underline: bool,
130
131    /// Border style for "border" mode: "single", "double", "rounded", "thick"
132    pub border_style: String,
133
134    /// Whether to show cell corners in "corners" mode
135    pub corner_chars: String, // e.g., "┌┐└┘" or "╭╮╰╯" for rounded
136}
137
138impl Default for Config {
139    fn default() -> Self {
140        Self {
141            display: DisplayConfig::default(),
142            keybindings: KeybindingConfig::default(),
143            behavior: BehaviorConfig::default(),
144            theme: ThemeConfig::default(),
145        }
146    }
147}
148
149impl Default for DisplayConfig {
150    fn default() -> Self {
151        Self {
152            use_glyphs: true, // Default to glyphs, can be disabled
153            show_row_numbers: false,
154            compact_mode: false,
155            icons: IconConfig::default(),
156            show_key_indicator: true, // Default on for better debugging
157        }
158    }
159}
160
161impl Default for IconConfig {
162    fn default() -> Self {
163        Self {
164            // Default to Unicode/Nerd Font icons
165            pin: "📌".to_string(),
166            lock: "🔒".to_string(),
167            cache: "📦".to_string(),
168            file: "📁".to_string(),
169            database: "🗄️".to_string(),
170            api: "🌐".to_string(),
171            case_insensitive: "Ⓘ".to_string(),
172            warning: "⚠️".to_string(),
173            error: "❌".to_string(),
174            info: "ℹ️".to_string(),
175            success: "✅".to_string(),
176        }
177    }
178}
179
180impl IconConfig {
181    /// Get simple ASCII alternatives for terminals without glyph support
182    pub fn simple() -> Self {
183        Self {
184            pin: "[P]".to_string(),
185            lock: "[L]".to_string(),
186            cache: "[C]".to_string(),
187            file: "[F]".to_string(),
188            database: "[DB]".to_string(),
189            api: "[API]".to_string(),
190            case_insensitive: "[i]".to_string(),
191            warning: "[!]".to_string(),
192            error: "[X]".to_string(),
193            info: "[i]".to_string(),
194            success: "[OK]".to_string(),
195        }
196    }
197}
198
199impl Default for KeybindingConfig {
200    fn default() -> Self {
201        Self {
202            vim_mode: true,
203            custom_mappings: None,
204        }
205    }
206}
207
208impl Default for BehaviorConfig {
209    fn default() -> Self {
210        Self {
211            auto_execute_on_load: true,
212            case_insensitive_default: true, // Default to case-insensitive for practical use
213            start_mode: "results".to_string(), // Default to results mode for immediate data view
214            max_display_rows: 10000,
215            cache_dir: None,
216            enable_history: true,
217            max_history_entries: 1000,
218            hide_empty_columns: false, // Default to false to avoid hiding data unexpectedly
219            default_date_notation: "us".to_string(), // Default to US format (MM/DD/YYYY)
220        }
221    }
222}
223
224impl Default for ThemeConfig {
225    fn default() -> Self {
226        Self {
227            color_scheme: "default".to_string(),
228            rainbow_parentheses: true,
229            syntax_highlighting: true,
230            cell_selection_style: CellSelectionStyle::default(),
231        }
232    }
233}
234
235impl Default for CellSelectionStyle {
236    fn default() -> Self {
237        Self {
238            mode: "underline".to_string(), // Default to current behavior
239            foreground: "yellow".to_string(),
240            use_background: false,
241            background: "cyan".to_string(),
242            bold: true,
243            underline: true, // Keep for backward compatibility
244            border_style: "single".to_string(),
245            corner_chars: "┌┐└┘".to_string(),
246        }
247    }
248}
249
250impl Config {
251    /// Generate debug info string for display
252    pub fn debug_info(&self) -> String {
253        let mut info = String::new();
254        info.push_str("\n========== CONFIGURATION ==========\n");
255
256        // Display configuration
257        info.push_str("[display]\n");
258        info.push_str(&format!("  use_glyphs = {}\n", self.display.use_glyphs));
259        info.push_str(&format!(
260            "  show_row_numbers = {}\n",
261            self.display.show_row_numbers
262        ));
263        info.push_str(&format!("  compact_mode = {}\n", self.display.compact_mode));
264        info.push_str(&format!(
265            "  show_key_indicator = {}\n",
266            self.display.show_key_indicator
267        ));
268
269        // Behavior configuration
270        info.push_str("\n[behavior]\n");
271        info.push_str(&format!(
272            "  auto_execute_on_load = {}\n",
273            self.behavior.auto_execute_on_load
274        ));
275        info.push_str(&format!(
276            "  case_insensitive_default = {}\n",
277            self.behavior.case_insensitive_default
278        ));
279        info.push_str(&format!(
280            "  start_mode = \"{}\"\n",
281            self.behavior.start_mode
282        ));
283        info.push_str(&format!(
284            "  max_display_rows = {}\n",
285            self.behavior.max_display_rows
286        ));
287        info.push_str(&format!(
288            "  enable_history = {}\n",
289            self.behavior.enable_history
290        ));
291        info.push_str(&format!(
292            "  max_history_entries = {}\n",
293            self.behavior.max_history_entries
294        ));
295        info.push_str(&format!(
296            "  hide_empty_columns = {}\n",
297            self.behavior.hide_empty_columns
298        ));
299        info.push_str(&format!(
300            "  default_date_notation = \"{}\"\n",
301            self.behavior.default_date_notation
302        ));
303
304        // Keybindings configuration
305        info.push_str("\n[keybindings]\n");
306        info.push_str(&format!("  vim_mode = {}\n", self.keybindings.vim_mode));
307
308        // Theme configuration
309        info.push_str("\n[theme]\n");
310        info.push_str(&format!("  color_scheme = {}\n", self.theme.color_scheme));
311        info.push_str(&format!(
312            "  rainbow_parentheses = {}\n",
313            self.theme.rainbow_parentheses
314        ));
315        info.push_str(&format!(
316            "  syntax_highlighting = {}\n",
317            self.theme.syntax_highlighting
318        ));
319        info.push_str(&format!(
320            "  cell_selection_style = {}\n",
321            self.theme.cell_selection_style.mode
322        ));
323
324        info.push_str("==========================================\n");
325        info
326    }
327
328    /// Load config from the default location
329    pub fn load() -> Result<Self> {
330        let config_path = Self::get_config_path()?;
331
332        if !config_path.exists() {
333            // Create default config if it doesn't exist
334            let default_config = Self::default();
335            default_config.save()?;
336            return Ok(default_config);
337        }
338
339        let contents = fs::read_to_string(&config_path)?;
340        let config: Config = toml::from_str(&contents)?;
341
342        // Apply simple mode if glyphs are disabled
343        let mut config = config;
344        if !config.display.use_glyphs {
345            config.display.icons = IconConfig::simple();
346        }
347
348        Ok(config)
349    }
350
351    /// Save config to the default location
352    pub fn save(&self) -> Result<()> {
353        let config_path = Self::get_config_path()?;
354
355        // Ensure parent directory exists
356        if let Some(parent) = config_path.parent() {
357            fs::create_dir_all(parent)?;
358        }
359
360        let contents = toml::to_string_pretty(self)?;
361        fs::write(&config_path, contents)?;
362
363        Ok(())
364    }
365
366    /// Get the default config file path
367    pub fn get_config_path() -> Result<PathBuf> {
368        let config_dir = dirs::config_dir()
369            .ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?;
370
371        Ok(config_dir.join("sql-cli").join("config.toml"))
372    }
373
374    /// Create a default config file with comments
375    pub fn create_default_with_comments() -> String {
376        r#"# SQL CLI Configuration File
377# Location: ~/.config/sql-cli/config.toml (Linux/macOS)
378#           %APPDATA%\sql-cli\config.toml (Windows)
379
380[display]
381# Use Unicode/Nerd Font glyphs for icons
382# Set to false for ASCII-only mode (better compatibility)
383use_glyphs = true
384
385# Show row numbers by default in results view
386show_row_numbers = false
387
388# Use compact mode by default (less padding, more data visible)
389compact_mode = false
390
391# Show key press indicator on status line (useful for debugging)
392show_key_indicator = true
393
394# Icon configuration
395# These are automatically set to ASCII when use_glyphs = false
396[display.icons]
397pin = "📌"
398lock = "🔒"
399cache = "📦"
400file = "📁"
401database = "🗄️"
402api = "🌐"
403case_insensitive = "Ⓘ"
404warning = "⚠️"
405error = "❌"
406info = "ℹ️"
407success = "✅"
408
409[keybindings]
410# Use vim-style keybindings (j/k navigation, yy to yank, etc.)
411vim_mode = true
412
413# Custom key mappings (future feature)
414# [keybindings.custom_mappings]
415# "copy_row" = "ctrl+c"
416# "paste" = "ctrl+v"
417
418[behavior]
419# Automatically execute SELECT * when loading CSV/JSON files
420auto_execute_on_load = true
421
422# Use case-insensitive string comparisons by default (recommended for practical use)
423case_insensitive_default = true
424
425# Start mode when loading files: "command" or "results"
426# - "command": Start in command mode (focus on SQL input)
427# - "results": Start in results mode (focus on data, press 'i' to edit query)
428start_mode = "results"
429
430# Maximum rows to display without warning
431max_display_rows = 10000
432
433# Cache directory (leave commented to use default)
434# cache_dir = "/path/to/cache"
435
436# Enable query history
437enable_history = true
438
439# Maximum number of history entries to keep
440max_history_entries = 1000
441
442# Automatically hide empty/null columns when data is loaded (can be toggled with 'E' key)
443hide_empty_columns = false
444
445# Default date notation for parsing ambiguous dates
446# "us" = MM/DD/YYYY format (e.g., 04/09/2025 = April 9, 2025)
447# "european" = DD/MM/YYYY format (e.g., 04/09/2025 = September 4, 2025)
448default_date_notation = "us"
449
450[theme]
451# Color scheme: "default", "dark", "light", "solarized"
452color_scheme = "default"
453
454# Enable rainbow parentheses in SQL queries
455rainbow_parentheses = true
456
457# Enable syntax highlighting
458syntax_highlighting = true
459
460# Cell selection highlighting style (for cell mode)
461[theme.cell_selection_style]
462# Foreground color: "yellow", "red", "green", "blue", "magenta", "cyan", "white"
463foreground = "yellow"
464
465# Whether to change background color (can be hard to read with some color schemes)
466use_background = false
467
468# Background color if use_background is true
469background = "cyan"
470
471# Text styling
472bold = true
473underline = true
474"#
475        .to_string()
476    }
477
478    /// Initialize config with a setup wizard
479    pub fn init_wizard() -> Result<Self> {
480        println!("SQL CLI Configuration Setup");
481        println!("============================");
482
483        // Ask about glyph support
484        print!("Does your terminal support Unicode/Nerd Font icons? (y/n) [y]: ");
485        std::io::Write::flush(&mut std::io::stdout())?;
486        let mut input = String::new();
487        std::io::stdin().read_line(&mut input)?;
488        let use_glyphs = !input.trim().eq_ignore_ascii_case("n");
489
490        let mut config = Config::default();
491        config.display.use_glyphs = use_glyphs;
492        if !use_glyphs {
493            config.display.icons = IconConfig::simple();
494        }
495
496        // Ask about vim mode
497        print!("Enable vim-style keybindings? (y/n) [y]: ");
498        std::io::Write::flush(&mut std::io::stdout())?;
499        input.clear();
500        std::io::stdin().read_line(&mut input)?;
501        config.keybindings.vim_mode = !input.trim().eq_ignore_ascii_case("n");
502
503        config.save()?;
504
505        println!("\nConfiguration saved to: {:?}", Config::get_config_path()?);
506        println!("You can edit this file directly to customize further.");
507
508        Ok(config)
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn test_default_config() {
518        let config = Config::default();
519        assert!(config.display.use_glyphs);
520        assert!(config.keybindings.vim_mode);
521    }
522
523    #[test]
524    fn test_simple_icons() {
525        let icons = IconConfig::simple();
526        assert_eq!(icons.pin, "[P]");
527        assert_eq!(icons.lock, "[L]");
528    }
529
530    #[test]
531    fn test_config_serialization() {
532        let config = Config::default();
533        let toml_str = toml::to_string(&config).unwrap();
534        let parsed: Config = toml::from_str(&toml_str).unwrap();
535        assert_eq!(config.display.use_glyphs, parsed.display.use_glyphs);
536    }
537}