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    /// CSV null values: list of strings. Plain string = treat as null in all columns; "COL=VAL" = treat VAL as null only in column COL (first "=" separates). Example: ["NA", "amount="].
471    pub null_values: Option<Vec<String>>,
472}
473
474// Field comments for FileLoadingConfig
475// Format: (field_name, comment_text)
476const FILE_LOADING_COMMENTS: &[(&str, &str)] = &[
477    (
478        "delimiter",
479        "Default delimiter for CSV files (as ASCII value, e.g., 44 for comma)\nIf not specified, auto-detection is used",
480    ),
481    (
482        "has_header",
483        "Whether files have headers by default\nnull = auto-detect, true = has header, false = no header",
484    ),
485    ("skip_lines", "Number of lines to skip at the start of files"),
486    ("skip_rows", "Number of rows to skip when reading files"),
487    (
488        "parse_dates",
489        "When true (default), CSV reader tries to parse string columns as dates (e.g. YYYY-MM-DD, ISO datetime)",
490    ),
491    (
492        "decompress_in_memory",
493        "When true, decompress compressed CSV into memory (eager). When false (default), decompress to a temp file and use lazy scan",
494    ),
495    (
496        "temp_dir",
497        "Directory for decompression temp files. null = system default (e.g. TMPDIR)",
498    ),
499    (
500        "single_spine_schema",
501        "When true (default), infer Hive/partitioned Parquet schema from one file for faster load. When false, use full schema scan (Polars collect_schema).",
502    ),
503    (
504        "null_values",
505        "CSV: values to treat as null. Plain string = all columns; \"COL=VAL\" = column COL only. Example: [\"NA\", \"amount=\"]",
506    ),
507];
508
509#[derive(Debug, Clone, Serialize, Deserialize)]
510#[serde(default)]
511pub struct DisplayConfig {
512    pub pages_lookahead: usize,
513    pub pages_lookback: usize,
514    /// Max rows in scroll buffer (0 = no limit).
515    pub max_buffered_rows: usize,
516    /// Max buffer size in MB (0 = no limit).
517    pub max_buffered_mb: usize,
518    pub row_numbers: bool,
519    pub row_start_index: usize,
520    pub table_cell_padding: usize,
521    /// When true, colorize main table cells by column type (string, int, float, bool, temporal).
522    pub column_colors: bool,
523    /// Optional fixed width for all sidebars (Info, Sort & Filter, Template, Pivot & Melt). When None, use built-in defaults per sidebar.
524    #[serde(default)]
525    pub sidebar_width: Option<u16>,
526}
527
528// Field comments for DisplayConfig
529const DISPLAY_COMMENTS: &[(&str, &str)] = &[
530    (
531        "pages_lookahead",
532        "Number of pages to buffer ahead of visible area\nLarger values = smoother scrolling but more memory",
533    ),
534    (
535        "pages_lookback",
536        "Number of pages to buffer behind visible area\nLarger values = smoother scrolling but more memory",
537    ),
538    (
539        "max_buffered_rows",
540        "Maximum rows in scroll buffer (0 = no limit)\nPrevents unbounded memory use when scrolling",
541    ),
542    (
543        "max_buffered_mb",
544        "Maximum buffer size in MB (0 = no limit)\nUses estimated memory; helps with very wide tables",
545    ),
546    ("row_numbers", "Display row numbers on the left side of the table"),
547    ("row_start_index", "Starting index for row numbers (0 or 1)"),
548    (
549        "table_cell_padding",
550        "Number of spaces between columns in the main data table (>= 0)\nDefault 2",
551    ),
552    (
553        "column_colors",
554        "Colorize main table cells by column type (string, int, float, bool, date/datetime)\nSet to false to use default text color for all cells",
555    ),
556    (
557        "sidebar_width",
558        "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",
559    ),
560];
561
562#[derive(Debug, Clone, Serialize, Deserialize)]
563#[serde(default)]
564pub struct PerformanceConfig {
565    /// When None, analysis uses full dataset (no sampling). When Some(n), datasets with >= n rows are sampled.
566    pub sampling_threshold: Option<usize>,
567    pub event_poll_interval_ms: u64,
568    /// When true (default), use Polars streaming engine for LazyFrame collect when the streaming feature is enabled (lower memory, batch processing).
569    pub polars_streaming: bool,
570}
571
572// Field comments for PerformanceConfig
573const PERFORMANCE_COMMENTS: &[(&str, &str)] = &[
574    (
575        "sampling_threshold",
576        "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",
577    ),
578    (
579        "event_poll_interval_ms",
580        "Event polling interval in milliseconds\nLower values = more responsive but higher CPU usage",
581    ),
582    (
583        "polars_streaming",
584        "Use Polars streaming engine for LazyFrame collect when available (default: true). Reduces memory and can improve performance on large or partitioned data.",
585    ),
586];
587
588/// Default maximum rows used for chart data when not overridden by config or UI.
589pub const DEFAULT_CHART_ROW_LIMIT: usize = 10_000;
590/// Maximum chart row limit (Polars slice takes u32).
591pub const MAX_CHART_ROW_LIMIT: usize = u32::MAX as usize;
592
593#[derive(Debug, Clone, Serialize, Deserialize)]
594#[serde(default)]
595pub struct ChartConfig {
596    /// Maximum rows for chart data. None (null in TOML) = unlimited; Some(n) = cap at n. Default 10000.
597    pub row_limit: Option<usize>,
598}
599
600// Field comments for ChartConfig
601const CHART_COMMENTS: &[(&str, &str)] = &[
602    (
603        "row_limit",
604        "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",
605    ),
606];
607
608impl Default for ChartConfig {
609    fn default() -> Self {
610        Self {
611            row_limit: Some(DEFAULT_CHART_ROW_LIMIT),
612        }
613    }
614}
615
616impl ChartConfig {
617    pub fn merge(&mut self, other: Self) {
618        if other.row_limit.is_some() {
619            self.row_limit = other.row_limit;
620        }
621    }
622}
623
624#[derive(Debug, Clone, Serialize, Deserialize, Default)]
625#[serde(default)]
626pub struct ThemeConfig {
627    pub colors: ColorConfig,
628}
629
630// Field comments for ThemeConfig
631const THEME_COMMENTS: &[(&str, &str)] = &[];
632
633fn default_row_numbers_color() -> String {
634    "dark_gray".to_string()
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize)]
638#[serde(default)]
639/// Color configuration for the application theme.
640///
641/// This struct defines all color settings used throughout the UI. Colors can be specified as:
642/// - Named colors: "cyan", "red", "yellow", etc.
643/// - Hex colors: "#ff0000"
644/// - Indexed colors: "indexed(236)" for 256-color palette
645/// - Special modifiers: "reversed" for selected rows
646///
647/// ## Color Usage:
648///
649/// **UI Element Colors:**
650/// - `keybind_hints`: Keybind hints (modals, breadcrumb, correlation matrix)
651/// - `keybind_labels`: Action labels in controls bar
652/// - `throbber`: Busy indicator (spinner) in control bar
653/// - `table_header`: Table column header text
654/// - `table_header_bg`: Table column header background
655/// - `column_separator`: Vertical line between columns
656/// - `sidebar_border`: Sidebar borders
657/// - `modal_border_active`: Active modal elements
658/// - `modal_border_error`: Error modal borders
659///
660/// **Chart Colors:**
661/// - `primary_chart_series_color`: Chart data (histogram bars, Q-Q plot data points)
662/// - `secondary_chart_series_color`: Chart theory (histogram overlays, Q-Q plot reference line)
663///
664/// **Status Colors:**
665/// - `success`: Success indicators, normal distributions
666/// - `error`: Error messages, outliers
667/// - `warning`: Warnings, skewed distributions
668/// - `distribution_normal`: Normal distribution indicator
669/// - `distribution_skewed`: Skewed distribution indicator
670/// - `distribution_other`: Other distribution types
671/// - `outlier_marker`: Outlier indicators
672///
673/// **Text Colors:**
674/// - `text_primary`: Primary text
675/// - `text_secondary`: Secondary text
676/// - `text_inverse`: Text on light backgrounds
677///
678/// **Background Colors:**
679/// - `background`: Main background
680/// - `surface`: Modal/surface backgrounds
681/// - `controls_bg`: Controls bar and table header backgrounds
682///
683/// **Other:**
684/// - `dimmed`: Dimmed elements, axis lines
685/// - `table_selected`: Selected row style (special modifier)
686pub struct ColorConfig {
687    pub keybind_hints: String,
688    pub keybind_labels: String,
689    pub throbber: String,
690    pub primary_chart_series_color: String,
691    pub secondary_chart_series_color: String,
692    pub success: String,
693    pub error: String,
694    pub warning: String,
695    pub dimmed: String,
696    pub background: String,
697    pub surface: String,
698    pub controls_bg: String,
699    pub text_primary: String,
700    pub text_secondary: String,
701    pub text_inverse: String,
702    pub table_header: String,
703    pub table_header_bg: String,
704    /// Row numbers column text. Use "default" for terminal default.
705    #[serde(default = "default_row_numbers_color")]
706    pub row_numbers: String,
707    pub column_separator: String,
708    pub table_selected: String,
709    pub sidebar_border: String,
710    pub modal_border_active: String,
711    pub modal_border_error: String,
712    pub distribution_normal: String,
713    pub distribution_skewed: String,
714    pub distribution_other: String,
715    pub outlier_marker: String,
716    pub cursor_focused: String,
717    pub cursor_dimmed: String,
718    /// "default" = no alternate row color; any other value is parsed as a color (e.g. "dark_gray")
719    pub alternate_row_color: String,
720    /// Column type colors (main data table): string, integer, float, boolean, temporal
721    pub str_col: String,
722    pub int_col: String,
723    pub float_col: String,
724    pub bool_col: String,
725    pub temporal_col: String,
726    /// Chart view: series colors 1–7 (line/scatter/bar series)
727    pub chart_series_color_1: String,
728    pub chart_series_color_2: String,
729    pub chart_series_color_3: String,
730    pub chart_series_color_4: String,
731    pub chart_series_color_5: String,
732    pub chart_series_color_6: String,
733    pub chart_series_color_7: String,
734}
735
736// Field comments for ColorConfig
737const COLOR_COMMENTS: &[(&str, &str)] = &[
738    (
739        "keybind_hints",
740        "Keybind hints (modals, breadcrumb, correlation matrix)",
741    ),
742    ("keybind_labels", "Action labels in controls bar"),
743    ("throbber", "Busy indicator (spinner) in control bar"),
744    (
745        "primary_chart_series_color",
746        "Chart data (histogram bars, Q-Q plot data points)",
747    ),
748    (
749        "secondary_chart_series_color",
750        "Chart theory (histogram overlays, Q-Q plot reference line)",
751    ),
752    ("success", "Success indicators, normal distributions"),
753    ("error", "Error messages, outliers"),
754    ("warning", "Warnings, skewed distributions"),
755    ("dimmed", "Dimmed elements, axis lines"),
756    ("background", "Main background"),
757    ("surface", "Modal/surface backgrounds"),
758    ("controls_bg", "Controls bar background"),
759    ("text_primary", "Primary text"),
760    ("text_secondary", "Secondary text"),
761    ("text_inverse", "Text on light backgrounds"),
762    ("table_header", "Table column header text"),
763    ("table_header_bg", "Table column header background"),
764    ("row_numbers", "Row numbers column text; use \"default\" for terminal default"),
765    ("column_separator", "Vertical line between columns"),
766    ("table_selected", "Selected row style"),
767    ("sidebar_border", "Sidebar borders"),
768    ("modal_border_active", "Active modal elements"),
769    ("modal_border_error", "Error modal borders"),
770    ("distribution_normal", "Normal distribution indicator"),
771    ("distribution_skewed", "Skewed distribution indicator"),
772    ("distribution_other", "Other distribution types"),
773    ("outlier_marker", "Outlier indicators"),
774    (
775        "cursor_focused",
776        "Cursor color when text input is focused\nText under cursor uses reverse of this color",
777    ),
778    (
779        "cursor_dimmed",
780        "Cursor color when text input is unfocused (currently unused - unfocused inputs hide cursor)",
781    ),
782    (
783        "alternate_row_color",
784        "Background color for every other row in the main data table\nSet to \"default\" to disable alternate row coloring",
785    ),
786    ("str_col", "Main table: string column text color"),
787    ("int_col", "Main table: integer column text color"),
788    ("float_col", "Main table: float column text color"),
789    ("bool_col", "Main table: boolean column text color"),
790    ("temporal_col", "Main table: date/datetime/time column text color"),
791    ("chart_series_color_1", "Chart view: first series color"),
792    ("chart_series_color_2", "Chart view: second series color"),
793    ("chart_series_color_3", "Chart view: third series color"),
794    ("chart_series_color_4", "Chart view: fourth series color"),
795    ("chart_series_color_5", "Chart view: fifth series color"),
796    ("chart_series_color_6", "Chart view: sixth series color"),
797    ("chart_series_color_7", "Chart view: seventh series color"),
798];
799
800#[derive(Debug, Clone, Serialize, Deserialize, Default)]
801#[serde(default)]
802pub struct UiConfig {
803    pub controls: ControlsConfig,
804}
805
806#[derive(Debug, Clone, Serialize, Deserialize)]
807#[serde(default)]
808pub struct ControlsConfig {
809    pub custom_controls: Option<Vec<(String, String)>>,
810    pub row_count_width: usize,
811}
812
813// Field comments for ControlsConfig
814const CONTROLS_COMMENTS: &[(&str, &str)] = &[
815    (
816        "custom_controls",
817        "Custom control keybindings (optional)\nFormat: [[\"key\", \"label\"], [\"key\", \"label\"], ...]\nIf not specified, uses default controls",
818    ),
819    ("row_count_width", "Row count display width in characters"),
820];
821
822#[derive(Debug, Clone, Serialize, Deserialize)]
823#[serde(default)]
824pub struct QueryConfig {
825    pub history_limit: usize,
826    pub enable_history: bool,
827}
828
829// Field comments for QueryConfig
830const QUERY_COMMENTS: &[(&str, &str)] = &[
831    (
832        "history_limit",
833        "Maximum number of queries to keep in history",
834    ),
835    ("enable_history", "Enable query history caching"),
836];
837
838#[derive(Debug, Clone, Serialize, Deserialize, Default)]
839#[serde(default)]
840pub struct TemplateConfig {
841    pub auto_apply: bool,
842}
843
844// Field comments for TemplateConfig
845const TEMPLATE_COMMENTS: &[(&str, &str)] = &[(
846    "auto_apply",
847    "Auto-apply most relevant template on file open",
848)];
849
850#[derive(Debug, Clone, Serialize, Deserialize)]
851#[serde(default)]
852pub struct DebugConfig {
853    pub enabled: bool,
854    pub show_performance: bool,
855    pub show_query: bool,
856    pub show_transformations: bool,
857}
858
859// Field comments for DebugConfig
860const DEBUG_COMMENTS: &[(&str, &str)] = &[
861    ("enabled", "Enable debug overlay by default"),
862    (
863        "show_performance",
864        "Show performance metrics in debug overlay",
865    ),
866    ("show_query", "Show LazyFrame query in debug overlay"),
867    (
868        "show_transformations",
869        "Show transformation state in debug overlay",
870    ),
871];
872
873// Default implementations
874impl Default for AppConfig {
875    fn default() -> Self {
876        Self {
877            version: "0.2".to_string(),
878            cloud: CloudConfig::default(),
879            file_loading: FileLoadingConfig::default(),
880            display: DisplayConfig::default(),
881            performance: PerformanceConfig::default(),
882            chart: ChartConfig::default(),
883            theme: ThemeConfig::default(),
884            ui: UiConfig::default(),
885            query: QueryConfig::default(),
886            templates: TemplateConfig::default(),
887            debug: DebugConfig::default(),
888        }
889    }
890}
891
892impl Default for DisplayConfig {
893    fn default() -> Self {
894        Self {
895            pages_lookahead: 3,
896            pages_lookback: 3,
897            max_buffered_rows: 100_000,
898            max_buffered_mb: 512,
899            row_numbers: false,
900            row_start_index: 1,
901            table_cell_padding: 2,
902            column_colors: true,
903            sidebar_width: None,
904        }
905    }
906}
907
908impl Default for PerformanceConfig {
909    fn default() -> Self {
910        Self {
911            sampling_threshold: None,
912            event_poll_interval_ms: 25,
913            polars_streaming: true,
914        }
915    }
916}
917
918impl Default for ColorConfig {
919    fn default() -> Self {
920        Self {
921            keybind_hints: "cyan".to_string(),
922            keybind_labels: "indexed(252)".to_string(),
923            throbber: "cyan".to_string(),
924            primary_chart_series_color: "cyan".to_string(),
925            secondary_chart_series_color: "indexed(245)".to_string(),
926            success: "green".to_string(),
927            error: "red".to_string(),
928            warning: "yellow".to_string(),
929            dimmed: "dark_gray".to_string(),
930            background: "default".to_string(),
931            surface: "default".to_string(),
932            controls_bg: "indexed(235)".to_string(),
933            text_primary: "default".to_string(),
934            text_secondary: "indexed(240)".to_string(),
935            text_inverse: "black".to_string(),
936            table_header: "white".to_string(),
937            table_header_bg: "indexed(235)".to_string(),
938            row_numbers: "dark_gray".to_string(),
939            column_separator: "cyan".to_string(),
940            table_selected: "reversed".to_string(),
941            sidebar_border: "indexed(235)".to_string(),
942            modal_border_active: "yellow".to_string(),
943            modal_border_error: "red".to_string(),
944            distribution_normal: "green".to_string(),
945            distribution_skewed: "yellow".to_string(),
946            distribution_other: "white".to_string(),
947            outlier_marker: "red".to_string(),
948            cursor_focused: "default".to_string(),
949            cursor_dimmed: "default".to_string(),
950            alternate_row_color: "indexed(235)".to_string(),
951            str_col: "green".to_string(),
952            int_col: "cyan".to_string(),
953            float_col: "blue".to_string(),
954            bool_col: "yellow".to_string(),
955            temporal_col: "magenta".to_string(),
956            chart_series_color_1: "cyan".to_string(),
957            chart_series_color_2: "magenta".to_string(),
958            chart_series_color_3: "green".to_string(),
959            chart_series_color_4: "yellow".to_string(),
960            chart_series_color_5: "blue".to_string(),
961            chart_series_color_6: "red".to_string(),
962            chart_series_color_7: "bright_cyan".to_string(),
963        }
964    }
965}
966
967impl Default for ControlsConfig {
968    fn default() -> Self {
969        Self {
970            custom_controls: None,
971            row_count_width: 20,
972        }
973    }
974}
975
976impl Default for QueryConfig {
977    fn default() -> Self {
978        Self {
979            history_limit: 1000,
980            enable_history: true,
981        }
982    }
983}
984
985impl Default for DebugConfig {
986    fn default() -> Self {
987        Self {
988            enabled: false,
989            show_performance: true,
990            show_query: true,
991            show_transformations: true,
992        }
993    }
994}
995
996// Configuration loading and merging
997impl AppConfig {
998    /// Load configuration from all layers (default → user)
999    pub fn load(app_name: &str) -> Result<Self> {
1000        let mut config = AppConfig::default();
1001
1002        // Try to load user config (if exists)
1003        let config_path = ConfigManager::new(app_name)
1004            .ok()
1005            .map(|m| m.config_path("config.toml"));
1006        if let Ok(user_config) = Self::load_user_config(app_name) {
1007            config.merge(user_config);
1008        }
1009
1010        // Validate configuration (e.g. color names); report config file path on error
1011        config.validate().map_err(|e| {
1012            let path_hint = config_path
1013                .as_ref()
1014                .map(|p| format!(" in {}", p.display()))
1015                .unwrap_or_default();
1016            eyre!("Invalid configuration{}: {}", path_hint, e)
1017        })?;
1018
1019        Ok(config)
1020    }
1021
1022    /// Load user configuration from ~/.config/datui/config.toml
1023    fn load_user_config(app_name: &str) -> Result<AppConfig> {
1024        let config_manager = ConfigManager::new(app_name)?;
1025        let config_path = config_manager.config_path("config.toml");
1026
1027        if !config_path.exists() {
1028            return Ok(AppConfig::default());
1029        }
1030
1031        let content = std::fs::read_to_string(&config_path).map_err(|e| {
1032            eyre!(
1033                "Failed to read config file at {}: {}",
1034                config_path.display(),
1035                e
1036            )
1037        })?;
1038
1039        toml::from_str(&content).map_err(|e| {
1040            eyre!(
1041                "Failed to parse config file at {}: {}",
1042                config_path.display(),
1043                e
1044            )
1045        })
1046    }
1047
1048    /// Merge another config into this one (other takes precedence)
1049    pub fn merge(&mut self, other: AppConfig) {
1050        // Version: take other's version if present and different from default
1051        if other.version != AppConfig::default().version {
1052            self.version = other.version;
1053        }
1054
1055        // Merge each section
1056        self.cloud.merge(other.cloud);
1057        self.file_loading.merge(other.file_loading);
1058        self.display.merge(other.display);
1059        self.performance.merge(other.performance);
1060        self.chart.merge(other.chart);
1061        self.theme.merge(other.theme);
1062        self.ui.merge(other.ui);
1063        self.query.merge(other.query);
1064        self.templates.merge(other.templates);
1065        self.debug.merge(other.debug);
1066    }
1067
1068    /// Validate configuration values
1069    pub fn validate(&self) -> Result<()> {
1070        // Validate version compatibility
1071        if !self.version.starts_with("0.2") {
1072            return Err(eyre!(
1073                "Unsupported config version: {}. Expected 0.2.x",
1074                self.version
1075            ));
1076        }
1077
1078        // Validate performance settings
1079        if let Some(t) = self.performance.sampling_threshold {
1080            if t == 0 {
1081                return Err(eyre!("sampling_threshold must be greater than 0 when set"));
1082            }
1083        }
1084
1085        if self.performance.event_poll_interval_ms == 0 {
1086            return Err(eyre!("event_poll_interval_ms must be greater than 0"));
1087        }
1088
1089        if let Some(n) = self.chart.row_limit {
1090            if n == 0 || n > MAX_CHART_ROW_LIMIT {
1091                return Err(eyre!(
1092                    "chart.row_limit must be between 1 and {} when set, got {}",
1093                    MAX_CHART_ROW_LIMIT,
1094                    n
1095                ));
1096            }
1097        }
1098
1099        // Validate all colors can be parsed
1100        let parser = ColorParser::new();
1101        self.theme.colors.validate(&parser)?;
1102
1103        Ok(())
1104    }
1105}
1106
1107// Merge implementations for each config section
1108impl FileLoadingConfig {
1109    pub fn merge(&mut self, other: Self) {
1110        if other.delimiter.is_some() {
1111            self.delimiter = other.delimiter;
1112        }
1113        if other.has_header.is_some() {
1114            self.has_header = other.has_header;
1115        }
1116        if other.skip_lines.is_some() {
1117            self.skip_lines = other.skip_lines;
1118        }
1119        if other.skip_rows.is_some() {
1120            self.skip_rows = other.skip_rows;
1121        }
1122        if other.parse_dates.is_some() {
1123            self.parse_dates = other.parse_dates;
1124        }
1125        if other.decompress_in_memory.is_some() {
1126            self.decompress_in_memory = other.decompress_in_memory;
1127        }
1128        if other.temp_dir.is_some() {
1129            self.temp_dir = other.temp_dir.clone();
1130        }
1131        if other.single_spine_schema.is_some() {
1132            self.single_spine_schema = other.single_spine_schema;
1133        }
1134        if other.null_values.is_some() {
1135            self.null_values = other.null_values.clone();
1136        }
1137    }
1138}
1139
1140impl DisplayConfig {
1141    pub fn merge(&mut self, other: Self) {
1142        let default = DisplayConfig::default();
1143        if other.pages_lookahead != default.pages_lookahead {
1144            self.pages_lookahead = other.pages_lookahead;
1145        }
1146        if other.pages_lookback != default.pages_lookback {
1147            self.pages_lookback = other.pages_lookback;
1148        }
1149        if other.max_buffered_rows != default.max_buffered_rows {
1150            self.max_buffered_rows = other.max_buffered_rows;
1151        }
1152        if other.max_buffered_mb != default.max_buffered_mb {
1153            self.max_buffered_mb = other.max_buffered_mb;
1154        }
1155        if other.row_numbers != default.row_numbers {
1156            self.row_numbers = other.row_numbers;
1157        }
1158        if other.row_start_index != default.row_start_index {
1159            self.row_start_index = other.row_start_index;
1160        }
1161        if other.table_cell_padding != default.table_cell_padding {
1162            self.table_cell_padding = other.table_cell_padding;
1163        }
1164        if other.column_colors != default.column_colors {
1165            self.column_colors = other.column_colors;
1166        }
1167        if other.sidebar_width != default.sidebar_width {
1168            self.sidebar_width = other.sidebar_width;
1169        }
1170    }
1171}
1172
1173impl PerformanceConfig {
1174    pub fn merge(&mut self, other: Self) {
1175        let default = PerformanceConfig::default();
1176        if other.sampling_threshold != default.sampling_threshold {
1177            self.sampling_threshold = other.sampling_threshold;
1178        }
1179        if other.event_poll_interval_ms != default.event_poll_interval_ms {
1180            self.event_poll_interval_ms = other.event_poll_interval_ms;
1181        }
1182        if other.polars_streaming != default.polars_streaming {
1183            self.polars_streaming = other.polars_streaming;
1184        }
1185    }
1186}
1187
1188impl ThemeConfig {
1189    pub fn merge(&mut self, other: Self) {
1190        self.colors.merge(other.colors);
1191    }
1192}
1193
1194impl ColorConfig {
1195    /// Validate all color strings can be parsed
1196    fn validate(&self, parser: &ColorParser) -> Result<()> {
1197        // Helper macro to validate a color field (reports as theme.colors.<name> for config file context)
1198        macro_rules! validate_color {
1199            ($field:expr, $name:expr) => {
1200                parser.parse($field).map_err(|e| {
1201                    eyre!(
1202                        "theme.colors.{}: {}. Use a valid color name (e.g. red, cyan, bright_red), \
1203                         hex (#rrggbb), or indexed(0-255)",
1204                        $name,
1205                        e
1206                    )
1207                })?;
1208            };
1209        }
1210
1211        validate_color!(&self.keybind_hints, "keybind_hints");
1212        validate_color!(&self.keybind_labels, "keybind_labels");
1213        validate_color!(&self.throbber, "throbber");
1214        validate_color!(
1215            &self.primary_chart_series_color,
1216            "primary_chart_series_color"
1217        );
1218        validate_color!(
1219            &self.secondary_chart_series_color,
1220            "secondary_chart_series_color"
1221        );
1222        validate_color!(&self.success, "success");
1223        validate_color!(&self.error, "error");
1224        validate_color!(&self.warning, "warning");
1225        validate_color!(&self.dimmed, "dimmed");
1226        validate_color!(&self.background, "background");
1227        validate_color!(&self.surface, "surface");
1228        validate_color!(&self.controls_bg, "controls_bg");
1229        validate_color!(&self.text_primary, "text_primary");
1230        validate_color!(&self.text_secondary, "text_secondary");
1231        validate_color!(&self.text_inverse, "text_inverse");
1232        validate_color!(&self.table_header, "table_header");
1233        validate_color!(&self.table_header_bg, "table_header_bg");
1234        validate_color!(&self.row_numbers, "row_numbers");
1235        validate_color!(&self.column_separator, "column_separator");
1236        validate_color!(&self.table_selected, "table_selected");
1237        validate_color!(&self.sidebar_border, "sidebar_border");
1238        validate_color!(&self.modal_border_active, "modal_border_active");
1239        validate_color!(&self.modal_border_error, "modal_border_error");
1240        validate_color!(&self.distribution_normal, "distribution_normal");
1241        validate_color!(&self.distribution_skewed, "distribution_skewed");
1242        validate_color!(&self.distribution_other, "distribution_other");
1243        validate_color!(&self.outlier_marker, "outlier_marker");
1244        validate_color!(&self.cursor_focused, "cursor_focused");
1245        validate_color!(&self.cursor_dimmed, "cursor_dimmed");
1246        if self.alternate_row_color != "default" {
1247            validate_color!(&self.alternate_row_color, "alternate_row_color");
1248        }
1249        validate_color!(&self.str_col, "str_col");
1250        validate_color!(&self.int_col, "int_col");
1251        validate_color!(&self.float_col, "float_col");
1252        validate_color!(&self.bool_col, "bool_col");
1253        validate_color!(&self.temporal_col, "temporal_col");
1254        validate_color!(&self.chart_series_color_1, "chart_series_color_1");
1255        validate_color!(&self.chart_series_color_2, "chart_series_color_2");
1256        validate_color!(&self.chart_series_color_3, "chart_series_color_3");
1257        validate_color!(&self.chart_series_color_4, "chart_series_color_4");
1258        validate_color!(&self.chart_series_color_5, "chart_series_color_5");
1259        validate_color!(&self.chart_series_color_6, "chart_series_color_6");
1260        validate_color!(&self.chart_series_color_7, "chart_series_color_7");
1261
1262        Ok(())
1263    }
1264
1265    pub fn merge(&mut self, other: Self) {
1266        let default = ColorConfig::default();
1267
1268        // Macro would be nice here, but keeping it explicit for clarity
1269        if other.keybind_hints != default.keybind_hints {
1270            self.keybind_hints = other.keybind_hints;
1271        }
1272        if other.keybind_labels != default.keybind_labels {
1273            self.keybind_labels = other.keybind_labels;
1274        }
1275        if other.throbber != default.throbber {
1276            self.throbber = other.throbber;
1277        }
1278        if other.primary_chart_series_color != default.primary_chart_series_color {
1279            self.primary_chart_series_color = other.primary_chart_series_color;
1280        }
1281        if other.secondary_chart_series_color != default.secondary_chart_series_color {
1282            self.secondary_chart_series_color = other.secondary_chart_series_color;
1283        }
1284        if other.success != default.success {
1285            self.success = other.success;
1286        }
1287        if other.error != default.error {
1288            self.error = other.error;
1289        }
1290        if other.warning != default.warning {
1291            self.warning = other.warning;
1292        }
1293        if other.dimmed != default.dimmed {
1294            self.dimmed = other.dimmed;
1295        }
1296        if other.background != default.background {
1297            self.background = other.background;
1298        }
1299        if other.surface != default.surface {
1300            self.surface = other.surface;
1301        }
1302        if other.controls_bg != default.controls_bg {
1303            self.controls_bg = other.controls_bg;
1304        }
1305        if other.text_primary != default.text_primary {
1306            self.text_primary = other.text_primary;
1307        }
1308        if other.text_secondary != default.text_secondary {
1309            self.text_secondary = other.text_secondary;
1310        }
1311        if other.text_inverse != default.text_inverse {
1312            self.text_inverse = other.text_inverse;
1313        }
1314        if other.table_header != default.table_header {
1315            self.table_header = other.table_header;
1316        }
1317        if other.table_header_bg != default.table_header_bg {
1318            self.table_header_bg = other.table_header_bg;
1319        }
1320        if other.row_numbers != default.row_numbers {
1321            self.row_numbers = other.row_numbers;
1322        }
1323        if other.column_separator != default.column_separator {
1324            self.column_separator = other.column_separator;
1325        }
1326        if other.table_selected != default.table_selected {
1327            self.table_selected = other.table_selected;
1328        }
1329        if other.sidebar_border != default.sidebar_border {
1330            self.sidebar_border = other.sidebar_border;
1331        }
1332        if other.modal_border_active != default.modal_border_active {
1333            self.modal_border_active = other.modal_border_active;
1334        }
1335        if other.modal_border_error != default.modal_border_error {
1336            self.modal_border_error = other.modal_border_error;
1337        }
1338        if other.distribution_normal != default.distribution_normal {
1339            self.distribution_normal = other.distribution_normal;
1340        }
1341        if other.distribution_skewed != default.distribution_skewed {
1342            self.distribution_skewed = other.distribution_skewed;
1343        }
1344        if other.distribution_other != default.distribution_other {
1345            self.distribution_other = other.distribution_other;
1346        }
1347        if other.outlier_marker != default.outlier_marker {
1348            self.outlier_marker = other.outlier_marker;
1349        }
1350        if other.cursor_focused != default.cursor_focused {
1351            self.cursor_focused = other.cursor_focused;
1352        }
1353        if other.cursor_dimmed != default.cursor_dimmed {
1354            self.cursor_dimmed = other.cursor_dimmed;
1355        }
1356        if other.alternate_row_color != default.alternate_row_color {
1357            self.alternate_row_color = other.alternate_row_color;
1358        }
1359        if other.str_col != default.str_col {
1360            self.str_col = other.str_col;
1361        }
1362        if other.int_col != default.int_col {
1363            self.int_col = other.int_col;
1364        }
1365        if other.float_col != default.float_col {
1366            self.float_col = other.float_col;
1367        }
1368        if other.bool_col != default.bool_col {
1369            self.bool_col = other.bool_col;
1370        }
1371        if other.temporal_col != default.temporal_col {
1372            self.temporal_col = other.temporal_col;
1373        }
1374        if other.chart_series_color_1 != default.chart_series_color_1 {
1375            self.chart_series_color_1 = other.chart_series_color_1;
1376        }
1377        if other.chart_series_color_2 != default.chart_series_color_2 {
1378            self.chart_series_color_2 = other.chart_series_color_2;
1379        }
1380        if other.chart_series_color_3 != default.chart_series_color_3 {
1381            self.chart_series_color_3 = other.chart_series_color_3;
1382        }
1383        if other.chart_series_color_4 != default.chart_series_color_4 {
1384            self.chart_series_color_4 = other.chart_series_color_4;
1385        }
1386        if other.chart_series_color_5 != default.chart_series_color_5 {
1387            self.chart_series_color_5 = other.chart_series_color_5;
1388        }
1389        if other.chart_series_color_6 != default.chart_series_color_6 {
1390            self.chart_series_color_6 = other.chart_series_color_6;
1391        }
1392        if other.chart_series_color_7 != default.chart_series_color_7 {
1393            self.chart_series_color_7 = other.chart_series_color_7;
1394        }
1395    }
1396}
1397
1398impl UiConfig {
1399    pub fn merge(&mut self, other: Self) {
1400        self.controls.merge(other.controls);
1401    }
1402}
1403
1404impl ControlsConfig {
1405    pub fn merge(&mut self, other: Self) {
1406        if other.custom_controls.is_some() {
1407            self.custom_controls = other.custom_controls;
1408        }
1409        let default = ControlsConfig::default();
1410        if other.row_count_width != default.row_count_width {
1411            self.row_count_width = other.row_count_width;
1412        }
1413    }
1414}
1415
1416impl QueryConfig {
1417    pub fn merge(&mut self, other: Self) {
1418        let default = QueryConfig::default();
1419        if other.history_limit != default.history_limit {
1420            self.history_limit = other.history_limit;
1421        }
1422        if other.enable_history != default.enable_history {
1423            self.enable_history = other.enable_history;
1424        }
1425    }
1426}
1427
1428impl TemplateConfig {
1429    pub fn merge(&mut self, other: Self) {
1430        let default = TemplateConfig::default();
1431        if other.auto_apply != default.auto_apply {
1432            self.auto_apply = other.auto_apply;
1433        }
1434    }
1435}
1436
1437impl DebugConfig {
1438    pub fn merge(&mut self, other: Self) {
1439        let default = DebugConfig::default();
1440        if other.enabled != default.enabled {
1441            self.enabled = other.enabled;
1442        }
1443        if other.show_performance != default.show_performance {
1444            self.show_performance = other.show_performance;
1445        }
1446        if other.show_query != default.show_query {
1447            self.show_query = other.show_query;
1448        }
1449        if other.show_transformations != default.show_transformations {
1450            self.show_transformations = other.show_transformations;
1451        }
1452    }
1453}
1454
1455/// Color parser with terminal capability detection
1456pub struct ColorParser {
1457    supports_true_color: bool,
1458    supports_256: bool,
1459    no_color: bool,
1460}
1461
1462impl ColorParser {
1463    /// Create a new ColorParser with automatic terminal capability detection
1464    pub fn new() -> Self {
1465        let no_color = std::env::var("NO_COLOR").is_ok();
1466        let support = supports_color::on(Stream::Stdout);
1467
1468        Self {
1469            supports_true_color: support.as_ref().map(|s| s.has_16m).unwrap_or(false),
1470            supports_256: support.as_ref().map(|s| s.has_256).unwrap_or(false),
1471            no_color,
1472        }
1473    }
1474
1475    /// Parse a color string (hex or named) and convert to appropriate terminal color
1476    pub fn parse(&self, s: &str) -> Result<Color> {
1477        if self.no_color {
1478            return Ok(Color::Reset);
1479        }
1480
1481        let trimmed = s.trim();
1482
1483        // Hex format: "#ff0000" or "#FF0000" (6-character hex)
1484        if trimmed.starts_with('#') && trimmed.len() == 7 {
1485            let (r, g, b) = parse_hex(trimmed)?;
1486            return Ok(self.convert_rgb_to_terminal_color(r, g, b));
1487        }
1488
1489        // Indexed colors: "indexed(236)" for explicit 256-color palette
1490        if trimmed.to_lowercase().starts_with("indexed(") && trimmed.ends_with(')') {
1491            let num_str = &trimmed[8..trimmed.len() - 1]; // Extract number between parentheses
1492            let num = num_str.parse::<u8>().map_err(|_| {
1493                eyre!(
1494                    "Invalid indexed color: '{}'. Expected format: indexed(0-255)",
1495                    trimmed
1496                )
1497            })?;
1498            return Ok(Color::Indexed(num));
1499        }
1500
1501        // Named colors (case-insensitive)
1502        let lower = trimmed.to_lowercase();
1503        match lower.as_str() {
1504            // Basic ANSI colors
1505            "black" => Ok(Color::Black),
1506            "red" => Ok(Color::Red),
1507            "green" => Ok(Color::Green),
1508            "yellow" => Ok(Color::Yellow),
1509            "blue" => Ok(Color::Blue),
1510            "magenta" => Ok(Color::Magenta),
1511            "cyan" => Ok(Color::Cyan),
1512            "white" => Ok(Color::White),
1513
1514            // Bright variants (256-color palette)
1515            "bright_black" | "bright black" => Ok(Color::Indexed(8)),
1516            "bright_red" | "bright red" => Ok(Color::Indexed(9)),
1517            "bright_green" | "bright green" => Ok(Color::Indexed(10)),
1518            "bright_yellow" | "bright yellow" => Ok(Color::Indexed(11)),
1519            "bright_blue" | "bright blue" => Ok(Color::Indexed(12)),
1520            "bright_magenta" | "bright magenta" => Ok(Color::Indexed(13)),
1521            "bright_cyan" | "bright cyan" => Ok(Color::Indexed(14)),
1522            "bright_white" | "bright white" => Ok(Color::Indexed(15)),
1523
1524            // Gray aliases
1525            "gray" | "grey" => Ok(Color::Indexed(8)),
1526            "dark_gray" | "dark gray" | "dark_grey" | "dark grey" => Ok(Color::Indexed(8)),
1527            "light_gray" | "light gray" | "light_grey" | "light grey" => Ok(Color::Indexed(7)),
1528
1529            // Special modifiers (pass through as Reset - handled specially in rendering)
1530            "reset" | "default" | "none" | "reversed" => Ok(Color::Reset),
1531
1532            _ => Err(eyre!(
1533                "Unknown color name: '{}'. Supported: basic ANSI colors (red, blue, etc.), \
1534                 bright variants (bright_red, etc.), or hex colors (#ff0000)",
1535                trimmed
1536            )),
1537        }
1538    }
1539
1540    /// Convert RGB values to appropriate terminal color based on capabilities
1541    fn convert_rgb_to_terminal_color(&self, r: u8, g: u8, b: u8) -> Color {
1542        if self.supports_true_color {
1543            Color::Rgb(r, g, b)
1544        } else if self.supports_256 {
1545            Color::Indexed(rgb_to_256_color(r, g, b))
1546        } else {
1547            rgb_to_basic_ansi(r, g, b)
1548        }
1549    }
1550}
1551
1552impl Default for ColorParser {
1553    fn default() -> Self {
1554        Self::new()
1555    }
1556}
1557
1558/// Parse hex color string (#ff0000) to RGB components
1559fn parse_hex(s: &str) -> Result<(u8, u8, u8)> {
1560    if !s.starts_with('#') || s.len() != 7 {
1561        return Err(eyre!(
1562            "Invalid hex color format: '{}'. Expected format: #rrggbb",
1563            s
1564        ));
1565    }
1566
1567    let r = u8::from_str_radix(&s[1..3], 16)
1568        .map_err(|_| eyre!("Invalid red component in hex color: {}", s))?;
1569    let g = u8::from_str_radix(&s[3..5], 16)
1570        .map_err(|_| eyre!("Invalid green component in hex color: {}", s))?;
1571    let b = u8::from_str_radix(&s[5..7], 16)
1572        .map_err(|_| eyre!("Invalid blue component in hex color: {}", s))?;
1573
1574    Ok((r, g, b))
1575}
1576
1577/// Convert RGB to nearest 256-color palette index
1578/// Uses standard xterm 256-color palette
1579pub fn rgb_to_256_color(r: u8, g: u8, b: u8) -> u8 {
1580    // Check if it's a gray shade (r ≈ g ≈ b)
1581    let max_diff = r.max(g).max(b) as i16 - r.min(g).min(b) as i16;
1582    if max_diff < 10 {
1583        // Map to grayscale ramp (232-255)
1584        let gray = (r as u16 + g as u16 + b as u16) / 3;
1585        if gray < 8 {
1586            return 16; // Black
1587        } else if gray > 247 {
1588            return 231; // White
1589        } else {
1590            return 232 + ((gray - 8) * 24 / 240) as u8;
1591        }
1592    }
1593
1594    // Map to 6x6x6 color cube (16-231)
1595    let r_idx = (r as u16 * 5 / 255) as u8;
1596    let g_idx = (g as u16 * 5 / 255) as u8;
1597    let b_idx = (b as u16 * 5 / 255) as u8;
1598
1599    16 + 36 * r_idx + 6 * g_idx + b_idx
1600}
1601
1602/// Convert RGB to nearest basic ANSI color (8 colors)
1603pub fn rgb_to_basic_ansi(r: u8, g: u8, b: u8) -> Color {
1604    // Simple threshold-based conversion
1605    let r_bright = r > 128;
1606    let g_bright = g > 128;
1607    let b_bright = b > 128;
1608
1609    // Check for grayscale
1610    let max_diff = r.max(g).max(b) as i16 - r.min(g).min(b) as i16;
1611    if max_diff < 30 {
1612        let avg = (r as u16 + g as u16 + b as u16) / 3;
1613        return if avg < 64 { Color::Black } else { Color::White };
1614    }
1615
1616    // Map to primary/secondary colors
1617    match (r_bright, g_bright, b_bright) {
1618        (false, false, false) => Color::Black,
1619        (true, false, false) => Color::Red,
1620        (false, true, false) => Color::Green,
1621        (true, true, false) => Color::Yellow,
1622        (false, false, true) => Color::Blue,
1623        (true, false, true) => Color::Magenta,
1624        (false, true, true) => Color::Cyan,
1625        (true, true, true) => Color::White,
1626    }
1627}
1628
1629/// Theme containing parsed colors ready for use
1630#[derive(Debug, Clone)]
1631pub struct Theme {
1632    pub colors: HashMap<String, Color>,
1633}
1634
1635impl Theme {
1636    /// Create a Theme from a ThemeConfig by parsing all color strings
1637    pub fn from_config(config: &ThemeConfig) -> Result<Self> {
1638        let parser = ColorParser::new();
1639        let mut colors = HashMap::new();
1640
1641        // Parse all colors from config
1642        colors.insert(
1643            "keybind_hints".to_string(),
1644            parser.parse(&config.colors.keybind_hints)?,
1645        );
1646        colors.insert(
1647            "keybind_labels".to_string(),
1648            parser.parse(&config.colors.keybind_labels)?,
1649        );
1650        colors.insert(
1651            "throbber".to_string(),
1652            parser.parse(&config.colors.throbber)?,
1653        );
1654        colors.insert(
1655            "primary_chart_series_color".to_string(),
1656            parser.parse(&config.colors.primary_chart_series_color)?,
1657        );
1658        colors.insert(
1659            "secondary_chart_series_color".to_string(),
1660            parser.parse(&config.colors.secondary_chart_series_color)?,
1661        );
1662        colors.insert("success".to_string(), parser.parse(&config.colors.success)?);
1663        colors.insert("error".to_string(), parser.parse(&config.colors.error)?);
1664        colors.insert("warning".to_string(), parser.parse(&config.colors.warning)?);
1665        colors.insert("dimmed".to_string(), parser.parse(&config.colors.dimmed)?);
1666        colors.insert(
1667            "background".to_string(),
1668            parser.parse(&config.colors.background)?,
1669        );
1670        colors.insert("surface".to_string(), parser.parse(&config.colors.surface)?);
1671        colors.insert(
1672            "controls_bg".to_string(),
1673            parser.parse(&config.colors.controls_bg)?,
1674        );
1675        colors.insert(
1676            "text_primary".to_string(),
1677            parser.parse(&config.colors.text_primary)?,
1678        );
1679        colors.insert(
1680            "text_secondary".to_string(),
1681            parser.parse(&config.colors.text_secondary)?,
1682        );
1683        colors.insert(
1684            "text_inverse".to_string(),
1685            parser.parse(&config.colors.text_inverse)?,
1686        );
1687        colors.insert(
1688            "table_header".to_string(),
1689            parser.parse(&config.colors.table_header)?,
1690        );
1691        colors.insert(
1692            "table_header_bg".to_string(),
1693            parser.parse(&config.colors.table_header_bg)?,
1694        );
1695        colors.insert(
1696            "row_numbers".to_string(),
1697            parser.parse(&config.colors.row_numbers)?,
1698        );
1699        colors.insert(
1700            "column_separator".to_string(),
1701            parser.parse(&config.colors.column_separator)?,
1702        );
1703        colors.insert(
1704            "table_selected".to_string(),
1705            parser.parse(&config.colors.table_selected)?,
1706        );
1707        colors.insert(
1708            "sidebar_border".to_string(),
1709            parser.parse(&config.colors.sidebar_border)?,
1710        );
1711        colors.insert(
1712            "modal_border_active".to_string(),
1713            parser.parse(&config.colors.modal_border_active)?,
1714        );
1715        colors.insert(
1716            "modal_border_error".to_string(),
1717            parser.parse(&config.colors.modal_border_error)?,
1718        );
1719        colors.insert(
1720            "distribution_normal".to_string(),
1721            parser.parse(&config.colors.distribution_normal)?,
1722        );
1723        colors.insert(
1724            "distribution_skewed".to_string(),
1725            parser.parse(&config.colors.distribution_skewed)?,
1726        );
1727        colors.insert(
1728            "distribution_other".to_string(),
1729            parser.parse(&config.colors.distribution_other)?,
1730        );
1731        colors.insert(
1732            "outlier_marker".to_string(),
1733            parser.parse(&config.colors.outlier_marker)?,
1734        );
1735        colors.insert(
1736            "cursor_focused".to_string(),
1737            parser.parse(&config.colors.cursor_focused)?,
1738        );
1739        colors.insert(
1740            "cursor_dimmed".to_string(),
1741            parser.parse(&config.colors.cursor_dimmed)?,
1742        );
1743        if config.colors.alternate_row_color != "default" {
1744            colors.insert(
1745                "alternate_row_color".to_string(),
1746                parser.parse(&config.colors.alternate_row_color)?,
1747            );
1748        }
1749        colors.insert("str_col".to_string(), parser.parse(&config.colors.str_col)?);
1750        colors.insert("int_col".to_string(), parser.parse(&config.colors.int_col)?);
1751        colors.insert(
1752            "float_col".to_string(),
1753            parser.parse(&config.colors.float_col)?,
1754        );
1755        colors.insert(
1756            "bool_col".to_string(),
1757            parser.parse(&config.colors.bool_col)?,
1758        );
1759        colors.insert(
1760            "temporal_col".to_string(),
1761            parser.parse(&config.colors.temporal_col)?,
1762        );
1763        colors.insert(
1764            "chart_series_color_1".to_string(),
1765            parser.parse(&config.colors.chart_series_color_1)?,
1766        );
1767        colors.insert(
1768            "chart_series_color_2".to_string(),
1769            parser.parse(&config.colors.chart_series_color_2)?,
1770        );
1771        colors.insert(
1772            "chart_series_color_3".to_string(),
1773            parser.parse(&config.colors.chart_series_color_3)?,
1774        );
1775        colors.insert(
1776            "chart_series_color_4".to_string(),
1777            parser.parse(&config.colors.chart_series_color_4)?,
1778        );
1779        colors.insert(
1780            "chart_series_color_5".to_string(),
1781            parser.parse(&config.colors.chart_series_color_5)?,
1782        );
1783        colors.insert(
1784            "chart_series_color_6".to_string(),
1785            parser.parse(&config.colors.chart_series_color_6)?,
1786        );
1787        colors.insert(
1788            "chart_series_color_7".to_string(),
1789            parser.parse(&config.colors.chart_series_color_7)?,
1790        );
1791
1792        Ok(Self { colors })
1793    }
1794
1795    /// Get a color by name, returns Reset if not found
1796    pub fn get(&self, name: &str) -> Color {
1797        self.colors.get(name).copied().unwrap_or(Color::Reset)
1798    }
1799
1800    /// Get a color by name, returns None if not found
1801    pub fn get_optional(&self, name: &str) -> Option<Color> {
1802        self.colors.get(name).copied()
1803    }
1804}