Skip to main content

datui_lib/
config.rs

1use color_eyre::eyre::eyre;
2use color_eyre::Result;
3use ratatui::style::Color;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use supports_color::Stream;
8
9/// Manages config directory and config file operations
10#[derive(Clone)]
11pub struct ConfigManager {
12    pub(crate) config_dir: PathBuf,
13}
14
15impl ConfigManager {
16    /// Create a ConfigManager with a custom config directory (primarily for testing)
17    pub fn with_dir(config_dir: PathBuf) -> Self {
18        Self { config_dir }
19    }
20
21    /// Create a new ConfigManager for the given app name
22    pub fn new(app_name: &str) -> Result<Self> {
23        let config_dir = dirs::config_dir()
24            .ok_or_else(|| eyre!("Could not determine config directory"))?
25            .join(app_name);
26
27        Ok(Self { config_dir })
28    }
29
30    /// Get the config directory path
31    pub fn config_dir(&self) -> &Path {
32        &self.config_dir
33    }
34
35    /// Get path to a specific config file or subdirectory
36    pub fn config_path(&self, path: &str) -> PathBuf {
37        self.config_dir.join(path)
38    }
39
40    /// Ensure the config directory exists
41    pub fn ensure_config_dir(&self) -> Result<()> {
42        if !self.config_dir.exists() {
43            std::fs::create_dir_all(&self.config_dir)?;
44        }
45        Ok(())
46    }
47
48    /// Ensure a subdirectory exists within the config directory
49    pub fn ensure_subdir(&self, subdir: &str) -> Result<PathBuf> {
50        let subdir_path = self.config_dir.join(subdir);
51        if !subdir_path.exists() {
52            std::fs::create_dir_all(&subdir_path)?;
53        }
54        Ok(subdir_path)
55    }
56
57    /// Generate default configuration template as a string with comments
58    /// All fields are commented out so defaults are used, but users can uncomment to override
59    pub fn generate_default_config(&self) -> String {
60        // Serialize default config to TOML
61        let config = AppConfig::default();
62        let toml_str = toml::to_string_pretty(&config)
63            .unwrap_or_else(|e| panic!("Failed to serialize default config: {}", e));
64
65        // Build comment map from all struct comment constants
66        let comments = Self::collect_all_comments();
67
68        // Comment out all fields and add comments
69        Self::comment_all_fields(toml_str, comments)
70    }
71
72    /// Collect all field comments from struct constants into a map
73    fn collect_all_comments() -> std::collections::HashMap<String, String> {
74        let mut comments = std::collections::HashMap::new();
75
76        // Top-level fields
77        for (field, comment) in APP_COMMENTS {
78            comments.insert(field.to_string(), comment.to_string());
79        }
80
81        // Cloud fields
82        for (field, comment) in CLOUD_COMMENTS {
83            comments.insert(format!("cloud.{}", field), comment.to_string());
84        }
85
86        // File loading fields
87        for (field, comment) in FILE_LOADING_COMMENTS {
88            comments.insert(format!("file_loading.{}", field), comment.to_string());
89        }
90
91        // Display fields
92        for (field, comment) in DISPLAY_COMMENTS {
93            comments.insert(format!("display.{}", field), comment.to_string());
94        }
95
96        // Performance fields
97        for (field, comment) in PERFORMANCE_COMMENTS {
98            comments.insert(format!("performance.{}", field), comment.to_string());
99        }
100
101        // Chart fields
102        for (field, comment) in CHART_COMMENTS {
103            comments.insert(format!("chart.{}", field), comment.to_string());
104        }
105
106        // Theme fields
107        for (field, comment) in THEME_COMMENTS {
108            comments.insert(format!("theme.{}", field), comment.to_string());
109        }
110
111        // Color fields
112        for (field, comment) in COLOR_COMMENTS {
113            comments.insert(format!("theme.colors.{}", field), comment.to_string());
114        }
115
116        // Controls fields
117        for (field, comment) in CONTROLS_COMMENTS {
118            comments.insert(format!("ui.controls.{}", field), comment.to_string());
119        }
120
121        // Query fields
122        for (field, comment) in QUERY_COMMENTS {
123            comments.insert(format!("query.{}", field), comment.to_string());
124        }
125
126        // Template fields
127        for (field, comment) in TEMPLATE_COMMENTS {
128            comments.insert(format!("templates.{}", field), comment.to_string());
129        }
130
131        // Debug fields
132        for (field, comment) in DEBUG_COMMENTS {
133            comments.insert(format!("debug.{}", field), comment.to_string());
134        }
135
136        comments
137    }
138
139    /// Comment out all fields in TOML and add comments
140    /// Also adds missing Option fields as commented-out `# field = null`
141    fn comment_all_fields(
142        toml: String,
143        comments: std::collections::HashMap<String, String>,
144    ) -> String {
145        let mut result = String::new();
146        result.push_str("# datui configuration file\n");
147        result
148            .push_str("# This file uses TOML format. See https://toml.io/ for syntax reference.\n");
149        result.push('\n');
150
151        let lines: Vec<&str> = toml.lines().collect();
152        let mut i = 0;
153        let mut current_section = String::new();
154        let mut seen_fields: std::collections::HashSet<String> = std::collections::HashSet::new();
155
156        // First pass: process existing fields and track what we've seen
157        while i < lines.len() {
158            let line = lines[i];
159
160            // Check if this is a section header
161            if let Some(section) = Self::extract_section_name(line) {
162                current_section = section.clone();
163
164                // Add section header comment if we have one
165                if let Some(header) = SECTION_HEADERS.iter().find(|(s, _)| s == &section) {
166                    result.push_str(header.1);
167                    result.push('\n');
168                }
169
170                // Comment out the section header
171                result.push_str("# ");
172                result.push_str(line);
173                result.push('\n');
174                i += 1;
175                continue;
176            }
177
178            // Check if this is a field assignment
179            if let Some(field_path) = Self::extract_field_path_simple(line, &current_section) {
180                seen_fields.insert(field_path.clone());
181
182                // Add comment if we have one
183                if let Some(comment) = comments.get(&field_path) {
184                    for comment_line in comment.lines() {
185                        result.push_str("# ");
186                        result.push_str(comment_line);
187                        result.push('\n');
188                    }
189                }
190
191                // Comment out the field line
192                result.push_str("# ");
193                result.push_str(line);
194                result.push('\n');
195            } else {
196                // Empty line or other content - preserve as-is
197                result.push_str(line);
198                result.push('\n');
199            }
200
201            i += 1;
202        }
203
204        // Second pass: add missing Option fields (those with comments but not in TOML)
205        result = Self::add_missing_option_fields(result, &comments, &seen_fields);
206
207        result
208    }
209
210    /// Add missing Option fields that weren't serialized (because they're None)
211    fn add_missing_option_fields(
212        mut result: String,
213        comments: &std::collections::HashMap<String, String>,
214        seen_fields: &std::collections::HashSet<String>,
215    ) -> String {
216        // Option fields that should appear even when None
217        let option_fields = [
218            "cloud.s3_endpoint_url",
219            "cloud.s3_access_key_id",
220            "cloud.s3_secret_access_key",
221            "cloud.s3_region",
222            "file_loading.delimiter",
223            "file_loading.has_header",
224            "file_loading.skip_lines",
225            "file_loading.skip_rows",
226            "file_loading.single_spine_schema",
227            "chart.row_limit",
228            "ui.controls.custom_controls",
229        ];
230
231        // Group missing fields by section
232        let mut missing_by_section: std::collections::HashMap<String, Vec<&str>> =
233            std::collections::HashMap::new();
234
235        for field_path in &option_fields {
236            if !seen_fields.contains(*field_path) && comments.contains_key(*field_path) {
237                if let Some(dot_pos) = field_path.find('.') {
238                    let section = &field_path[..dot_pos];
239                    missing_by_section
240                        .entry(section.to_string())
241                        .or_default()
242                        .push(field_path);
243                }
244            }
245        }
246
247        // Insert missing fields into appropriate sections
248        for (section, fields) in &missing_by_section {
249            let section_header = format!("[{}]", section);
250            if let Some(section_pos) = result.find(&section_header) {
251                // Find the newline after the section header
252                let after_header_start = section_pos + section_header.len();
253                let after_header = &result[after_header_start..];
254
255                // Find the first newline after the section header
256                let newline_pos = after_header.find('\n').unwrap_or(0);
257                let insert_pos = after_header_start + newline_pos + 1;
258
259                // Build content to insert
260                let mut new_content = String::new();
261                for field_path in fields {
262                    if let Some(comment) = comments.get(*field_path) {
263                        for comment_line in comment.lines() {
264                            new_content.push_str("# ");
265                            new_content.push_str(comment_line);
266                            new_content.push('\n');
267                        }
268                    }
269                    let field_name = field_path.rsplit('.').next().unwrap_or(field_path);
270                    new_content.push_str(&format!("# {} = null\n", field_name));
271                    new_content.push('\n');
272                }
273
274                result.insert_str(insert_pos, &new_content);
275            }
276        }
277
278        result
279    }
280
281    /// Extract section name from TOML line like "[performance]" or "[theme.colors]"
282    fn extract_section_name(line: &str) -> Option<String> {
283        let trimmed = line.trim();
284        if trimmed.starts_with('[') && trimmed.ends_with(']') {
285            Some(trimmed[1..trimmed.len() - 1].to_string())
286        } else {
287            None
288        }
289    }
290
291    /// Extract field path from a line (simpler version)
292    fn extract_field_path_simple(line: &str, current_section: &str) -> Option<String> {
293        let trimmed = line.trim();
294        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('[') {
295            return None;
296        }
297
298        // Extract field name from line (e.g., "sampling_threshold = 10000")
299        if let Some(eq_pos) = trimmed.find('=') {
300            let field_name = trimmed[..eq_pos].trim();
301            if current_section.is_empty() {
302                Some(field_name.to_string())
303            } else {
304                Some(format!("{}.{}", current_section, field_name))
305            }
306        } else {
307            None
308        }
309    }
310
311    /// Write default configuration to config file
312    pub fn write_default_config(&self, force: bool) -> Result<PathBuf> {
313        let config_path = self.config_path("config.toml");
314
315        if config_path.exists() && !force {
316            return Err(eyre!(
317                "Config file already exists at {}. Use --force to overwrite.",
318                config_path.display()
319            ));
320        }
321
322        // Ensure config directory exists
323        self.ensure_config_dir()?;
324
325        // Generate and write default template
326        let template = self.generate_default_config();
327        std::fs::write(&config_path, template)?;
328
329        Ok(config_path)
330    }
331}
332
333/// Complete application configuration
334#[derive(Debug, Clone, Serialize, Deserialize)]
335#[serde(default)]
336pub struct AppConfig {
337    /// Configuration format version (for future compatibility)
338    pub version: String,
339    pub cloud: CloudConfig,
340    pub file_loading: FileLoadingConfig,
341    pub display: DisplayConfig,
342    pub performance: PerformanceConfig,
343    pub chart: ChartConfig,
344    pub theme: ThemeConfig,
345    pub ui: UiConfig,
346    pub query: QueryConfig,
347    pub templates: TemplateConfig,
348    pub debug: DebugConfig,
349}
350
351// Field comments for AppConfig (top-level fields)
352const APP_COMMENTS: &[(&str, &str)] = &[(
353    "version",
354    "Configuration format version (for future compatibility)",
355)];
356
357// Section header comments
358const SECTION_HEADERS: &[(&str, &str)] = &[
359    (
360        "cloud",
361        "# ============================================================================\n# Cloud / Object Storage (S3, MinIO)\n# ============================================================================\n# Optional overrides for s3:// URLs. Leave unset to use AWS defaults (env, ~/.aws/).\n# Set endpoint_url to use MinIO or other S3-compatible backends.",
362    ),
363    (
364        "file_loading",
365        "# ============================================================================\n# File Loading Defaults\n# ============================================================================",
366    ),
367    (
368        "display",
369        "# ============================================================================\n# Display Settings\n# ============================================================================",
370    ),
371    (
372        "performance",
373        "# ============================================================================\n# Performance Settings\n# ============================================================================",
374    ),
375    (
376        "chart",
377        "# ============================================================================\n# Chart View\n# ============================================================================",
378    ),
379    (
380        "theme",
381        "# ============================================================================\n# Color Theme\n# ============================================================================",
382    ),
383    (
384        "theme.colors",
385        "# Color definitions\n# Supported formats:\n#   - Named colors: \"red\", \"blue\", \"bright_red\", \"dark_gray\", etc. (case-insensitive)\n#   - Hex colors: \"#ff0000\" or \"#FF0000\" (case-insensitive)\n#   - Indexed colors: \"indexed(0-255)\" for specific xterm 256-color palette entries\n# Colors automatically adapt to your terminal's capabilities",
386    ),
387    (
388        "ui",
389        "# ============================================================================\n# UI Layout\n# ============================================================================",
390    ),
391    ("ui.controls", "# Control bar settings"),
392    (
393        "query",
394        "# ============================================================================\n# Query System\n# ============================================================================",
395    ),
396    (
397        "templates",
398        "# ============================================================================\n# Template Settings\n# ============================================================================",
399    ),
400    (
401        "debug",
402        "# ============================================================================\n# Debug Settings\n# ============================================================================",
403    ),
404];
405
406#[derive(Debug, Clone, Serialize, Deserialize, Default)]
407#[serde(default)]
408pub struct CloudConfig {
409    /// Custom endpoint for S3-compatible storage (e.g. MinIO). Example: "http://localhost:9000"
410    pub s3_endpoint_url: Option<String>,
411    /// Access key for S3-compatible backends when not using env / AWS config
412    pub s3_access_key_id: Option<String>,
413    /// Secret key for S3-compatible backends when not using env / AWS config
414    pub s3_secret_access_key: Option<String>,
415    /// Region (e.g. us-east-1). Often required when using a custom endpoint (MinIO uses us-east-1).
416    pub s3_region: Option<String>,
417}
418
419const CLOUD_COMMENTS: &[(&str, &str)] = &[
420    (
421        "s3_endpoint_url",
422        "Custom endpoint for S3-compatible storage (MinIO, etc.). Example: \"http://localhost:9000\". Unset = AWS.",
423    ),
424    (
425        "s3_access_key_id",
426        "Access key when using custom endpoint (or set AWS_ACCESS_KEY_ID).",
427    ),
428    (
429        "s3_secret_access_key",
430        "Secret key when using custom endpoint (or set AWS_SECRET_ACCESS_KEY).",
431    ),
432    (
433        "s3_region",
434        "Region (e.g. us-east-1). Required for custom endpoints; MinIO often uses us-east-1.",
435    ),
436];
437
438impl CloudConfig {
439    pub fn merge(&mut self, other: Self) {
440        if other.s3_endpoint_url.is_some() {
441            self.s3_endpoint_url = other.s3_endpoint_url;
442        }
443        if other.s3_access_key_id.is_some() {
444            self.s3_access_key_id = other.s3_access_key_id;
445        }
446        if other.s3_secret_access_key.is_some() {
447            self.s3_secret_access_key = other.s3_secret_access_key;
448        }
449        if other.s3_region.is_some() {
450            self.s3_region = other.s3_region;
451        }
452    }
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize, Default)]
456#[serde(default)]
457pub struct FileLoadingConfig {
458    pub delimiter: Option<u8>,
459    pub has_header: Option<bool>,
460    pub skip_lines: Option<usize>,
461    pub skip_rows: Option<usize>,
462    /// When true, CSV reader tries to parse string columns as dates (YYYY-MM-DD, ISO datetime). Default: true.
463    pub parse_dates: Option<bool>,
464    /// When true, decompress compressed CSV into memory (eager read). When false (default), decompress to a temp file and use lazy scan.
465    pub decompress_in_memory: Option<bool>,
466    /// Directory for decompression temp files. null = system default (e.g. TMPDIR).
467    pub temp_dir: Option<String>,
468    /// When true (default), infer Hive/partitioned Parquet schema from one file (single-spine) for faster "Caching schema". When false, use Polars collect_schema() over all files.
469    pub single_spine_schema: Option<bool>,
470}
471
472// Field comments for FileLoadingConfig
473// Format: (field_name, comment_text)
474const FILE_LOADING_COMMENTS: &[(&str, &str)] = &[
475    (
476        "delimiter",
477        "Default delimiter for CSV files (as ASCII value, e.g., 44 for comma)\nIf not specified, auto-detection is used",
478    ),
479    (
480        "has_header",
481        "Whether files have headers by default\nnull = auto-detect, true = has header, false = no header",
482    ),
483    ("skip_lines", "Number of lines to skip at the start of files"),
484    ("skip_rows", "Number of rows to skip when reading files"),
485    (
486        "parse_dates",
487        "When true (default), CSV reader tries to parse string columns as dates (e.g. YYYY-MM-DD, ISO datetime)",
488    ),
489    (
490        "decompress_in_memory",
491        "When true, decompress compressed CSV into memory (eager). When false (default), decompress to a temp file and use lazy scan",
492    ),
493    (
494        "temp_dir",
495        "Directory for decompression temp files. null = system default (e.g. TMPDIR)",
496    ),
497    (
498        "single_spine_schema",
499        "When true (default), infer Hive/partitioned Parquet schema from one file for faster load. When false, use full schema scan (Polars collect_schema).",
500    ),
501];
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
504#[serde(default)]
505pub struct DisplayConfig {
506    pub pages_lookahead: usize,
507    pub pages_lookback: usize,
508    /// Max rows in scroll buffer (0 = no limit).
509    pub max_buffered_rows: usize,
510    /// Max buffer size in MB (0 = no limit).
511    pub max_buffered_mb: usize,
512    pub row_numbers: bool,
513    pub row_start_index: usize,
514    pub table_cell_padding: usize,
515    /// When true, colorize main table cells by column type (string, int, float, bool, temporal).
516    pub column_colors: bool,
517    /// Optional fixed width for all sidebars (Info, Sort & Filter, Template, Pivot & Melt). When None, use built-in defaults per sidebar.
518    #[serde(default)]
519    pub sidebar_width: Option<u16>,
520}
521
522// Field comments for DisplayConfig
523const DISPLAY_COMMENTS: &[(&str, &str)] = &[
524    (
525        "pages_lookahead",
526        "Number of pages to buffer ahead of visible area\nLarger values = smoother scrolling but more memory",
527    ),
528    (
529        "pages_lookback",
530        "Number of pages to buffer behind visible area\nLarger values = smoother scrolling but more memory",
531    ),
532    (
533        "max_buffered_rows",
534        "Maximum rows in scroll buffer (0 = no limit)\nPrevents unbounded memory use when scrolling",
535    ),
536    (
537        "max_buffered_mb",
538        "Maximum buffer size in MB (0 = no limit)\nUses estimated memory; helps with very wide tables",
539    ),
540    ("row_numbers", "Display row numbers on the left side of the table"),
541    ("row_start_index", "Starting index for row numbers (0 or 1)"),
542    (
543        "table_cell_padding",
544        "Number of spaces between columns in the main data table (>= 0)\nDefault 2",
545    ),
546    (
547        "column_colors",
548        "Colorize main table cells by column type (string, int, float, bool, date/datetime)\nSet to false to use default text color for all cells",
549    ),
550    (
551        "sidebar_width",
552        "Optional: fixed width in characters for all sidebars (Info, Sort & Filter, Templates, Pivot & Melt). When unset, each sidebar uses its default width. Example: sidebar_width = 70",
553    ),
554];
555
556#[derive(Debug, Clone, Serialize, Deserialize)]
557#[serde(default)]
558pub struct PerformanceConfig {
559    /// When None, analysis uses full dataset (no sampling). When Some(n), datasets with >= n rows are sampled.
560    pub sampling_threshold: Option<usize>,
561    pub event_poll_interval_ms: u64,
562    /// When true (default), use Polars streaming engine for LazyFrame collect when the streaming feature is enabled (lower memory, batch processing).
563    pub polars_streaming: bool,
564}
565
566// Field comments for PerformanceConfig
567const PERFORMANCE_COMMENTS: &[(&str, &str)] = &[
568    (
569        "sampling_threshold",
570        "Optional: when set, datasets with >= this many rows are sampled for analysis (faster, less memory).\nWhen unset or omitted, full dataset is used. Example: sampling_threshold = 10000",
571    ),
572    (
573        "event_poll_interval_ms",
574        "Event polling interval in milliseconds\nLower values = more responsive but higher CPU usage",
575    ),
576    (
577        "polars_streaming",
578        "Use Polars streaming engine for LazyFrame collect when available (default: true). Reduces memory and can improve performance on large or partitioned data.",
579    ),
580];
581
582/// Default maximum rows used for chart data when not overridden by config or UI.
583pub const DEFAULT_CHART_ROW_LIMIT: usize = 10_000;
584/// Maximum chart row limit (Polars slice takes u32).
585pub const MAX_CHART_ROW_LIMIT: usize = u32::MAX as usize;
586
587#[derive(Debug, Clone, Serialize, Deserialize)]
588#[serde(default)]
589pub struct ChartConfig {
590    /// Maximum rows for chart data. None (null in TOML) = unlimited; Some(n) = cap at n. Default 10000.
591    pub row_limit: Option<usize>,
592}
593
594// Field comments for ChartConfig
595const CHART_COMMENTS: &[(&str, &str)] = &[
596    (
597        "row_limit",
598        "Maximum rows used when building charts (display and export).\nSet to null for unlimited (uses full dataset). Set to a number (e.g. 10000) to cap. Can also be changed in chart view (Limit Rows). Example: row_limit = 10000",
599    ),
600];
601
602impl Default for ChartConfig {
603    fn default() -> Self {
604        Self {
605            row_limit: Some(DEFAULT_CHART_ROW_LIMIT),
606        }
607    }
608}
609
610impl ChartConfig {
611    pub fn merge(&mut self, other: Self) {
612        if other.row_limit.is_some() {
613            self.row_limit = other.row_limit;
614        }
615    }
616}
617
618#[derive(Debug, Clone, Serialize, Deserialize, Default)]
619#[serde(default)]
620pub struct ThemeConfig {
621    pub colors: ColorConfig,
622}
623
624// Field comments for ThemeConfig
625const THEME_COMMENTS: &[(&str, &str)] = &[];
626
627fn default_row_numbers_color() -> String {
628    "dark_gray".to_string()
629}
630
631#[derive(Debug, Clone, Serialize, Deserialize)]
632#[serde(default)]
633/// Color configuration for the application theme.
634///
635/// This struct defines all color settings used throughout the UI. Colors can be specified as:
636/// - Named colors: "cyan", "red", "yellow", etc.
637/// - Hex colors: "#ff0000"
638/// - Indexed colors: "indexed(236)" for 256-color palette
639/// - Special modifiers: "reversed" for selected rows
640///
641/// ## Color Usage:
642///
643/// **UI Element Colors:**
644/// - `keybind_hints`: Keybind hints (modals, breadcrumb, correlation matrix)
645/// - `keybind_labels`: Action labels in controls bar
646/// - `throbber`: Busy indicator (spinner) in control bar
647/// - `table_header`: Table column header text
648/// - `table_header_bg`: Table column header background
649/// - `column_separator`: Vertical line between columns
650/// - `sidebar_border`: Sidebar borders
651/// - `modal_border_active`: Active modal elements
652/// - `modal_border_error`: Error modal borders
653///
654/// **Chart Colors:**
655/// - `primary_chart_series_color`: Chart data (histogram bars, Q-Q plot data points)
656/// - `secondary_chart_series_color`: Chart theory (histogram overlays, Q-Q plot reference line)
657///
658/// **Status Colors:**
659/// - `success`: Success indicators, normal distributions
660/// - `error`: Error messages, outliers
661/// - `warning`: Warnings, skewed distributions
662/// - `distribution_normal`: Normal distribution indicator
663/// - `distribution_skewed`: Skewed distribution indicator
664/// - `distribution_other`: Other distribution types
665/// - `outlier_marker`: Outlier indicators
666///
667/// **Text Colors:**
668/// - `text_primary`: Primary text
669/// - `text_secondary`: Secondary text
670/// - `text_inverse`: Text on light backgrounds
671///
672/// **Background Colors:**
673/// - `background`: Main background
674/// - `surface`: Modal/surface backgrounds
675/// - `controls_bg`: Controls bar and table header backgrounds
676///
677/// **Other:**
678/// - `dimmed`: Dimmed elements, axis lines
679/// - `table_selected`: Selected row style (special modifier)
680pub struct ColorConfig {
681    pub keybind_hints: String,
682    pub keybind_labels: String,
683    pub throbber: String,
684    pub primary_chart_series_color: String,
685    pub secondary_chart_series_color: String,
686    pub success: String,
687    pub error: String,
688    pub warning: String,
689    pub dimmed: String,
690    pub background: String,
691    pub surface: String,
692    pub controls_bg: String,
693    pub text_primary: String,
694    pub text_secondary: String,
695    pub text_inverse: String,
696    pub table_header: String,
697    pub table_header_bg: String,
698    /// Row numbers column text. Use "default" for terminal default.
699    #[serde(default = "default_row_numbers_color")]
700    pub row_numbers: String,
701    pub column_separator: String,
702    pub table_selected: String,
703    pub sidebar_border: String,
704    pub modal_border_active: String,
705    pub modal_border_error: String,
706    pub distribution_normal: String,
707    pub distribution_skewed: String,
708    pub distribution_other: String,
709    pub outlier_marker: String,
710    pub cursor_focused: String,
711    pub cursor_dimmed: String,
712    /// "default" = no alternate row color; any other value is parsed as a color (e.g. "dark_gray")
713    pub alternate_row_color: String,
714    /// Column type colors (main data table): string, integer, float, boolean, temporal
715    pub str_col: String,
716    pub int_col: String,
717    pub float_col: String,
718    pub bool_col: String,
719    pub temporal_col: String,
720    /// Chart view: series colors 1–7 (line/scatter/bar series)
721    pub chart_series_color_1: String,
722    pub chart_series_color_2: String,
723    pub chart_series_color_3: String,
724    pub chart_series_color_4: String,
725    pub chart_series_color_5: String,
726    pub chart_series_color_6: String,
727    pub chart_series_color_7: String,
728}
729
730// Field comments for ColorConfig
731const COLOR_COMMENTS: &[(&str, &str)] = &[
732    (
733        "keybind_hints",
734        "Keybind hints (modals, breadcrumb, correlation matrix)",
735    ),
736    ("keybind_labels", "Action labels in controls bar"),
737    ("throbber", "Busy indicator (spinner) in control bar"),
738    (
739        "primary_chart_series_color",
740        "Chart data (histogram bars, Q-Q plot data points)",
741    ),
742    (
743        "secondary_chart_series_color",
744        "Chart theory (histogram overlays, Q-Q plot reference line)",
745    ),
746    ("success", "Success indicators, normal distributions"),
747    ("error", "Error messages, outliers"),
748    ("warning", "Warnings, skewed distributions"),
749    ("dimmed", "Dimmed elements, axis lines"),
750    ("background", "Main background"),
751    ("surface", "Modal/surface backgrounds"),
752    ("controls_bg", "Controls bar background"),
753    ("text_primary", "Primary text"),
754    ("text_secondary", "Secondary text"),
755    ("text_inverse", "Text on light backgrounds"),
756    ("table_header", "Table column header text"),
757    ("table_header_bg", "Table column header background"),
758    ("row_numbers", "Row numbers column text; use \"default\" for terminal default"),
759    ("column_separator", "Vertical line between columns"),
760    ("table_selected", "Selected row style"),
761    ("sidebar_border", "Sidebar borders"),
762    ("modal_border_active", "Active modal elements"),
763    ("modal_border_error", "Error modal borders"),
764    ("distribution_normal", "Normal distribution indicator"),
765    ("distribution_skewed", "Skewed distribution indicator"),
766    ("distribution_other", "Other distribution types"),
767    ("outlier_marker", "Outlier indicators"),
768    (
769        "cursor_focused",
770        "Cursor color when text input is focused\nText under cursor uses reverse of this color",
771    ),
772    (
773        "cursor_dimmed",
774        "Cursor color when text input is unfocused (currently unused - unfocused inputs hide cursor)",
775    ),
776    (
777        "alternate_row_color",
778        "Background color for every other row in the main data table\nSet to \"default\" to disable alternate row coloring",
779    ),
780    ("str_col", "Main table: string column text color"),
781    ("int_col", "Main table: integer column text color"),
782    ("float_col", "Main table: float column text color"),
783    ("bool_col", "Main table: boolean column text color"),
784    ("temporal_col", "Main table: date/datetime/time column text color"),
785    ("chart_series_color_1", "Chart view: first series color"),
786    ("chart_series_color_2", "Chart view: second series color"),
787    ("chart_series_color_3", "Chart view: third series color"),
788    ("chart_series_color_4", "Chart view: fourth series color"),
789    ("chart_series_color_5", "Chart view: fifth series color"),
790    ("chart_series_color_6", "Chart view: sixth series color"),
791    ("chart_series_color_7", "Chart view: seventh series color"),
792];
793
794#[derive(Debug, Clone, Serialize, Deserialize, Default)]
795#[serde(default)]
796pub struct UiConfig {
797    pub controls: ControlsConfig,
798}
799
800#[derive(Debug, Clone, Serialize, Deserialize)]
801#[serde(default)]
802pub struct ControlsConfig {
803    pub custom_controls: Option<Vec<(String, String)>>,
804    pub row_count_width: usize,
805}
806
807// Field comments for ControlsConfig
808const CONTROLS_COMMENTS: &[(&str, &str)] = &[
809    (
810        "custom_controls",
811        "Custom control keybindings (optional)\nFormat: [[\"key\", \"label\"], [\"key\", \"label\"], ...]\nIf not specified, uses default controls",
812    ),
813    ("row_count_width", "Row count display width in characters"),
814];
815
816#[derive(Debug, Clone, Serialize, Deserialize)]
817#[serde(default)]
818pub struct QueryConfig {
819    pub history_limit: usize,
820    pub enable_history: bool,
821}
822
823// Field comments for QueryConfig
824const QUERY_COMMENTS: &[(&str, &str)] = &[
825    (
826        "history_limit",
827        "Maximum number of queries to keep in history",
828    ),
829    ("enable_history", "Enable query history caching"),
830];
831
832#[derive(Debug, Clone, Serialize, Deserialize, Default)]
833#[serde(default)]
834pub struct TemplateConfig {
835    pub auto_apply: bool,
836}
837
838// Field comments for TemplateConfig
839const TEMPLATE_COMMENTS: &[(&str, &str)] = &[(
840    "auto_apply",
841    "Auto-apply most relevant template on file open",
842)];
843
844#[derive(Debug, Clone, Serialize, Deserialize)]
845#[serde(default)]
846pub struct DebugConfig {
847    pub enabled: bool,
848    pub show_performance: bool,
849    pub show_query: bool,
850    pub show_transformations: bool,
851}
852
853// Field comments for DebugConfig
854const DEBUG_COMMENTS: &[(&str, &str)] = &[
855    ("enabled", "Enable debug overlay by default"),
856    (
857        "show_performance",
858        "Show performance metrics in debug overlay",
859    ),
860    ("show_query", "Show LazyFrame query in debug overlay"),
861    (
862        "show_transformations",
863        "Show transformation state in debug overlay",
864    ),
865];
866
867// Default implementations
868impl Default for AppConfig {
869    fn default() -> Self {
870        Self {
871            version: "0.2".to_string(),
872            cloud: CloudConfig::default(),
873            file_loading: FileLoadingConfig::default(),
874            display: DisplayConfig::default(),
875            performance: PerformanceConfig::default(),
876            chart: ChartConfig::default(),
877            theme: ThemeConfig::default(),
878            ui: UiConfig::default(),
879            query: QueryConfig::default(),
880            templates: TemplateConfig::default(),
881            debug: DebugConfig::default(),
882        }
883    }
884}
885
886impl Default for DisplayConfig {
887    fn default() -> Self {
888        Self {
889            pages_lookahead: 3,
890            pages_lookback: 3,
891            max_buffered_rows: 100_000,
892            max_buffered_mb: 512,
893            row_numbers: false,
894            row_start_index: 1,
895            table_cell_padding: 2,
896            column_colors: true,
897            sidebar_width: None,
898        }
899    }
900}
901
902impl Default for PerformanceConfig {
903    fn default() -> Self {
904        Self {
905            sampling_threshold: None,
906            event_poll_interval_ms: 25,
907            polars_streaming: true,
908        }
909    }
910}
911
912impl Default for ColorConfig {
913    fn default() -> Self {
914        Self {
915            keybind_hints: "cyan".to_string(),
916            keybind_labels: "indexed(252)".to_string(),
917            throbber: "cyan".to_string(),
918            primary_chart_series_color: "cyan".to_string(),
919            secondary_chart_series_color: "indexed(245)".to_string(),
920            success: "green".to_string(),
921            error: "red".to_string(),
922            warning: "yellow".to_string(),
923            dimmed: "dark_gray".to_string(),
924            background: "default".to_string(),
925            surface: "default".to_string(),
926            controls_bg: "indexed(235)".to_string(),
927            text_primary: "default".to_string(),
928            text_secondary: "indexed(240)".to_string(),
929            text_inverse: "black".to_string(),
930            table_header: "white".to_string(),
931            table_header_bg: "indexed(235)".to_string(),
932            row_numbers: "dark_gray".to_string(),
933            column_separator: "cyan".to_string(),
934            table_selected: "reversed".to_string(),
935            sidebar_border: "indexed(235)".to_string(),
936            modal_border_active: "yellow".to_string(),
937            modal_border_error: "red".to_string(),
938            distribution_normal: "green".to_string(),
939            distribution_skewed: "yellow".to_string(),
940            distribution_other: "white".to_string(),
941            outlier_marker: "red".to_string(),
942            cursor_focused: "default".to_string(),
943            cursor_dimmed: "default".to_string(),
944            alternate_row_color: "indexed(235)".to_string(),
945            str_col: "green".to_string(),
946            int_col: "cyan".to_string(),
947            float_col: "blue".to_string(),
948            bool_col: "yellow".to_string(),
949            temporal_col: "magenta".to_string(),
950            chart_series_color_1: "cyan".to_string(),
951            chart_series_color_2: "magenta".to_string(),
952            chart_series_color_3: "green".to_string(),
953            chart_series_color_4: "yellow".to_string(),
954            chart_series_color_5: "blue".to_string(),
955            chart_series_color_6: "red".to_string(),
956            chart_series_color_7: "bright_cyan".to_string(),
957        }
958    }
959}
960
961impl Default for ControlsConfig {
962    fn default() -> Self {
963        Self {
964            custom_controls: None,
965            row_count_width: 20,
966        }
967    }
968}
969
970impl Default for QueryConfig {
971    fn default() -> Self {
972        Self {
973            history_limit: 1000,
974            enable_history: true,
975        }
976    }
977}
978
979impl Default for DebugConfig {
980    fn default() -> Self {
981        Self {
982            enabled: false,
983            show_performance: true,
984            show_query: true,
985            show_transformations: true,
986        }
987    }
988}
989
990// Configuration loading and merging
991impl AppConfig {
992    /// Load configuration from all layers (default → user)
993    pub fn load(app_name: &str) -> Result<Self> {
994        let mut config = AppConfig::default();
995
996        // Try to load user config (if exists)
997        let config_path = ConfigManager::new(app_name)
998            .ok()
999            .map(|m| m.config_path("config.toml"));
1000        if let Ok(user_config) = Self::load_user_config(app_name) {
1001            config.merge(user_config);
1002        }
1003
1004        // Validate configuration (e.g. color names); report config file path on error
1005        config.validate().map_err(|e| {
1006            let path_hint = config_path
1007                .as_ref()
1008                .map(|p| format!(" in {}", p.display()))
1009                .unwrap_or_default();
1010            eyre!("Invalid configuration{}: {}", path_hint, e)
1011        })?;
1012
1013        Ok(config)
1014    }
1015
1016    /// Load user configuration from ~/.config/datui/config.toml
1017    fn load_user_config(app_name: &str) -> Result<AppConfig> {
1018        let config_manager = ConfigManager::new(app_name)?;
1019        let config_path = config_manager.config_path("config.toml");
1020
1021        if !config_path.exists() {
1022            return Ok(AppConfig::default());
1023        }
1024
1025        let content = std::fs::read_to_string(&config_path).map_err(|e| {
1026            eyre!(
1027                "Failed to read config file at {}: {}",
1028                config_path.display(),
1029                e
1030            )
1031        })?;
1032
1033        toml::from_str(&content).map_err(|e| {
1034            eyre!(
1035                "Failed to parse config file at {}: {}",
1036                config_path.display(),
1037                e
1038            )
1039        })
1040    }
1041
1042    /// Merge another config into this one (other takes precedence)
1043    pub fn merge(&mut self, other: AppConfig) {
1044        // Version: take other's version if present and different from default
1045        if other.version != AppConfig::default().version {
1046            self.version = other.version;
1047        }
1048
1049        // Merge each section
1050        self.cloud.merge(other.cloud);
1051        self.file_loading.merge(other.file_loading);
1052        self.display.merge(other.display);
1053        self.performance.merge(other.performance);
1054        self.chart.merge(other.chart);
1055        self.theme.merge(other.theme);
1056        self.ui.merge(other.ui);
1057        self.query.merge(other.query);
1058        self.templates.merge(other.templates);
1059        self.debug.merge(other.debug);
1060    }
1061
1062    /// Validate configuration values
1063    pub fn validate(&self) -> Result<()> {
1064        // Validate version compatibility
1065        if !self.version.starts_with("0.2") {
1066            return Err(eyre!(
1067                "Unsupported config version: {}. Expected 0.2.x",
1068                self.version
1069            ));
1070        }
1071
1072        // Validate performance settings
1073        if let Some(t) = self.performance.sampling_threshold {
1074            if t == 0 {
1075                return Err(eyre!("sampling_threshold must be greater than 0 when set"));
1076            }
1077        }
1078
1079        if self.performance.event_poll_interval_ms == 0 {
1080            return Err(eyre!("event_poll_interval_ms must be greater than 0"));
1081        }
1082
1083        if let Some(n) = self.chart.row_limit {
1084            if n == 0 || n > MAX_CHART_ROW_LIMIT {
1085                return Err(eyre!(
1086                    "chart.row_limit must be between 1 and {} when set, got {}",
1087                    MAX_CHART_ROW_LIMIT,
1088                    n
1089                ));
1090            }
1091        }
1092
1093        // Validate all colors can be parsed
1094        let parser = ColorParser::new();
1095        self.theme.colors.validate(&parser)?;
1096
1097        Ok(())
1098    }
1099}
1100
1101// Merge implementations for each config section
1102impl FileLoadingConfig {
1103    pub fn merge(&mut self, other: Self) {
1104        if other.delimiter.is_some() {
1105            self.delimiter = other.delimiter;
1106        }
1107        if other.has_header.is_some() {
1108            self.has_header = other.has_header;
1109        }
1110        if other.skip_lines.is_some() {
1111            self.skip_lines = other.skip_lines;
1112        }
1113        if other.skip_rows.is_some() {
1114            self.skip_rows = other.skip_rows;
1115        }
1116        if other.parse_dates.is_some() {
1117            self.parse_dates = other.parse_dates;
1118        }
1119        if other.decompress_in_memory.is_some() {
1120            self.decompress_in_memory = other.decompress_in_memory;
1121        }
1122        if other.temp_dir.is_some() {
1123            self.temp_dir = other.temp_dir.clone();
1124        }
1125        if other.single_spine_schema.is_some() {
1126            self.single_spine_schema = other.single_spine_schema;
1127        }
1128    }
1129}
1130
1131impl DisplayConfig {
1132    pub fn merge(&mut self, other: Self) {
1133        let default = DisplayConfig::default();
1134        if other.pages_lookahead != default.pages_lookahead {
1135            self.pages_lookahead = other.pages_lookahead;
1136        }
1137        if other.pages_lookback != default.pages_lookback {
1138            self.pages_lookback = other.pages_lookback;
1139        }
1140        if other.max_buffered_rows != default.max_buffered_rows {
1141            self.max_buffered_rows = other.max_buffered_rows;
1142        }
1143        if other.max_buffered_mb != default.max_buffered_mb {
1144            self.max_buffered_mb = other.max_buffered_mb;
1145        }
1146        if other.row_numbers != default.row_numbers {
1147            self.row_numbers = other.row_numbers;
1148        }
1149        if other.row_start_index != default.row_start_index {
1150            self.row_start_index = other.row_start_index;
1151        }
1152        if other.table_cell_padding != default.table_cell_padding {
1153            self.table_cell_padding = other.table_cell_padding;
1154        }
1155        if other.column_colors != default.column_colors {
1156            self.column_colors = other.column_colors;
1157        }
1158        if other.sidebar_width != default.sidebar_width {
1159            self.sidebar_width = other.sidebar_width;
1160        }
1161    }
1162}
1163
1164impl PerformanceConfig {
1165    pub fn merge(&mut self, other: Self) {
1166        let default = PerformanceConfig::default();
1167        if other.sampling_threshold != default.sampling_threshold {
1168            self.sampling_threshold = other.sampling_threshold;
1169        }
1170        if other.event_poll_interval_ms != default.event_poll_interval_ms {
1171            self.event_poll_interval_ms = other.event_poll_interval_ms;
1172        }
1173        if other.polars_streaming != default.polars_streaming {
1174            self.polars_streaming = other.polars_streaming;
1175        }
1176    }
1177}
1178
1179impl ThemeConfig {
1180    pub fn merge(&mut self, other: Self) {
1181        self.colors.merge(other.colors);
1182    }
1183}
1184
1185impl ColorConfig {
1186    /// Validate all color strings can be parsed
1187    fn validate(&self, parser: &ColorParser) -> Result<()> {
1188        // Helper macro to validate a color field (reports as theme.colors.<name> for config file context)
1189        macro_rules! validate_color {
1190            ($field:expr, $name:expr) => {
1191                parser.parse($field).map_err(|e| {
1192                    eyre!(
1193                        "theme.colors.{}: {}. Use a valid color name (e.g. red, cyan, bright_red), \
1194                         hex (#rrggbb), or indexed(0-255)",
1195                        $name,
1196                        e
1197                    )
1198                })?;
1199            };
1200        }
1201
1202        validate_color!(&self.keybind_hints, "keybind_hints");
1203        validate_color!(&self.keybind_labels, "keybind_labels");
1204        validate_color!(&self.throbber, "throbber");
1205        validate_color!(
1206            &self.primary_chart_series_color,
1207            "primary_chart_series_color"
1208        );
1209        validate_color!(
1210            &self.secondary_chart_series_color,
1211            "secondary_chart_series_color"
1212        );
1213        validate_color!(&self.success, "success");
1214        validate_color!(&self.error, "error");
1215        validate_color!(&self.warning, "warning");
1216        validate_color!(&self.dimmed, "dimmed");
1217        validate_color!(&self.background, "background");
1218        validate_color!(&self.surface, "surface");
1219        validate_color!(&self.controls_bg, "controls_bg");
1220        validate_color!(&self.text_primary, "text_primary");
1221        validate_color!(&self.text_secondary, "text_secondary");
1222        validate_color!(&self.text_inverse, "text_inverse");
1223        validate_color!(&self.table_header, "table_header");
1224        validate_color!(&self.table_header_bg, "table_header_bg");
1225        validate_color!(&self.row_numbers, "row_numbers");
1226        validate_color!(&self.column_separator, "column_separator");
1227        validate_color!(&self.table_selected, "table_selected");
1228        validate_color!(&self.sidebar_border, "sidebar_border");
1229        validate_color!(&self.modal_border_active, "modal_border_active");
1230        validate_color!(&self.modal_border_error, "modal_border_error");
1231        validate_color!(&self.distribution_normal, "distribution_normal");
1232        validate_color!(&self.distribution_skewed, "distribution_skewed");
1233        validate_color!(&self.distribution_other, "distribution_other");
1234        validate_color!(&self.outlier_marker, "outlier_marker");
1235        validate_color!(&self.cursor_focused, "cursor_focused");
1236        validate_color!(&self.cursor_dimmed, "cursor_dimmed");
1237        if self.alternate_row_color != "default" {
1238            validate_color!(&self.alternate_row_color, "alternate_row_color");
1239        }
1240        validate_color!(&self.str_col, "str_col");
1241        validate_color!(&self.int_col, "int_col");
1242        validate_color!(&self.float_col, "float_col");
1243        validate_color!(&self.bool_col, "bool_col");
1244        validate_color!(&self.temporal_col, "temporal_col");
1245        validate_color!(&self.chart_series_color_1, "chart_series_color_1");
1246        validate_color!(&self.chart_series_color_2, "chart_series_color_2");
1247        validate_color!(&self.chart_series_color_3, "chart_series_color_3");
1248        validate_color!(&self.chart_series_color_4, "chart_series_color_4");
1249        validate_color!(&self.chart_series_color_5, "chart_series_color_5");
1250        validate_color!(&self.chart_series_color_6, "chart_series_color_6");
1251        validate_color!(&self.chart_series_color_7, "chart_series_color_7");
1252
1253        Ok(())
1254    }
1255
1256    pub fn merge(&mut self, other: Self) {
1257        let default = ColorConfig::default();
1258
1259        // Macro would be nice here, but keeping it explicit for clarity
1260        if other.keybind_hints != default.keybind_hints {
1261            self.keybind_hints = other.keybind_hints;
1262        }
1263        if other.keybind_labels != default.keybind_labels {
1264            self.keybind_labels = other.keybind_labels;
1265        }
1266        if other.throbber != default.throbber {
1267            self.throbber = other.throbber;
1268        }
1269        if other.primary_chart_series_color != default.primary_chart_series_color {
1270            self.primary_chart_series_color = other.primary_chart_series_color;
1271        }
1272        if other.secondary_chart_series_color != default.secondary_chart_series_color {
1273            self.secondary_chart_series_color = other.secondary_chart_series_color;
1274        }
1275        if other.success != default.success {
1276            self.success = other.success;
1277        }
1278        if other.error != default.error {
1279            self.error = other.error;
1280        }
1281        if other.warning != default.warning {
1282            self.warning = other.warning;
1283        }
1284        if other.dimmed != default.dimmed {
1285            self.dimmed = other.dimmed;
1286        }
1287        if other.background != default.background {
1288            self.background = other.background;
1289        }
1290        if other.surface != default.surface {
1291            self.surface = other.surface;
1292        }
1293        if other.controls_bg != default.controls_bg {
1294            self.controls_bg = other.controls_bg;
1295        }
1296        if other.text_primary != default.text_primary {
1297            self.text_primary = other.text_primary;
1298        }
1299        if other.text_secondary != default.text_secondary {
1300            self.text_secondary = other.text_secondary;
1301        }
1302        if other.text_inverse != default.text_inverse {
1303            self.text_inverse = other.text_inverse;
1304        }
1305        if other.table_header != default.table_header {
1306            self.table_header = other.table_header;
1307        }
1308        if other.table_header_bg != default.table_header_bg {
1309            self.table_header_bg = other.table_header_bg;
1310        }
1311        if other.row_numbers != default.row_numbers {
1312            self.row_numbers = other.row_numbers;
1313        }
1314        if other.column_separator != default.column_separator {
1315            self.column_separator = other.column_separator;
1316        }
1317        if other.table_selected != default.table_selected {
1318            self.table_selected = other.table_selected;
1319        }
1320        if other.sidebar_border != default.sidebar_border {
1321            self.sidebar_border = other.sidebar_border;
1322        }
1323        if other.modal_border_active != default.modal_border_active {
1324            self.modal_border_active = other.modal_border_active;
1325        }
1326        if other.modal_border_error != default.modal_border_error {
1327            self.modal_border_error = other.modal_border_error;
1328        }
1329        if other.distribution_normal != default.distribution_normal {
1330            self.distribution_normal = other.distribution_normal;
1331        }
1332        if other.distribution_skewed != default.distribution_skewed {
1333            self.distribution_skewed = other.distribution_skewed;
1334        }
1335        if other.distribution_other != default.distribution_other {
1336            self.distribution_other = other.distribution_other;
1337        }
1338        if other.outlier_marker != default.outlier_marker {
1339            self.outlier_marker = other.outlier_marker;
1340        }
1341        if other.cursor_focused != default.cursor_focused {
1342            self.cursor_focused = other.cursor_focused;
1343        }
1344        if other.cursor_dimmed != default.cursor_dimmed {
1345            self.cursor_dimmed = other.cursor_dimmed;
1346        }
1347        if other.alternate_row_color != default.alternate_row_color {
1348            self.alternate_row_color = other.alternate_row_color;
1349        }
1350        if other.str_col != default.str_col {
1351            self.str_col = other.str_col;
1352        }
1353        if other.int_col != default.int_col {
1354            self.int_col = other.int_col;
1355        }
1356        if other.float_col != default.float_col {
1357            self.float_col = other.float_col;
1358        }
1359        if other.bool_col != default.bool_col {
1360            self.bool_col = other.bool_col;
1361        }
1362        if other.temporal_col != default.temporal_col {
1363            self.temporal_col = other.temporal_col;
1364        }
1365        if other.chart_series_color_1 != default.chart_series_color_1 {
1366            self.chart_series_color_1 = other.chart_series_color_1;
1367        }
1368        if other.chart_series_color_2 != default.chart_series_color_2 {
1369            self.chart_series_color_2 = other.chart_series_color_2;
1370        }
1371        if other.chart_series_color_3 != default.chart_series_color_3 {
1372            self.chart_series_color_3 = other.chart_series_color_3;
1373        }
1374        if other.chart_series_color_4 != default.chart_series_color_4 {
1375            self.chart_series_color_4 = other.chart_series_color_4;
1376        }
1377        if other.chart_series_color_5 != default.chart_series_color_5 {
1378            self.chart_series_color_5 = other.chart_series_color_5;
1379        }
1380        if other.chart_series_color_6 != default.chart_series_color_6 {
1381            self.chart_series_color_6 = other.chart_series_color_6;
1382        }
1383        if other.chart_series_color_7 != default.chart_series_color_7 {
1384            self.chart_series_color_7 = other.chart_series_color_7;
1385        }
1386    }
1387}
1388
1389impl UiConfig {
1390    pub fn merge(&mut self, other: Self) {
1391        self.controls.merge(other.controls);
1392    }
1393}
1394
1395impl ControlsConfig {
1396    pub fn merge(&mut self, other: Self) {
1397        if other.custom_controls.is_some() {
1398            self.custom_controls = other.custom_controls;
1399        }
1400        let default = ControlsConfig::default();
1401        if other.row_count_width != default.row_count_width {
1402            self.row_count_width = other.row_count_width;
1403        }
1404    }
1405}
1406
1407impl QueryConfig {
1408    pub fn merge(&mut self, other: Self) {
1409        let default = QueryConfig::default();
1410        if other.history_limit != default.history_limit {
1411            self.history_limit = other.history_limit;
1412        }
1413        if other.enable_history != default.enable_history {
1414            self.enable_history = other.enable_history;
1415        }
1416    }
1417}
1418
1419impl TemplateConfig {
1420    pub fn merge(&mut self, other: Self) {
1421        let default = TemplateConfig::default();
1422        if other.auto_apply != default.auto_apply {
1423            self.auto_apply = other.auto_apply;
1424        }
1425    }
1426}
1427
1428impl DebugConfig {
1429    pub fn merge(&mut self, other: Self) {
1430        let default = DebugConfig::default();
1431        if other.enabled != default.enabled {
1432            self.enabled = other.enabled;
1433        }
1434        if other.show_performance != default.show_performance {
1435            self.show_performance = other.show_performance;
1436        }
1437        if other.show_query != default.show_query {
1438            self.show_query = other.show_query;
1439        }
1440        if other.show_transformations != default.show_transformations {
1441            self.show_transformations = other.show_transformations;
1442        }
1443    }
1444}
1445
1446/// Color parser with terminal capability detection
1447pub struct ColorParser {
1448    supports_true_color: bool,
1449    supports_256: bool,
1450    no_color: bool,
1451}
1452
1453impl ColorParser {
1454    /// Create a new ColorParser with automatic terminal capability detection
1455    pub fn new() -> Self {
1456        let no_color = std::env::var("NO_COLOR").is_ok();
1457        let support = supports_color::on(Stream::Stdout);
1458
1459        Self {
1460            supports_true_color: support.as_ref().map(|s| s.has_16m).unwrap_or(false),
1461            supports_256: support.as_ref().map(|s| s.has_256).unwrap_or(false),
1462            no_color,
1463        }
1464    }
1465
1466    /// Parse a color string (hex or named) and convert to appropriate terminal color
1467    pub fn parse(&self, s: &str) -> Result<Color> {
1468        if self.no_color {
1469            return Ok(Color::Reset);
1470        }
1471
1472        let trimmed = s.trim();
1473
1474        // Hex format: "#ff0000" or "#FF0000" (6-character hex)
1475        if trimmed.starts_with('#') && trimmed.len() == 7 {
1476            let (r, g, b) = parse_hex(trimmed)?;
1477            return Ok(self.convert_rgb_to_terminal_color(r, g, b));
1478        }
1479
1480        // Indexed colors: "indexed(236)" for explicit 256-color palette
1481        if trimmed.to_lowercase().starts_with("indexed(") && trimmed.ends_with(')') {
1482            let num_str = &trimmed[8..trimmed.len() - 1]; // Extract number between parentheses
1483            let num = num_str.parse::<u8>().map_err(|_| {
1484                eyre!(
1485                    "Invalid indexed color: '{}'. Expected format: indexed(0-255)",
1486                    trimmed
1487                )
1488            })?;
1489            return Ok(Color::Indexed(num));
1490        }
1491
1492        // Named colors (case-insensitive)
1493        let lower = trimmed.to_lowercase();
1494        match lower.as_str() {
1495            // Basic ANSI colors
1496            "black" => Ok(Color::Black),
1497            "red" => Ok(Color::Red),
1498            "green" => Ok(Color::Green),
1499            "yellow" => Ok(Color::Yellow),
1500            "blue" => Ok(Color::Blue),
1501            "magenta" => Ok(Color::Magenta),
1502            "cyan" => Ok(Color::Cyan),
1503            "white" => Ok(Color::White),
1504
1505            // Bright variants (256-color palette)
1506            "bright_black" | "bright black" => Ok(Color::Indexed(8)),
1507            "bright_red" | "bright red" => Ok(Color::Indexed(9)),
1508            "bright_green" | "bright green" => Ok(Color::Indexed(10)),
1509            "bright_yellow" | "bright yellow" => Ok(Color::Indexed(11)),
1510            "bright_blue" | "bright blue" => Ok(Color::Indexed(12)),
1511            "bright_magenta" | "bright magenta" => Ok(Color::Indexed(13)),
1512            "bright_cyan" | "bright cyan" => Ok(Color::Indexed(14)),
1513            "bright_white" | "bright white" => Ok(Color::Indexed(15)),
1514
1515            // Gray aliases
1516            "gray" | "grey" => Ok(Color::Indexed(8)),
1517            "dark_gray" | "dark gray" | "dark_grey" | "dark grey" => Ok(Color::Indexed(8)),
1518            "light_gray" | "light gray" | "light_grey" | "light grey" => Ok(Color::Indexed(7)),
1519
1520            // Special modifiers (pass through as Reset - handled specially in rendering)
1521            "reset" | "default" | "none" | "reversed" => Ok(Color::Reset),
1522
1523            _ => Err(eyre!(
1524                "Unknown color name: '{}'. Supported: basic ANSI colors (red, blue, etc.), \
1525                 bright variants (bright_red, etc.), or hex colors (#ff0000)",
1526                trimmed
1527            )),
1528        }
1529    }
1530
1531    /// Convert RGB values to appropriate terminal color based on capabilities
1532    fn convert_rgb_to_terminal_color(&self, r: u8, g: u8, b: u8) -> Color {
1533        if self.supports_true_color {
1534            Color::Rgb(r, g, b)
1535        } else if self.supports_256 {
1536            Color::Indexed(rgb_to_256_color(r, g, b))
1537        } else {
1538            rgb_to_basic_ansi(r, g, b)
1539        }
1540    }
1541}
1542
1543impl Default for ColorParser {
1544    fn default() -> Self {
1545        Self::new()
1546    }
1547}
1548
1549/// Parse hex color string (#ff0000) to RGB components
1550fn parse_hex(s: &str) -> Result<(u8, u8, u8)> {
1551    if !s.starts_with('#') || s.len() != 7 {
1552        return Err(eyre!(
1553            "Invalid hex color format: '{}'. Expected format: #rrggbb",
1554            s
1555        ));
1556    }
1557
1558    let r = u8::from_str_radix(&s[1..3], 16)
1559        .map_err(|_| eyre!("Invalid red component in hex color: {}", s))?;
1560    let g = u8::from_str_radix(&s[3..5], 16)
1561        .map_err(|_| eyre!("Invalid green component in hex color: {}", s))?;
1562    let b = u8::from_str_radix(&s[5..7], 16)
1563        .map_err(|_| eyre!("Invalid blue component in hex color: {}", s))?;
1564
1565    Ok((r, g, b))
1566}
1567
1568/// Convert RGB to nearest 256-color palette index
1569/// Uses standard xterm 256-color palette
1570pub fn rgb_to_256_color(r: u8, g: u8, b: u8) -> u8 {
1571    // Check if it's a gray shade (r ≈ g ≈ b)
1572    let max_diff = r.max(g).max(b) as i16 - r.min(g).min(b) as i16;
1573    if max_diff < 10 {
1574        // Map to grayscale ramp (232-255)
1575        let gray = (r as u16 + g as u16 + b as u16) / 3;
1576        if gray < 8 {
1577            return 16; // Black
1578        } else if gray > 247 {
1579            return 231; // White
1580        } else {
1581            return 232 + ((gray - 8) * 24 / 240) as u8;
1582        }
1583    }
1584
1585    // Map to 6x6x6 color cube (16-231)
1586    let r_idx = (r as u16 * 5 / 255) as u8;
1587    let g_idx = (g as u16 * 5 / 255) as u8;
1588    let b_idx = (b as u16 * 5 / 255) as u8;
1589
1590    16 + 36 * r_idx + 6 * g_idx + b_idx
1591}
1592
1593/// Convert RGB to nearest basic ANSI color (8 colors)
1594pub fn rgb_to_basic_ansi(r: u8, g: u8, b: u8) -> Color {
1595    // Simple threshold-based conversion
1596    let r_bright = r > 128;
1597    let g_bright = g > 128;
1598    let b_bright = b > 128;
1599
1600    // Check for grayscale
1601    let max_diff = r.max(g).max(b) as i16 - r.min(g).min(b) as i16;
1602    if max_diff < 30 {
1603        let avg = (r as u16 + g as u16 + b as u16) / 3;
1604        return if avg < 64 { Color::Black } else { Color::White };
1605    }
1606
1607    // Map to primary/secondary colors
1608    match (r_bright, g_bright, b_bright) {
1609        (false, false, false) => Color::Black,
1610        (true, false, false) => Color::Red,
1611        (false, true, false) => Color::Green,
1612        (true, true, false) => Color::Yellow,
1613        (false, false, true) => Color::Blue,
1614        (true, false, true) => Color::Magenta,
1615        (false, true, true) => Color::Cyan,
1616        (true, true, true) => Color::White,
1617    }
1618}
1619
1620/// Theme containing parsed colors ready for use
1621#[derive(Debug, Clone)]
1622pub struct Theme {
1623    pub colors: HashMap<String, Color>,
1624}
1625
1626impl Theme {
1627    /// Create a Theme from a ThemeConfig by parsing all color strings
1628    pub fn from_config(config: &ThemeConfig) -> Result<Self> {
1629        let parser = ColorParser::new();
1630        let mut colors = HashMap::new();
1631
1632        // Parse all colors from config
1633        colors.insert(
1634            "keybind_hints".to_string(),
1635            parser.parse(&config.colors.keybind_hints)?,
1636        );
1637        colors.insert(
1638            "keybind_labels".to_string(),
1639            parser.parse(&config.colors.keybind_labels)?,
1640        );
1641        colors.insert(
1642            "throbber".to_string(),
1643            parser.parse(&config.colors.throbber)?,
1644        );
1645        colors.insert(
1646            "primary_chart_series_color".to_string(),
1647            parser.parse(&config.colors.primary_chart_series_color)?,
1648        );
1649        colors.insert(
1650            "secondary_chart_series_color".to_string(),
1651            parser.parse(&config.colors.secondary_chart_series_color)?,
1652        );
1653        colors.insert("success".to_string(), parser.parse(&config.colors.success)?);
1654        colors.insert("error".to_string(), parser.parse(&config.colors.error)?);
1655        colors.insert("warning".to_string(), parser.parse(&config.colors.warning)?);
1656        colors.insert("dimmed".to_string(), parser.parse(&config.colors.dimmed)?);
1657        colors.insert(
1658            "background".to_string(),
1659            parser.parse(&config.colors.background)?,
1660        );
1661        colors.insert("surface".to_string(), parser.parse(&config.colors.surface)?);
1662        colors.insert(
1663            "controls_bg".to_string(),
1664            parser.parse(&config.colors.controls_bg)?,
1665        );
1666        colors.insert(
1667            "text_primary".to_string(),
1668            parser.parse(&config.colors.text_primary)?,
1669        );
1670        colors.insert(
1671            "text_secondary".to_string(),
1672            parser.parse(&config.colors.text_secondary)?,
1673        );
1674        colors.insert(
1675            "text_inverse".to_string(),
1676            parser.parse(&config.colors.text_inverse)?,
1677        );
1678        colors.insert(
1679            "table_header".to_string(),
1680            parser.parse(&config.colors.table_header)?,
1681        );
1682        colors.insert(
1683            "table_header_bg".to_string(),
1684            parser.parse(&config.colors.table_header_bg)?,
1685        );
1686        colors.insert(
1687            "row_numbers".to_string(),
1688            parser.parse(&config.colors.row_numbers)?,
1689        );
1690        colors.insert(
1691            "column_separator".to_string(),
1692            parser.parse(&config.colors.column_separator)?,
1693        );
1694        colors.insert(
1695            "table_selected".to_string(),
1696            parser.parse(&config.colors.table_selected)?,
1697        );
1698        colors.insert(
1699            "sidebar_border".to_string(),
1700            parser.parse(&config.colors.sidebar_border)?,
1701        );
1702        colors.insert(
1703            "modal_border_active".to_string(),
1704            parser.parse(&config.colors.modal_border_active)?,
1705        );
1706        colors.insert(
1707            "modal_border_error".to_string(),
1708            parser.parse(&config.colors.modal_border_error)?,
1709        );
1710        colors.insert(
1711            "distribution_normal".to_string(),
1712            parser.parse(&config.colors.distribution_normal)?,
1713        );
1714        colors.insert(
1715            "distribution_skewed".to_string(),
1716            parser.parse(&config.colors.distribution_skewed)?,
1717        );
1718        colors.insert(
1719            "distribution_other".to_string(),
1720            parser.parse(&config.colors.distribution_other)?,
1721        );
1722        colors.insert(
1723            "outlier_marker".to_string(),
1724            parser.parse(&config.colors.outlier_marker)?,
1725        );
1726        colors.insert(
1727            "cursor_focused".to_string(),
1728            parser.parse(&config.colors.cursor_focused)?,
1729        );
1730        colors.insert(
1731            "cursor_dimmed".to_string(),
1732            parser.parse(&config.colors.cursor_dimmed)?,
1733        );
1734        if config.colors.alternate_row_color != "default" {
1735            colors.insert(
1736                "alternate_row_color".to_string(),
1737                parser.parse(&config.colors.alternate_row_color)?,
1738            );
1739        }
1740        colors.insert("str_col".to_string(), parser.parse(&config.colors.str_col)?);
1741        colors.insert("int_col".to_string(), parser.parse(&config.colors.int_col)?);
1742        colors.insert(
1743            "float_col".to_string(),
1744            parser.parse(&config.colors.float_col)?,
1745        );
1746        colors.insert(
1747            "bool_col".to_string(),
1748            parser.parse(&config.colors.bool_col)?,
1749        );
1750        colors.insert(
1751            "temporal_col".to_string(),
1752            parser.parse(&config.colors.temporal_col)?,
1753        );
1754        colors.insert(
1755            "chart_series_color_1".to_string(),
1756            parser.parse(&config.colors.chart_series_color_1)?,
1757        );
1758        colors.insert(
1759            "chart_series_color_2".to_string(),
1760            parser.parse(&config.colors.chart_series_color_2)?,
1761        );
1762        colors.insert(
1763            "chart_series_color_3".to_string(),
1764            parser.parse(&config.colors.chart_series_color_3)?,
1765        );
1766        colors.insert(
1767            "chart_series_color_4".to_string(),
1768            parser.parse(&config.colors.chart_series_color_4)?,
1769        );
1770        colors.insert(
1771            "chart_series_color_5".to_string(),
1772            parser.parse(&config.colors.chart_series_color_5)?,
1773        );
1774        colors.insert(
1775            "chart_series_color_6".to_string(),
1776            parser.parse(&config.colors.chart_series_color_6)?,
1777        );
1778        colors.insert(
1779            "chart_series_color_7".to_string(),
1780            parser.parse(&config.colors.chart_series_color_7)?,
1781        );
1782
1783        Ok(Self { colors })
1784    }
1785
1786    /// Get a color by name, returns Reset if not found
1787    pub fn get(&self, name: &str) -> Color {
1788        self.colors.get(name).copied().unwrap_or(Color::Reset)
1789    }
1790
1791    /// Get a color by name, returns None if not found
1792    pub fn get_optional(&self, name: &str) -> Option<Color> {
1793        self.colors.get(name).copied()
1794    }
1795}