Skip to main content

datui_lib/
config.rs

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