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