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