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