Skip to main content

cqlite_cli/
config.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Config {
8    pub default_database: Option<PathBuf>,
9    #[serde(default)]
10    pub connection: ConnectionConfig,
11    #[serde(default)]
12    pub output: OutputSettings,
13    #[serde(default)]
14    pub performance: PerformanceConfig,
15    #[serde(default)]
16    pub logging: LoggingConfig,
17    #[serde(default)]
18    pub repl: ReplConfig,
19    pub data_directory: Option<PathBuf>,
20    pub default_keyspace: Option<String>,
21
22    // Legacy fields for backward compatibility
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub enable_history: Option<bool>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub enable_completion: Option<bool>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub show_timing: Option<bool>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub page_size: Option<usize>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub enable_paging: Option<bool>,
33    #[serde(default)]
34    pub no_color: bool,
35
36    // M2 one-shot mode fields
37    /// Schema file paths (supports multiple sources)
38    #[serde(default)]
39    pub schema_paths: Vec<PathBuf>,
40
41    /// One-shot execution query (from -e flag)
42    #[serde(skip)]
43    pub execution_query: Option<String>,
44
45    /// One-shot execution file (from -f flag)
46    #[serde(skip)]
47    pub execution_file: Option<PathBuf>,
48
49    /// Output mode for query results (table/json/csv)
50    pub output_mode: Option<String>,
51
52    /// Maximum rows for queries
53    pub query_limit: Option<usize>,
54
55    // Version hint resolution (Issue #130)
56    /// Cassandra version hint from CLI flag (for precedence chain)
57    #[serde(skip)]
58    pub cassandra_version: Option<String>,
59
60    /// Resolved version information (computed async after config load)
61    /// TODO(Issue #130): Used by :status meta-command (not yet implemented)
62    #[serde(skip)]
63    #[allow(dead_code)]
64    pub resolved_version: Option<cqlite_core::version_hints::ResolvedVersion>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ConnectionConfig {
69    pub timeout_ms: u64,
70    pub retry_attempts: u32,
71    pub pool_size: u32,
72}
73
74impl Default for ConnectionConfig {
75    fn default() -> Self {
76        Self {
77            timeout_ms: 30000,
78            retry_attempts: 3,
79            pool_size: 10,
80        }
81    }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct OutputSettings {
86    pub max_rows: Option<usize>,
87    pub pager: Option<String>,
88    pub colors: bool,
89    pub timestamp_format: String,
90}
91
92impl Default for OutputSettings {
93    fn default() -> Self {
94        Self {
95            max_rows: Some(1000),
96            pager: None,
97            colors: true,
98            timestamp_format: "%Y-%m-%d %H:%M:%S".to_string(),
99        }
100    }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct PerformanceConfig {
105    pub query_timeout_ms: u64,
106    pub memory_limit_mb: Option<u64>,
107    pub cache_size_mb: u64,
108}
109
110impl Default for PerformanceConfig {
111    fn default() -> Self {
112        Self {
113            query_timeout_ms: 30000,
114            memory_limit_mb: None,
115            cache_size_mb: 64,
116        }
117    }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct LoggingConfig {
122    pub level: String,
123    pub file: Option<PathBuf>,
124    pub format: LogFormat,
125}
126
127impl Default for LoggingConfig {
128    fn default() -> Self {
129        Self {
130            level: "info".to_string(),
131            file: None,
132            format: LogFormat::Pretty,
133        }
134    }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, Default)]
138pub enum LogFormat {
139    Plain,
140    Json,
141    #[default]
142    Pretty,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct ReplConfig {
147    pub enable_history: bool,
148    pub enable_completion: bool,
149    pub enable_colors: bool,
150    pub show_timing: bool,
151    pub page_size: usize,
152    pub enable_paging: bool,
153    pub max_history_size: usize,
154    pub prompt: String,
155    pub prompt_continuation: String,
156    pub history_file: Option<PathBuf>,
157}
158
159impl Default for Config {
160    fn default() -> Self {
161        Self {
162            default_database: None,
163            connection: ConnectionConfig::default(),
164            output: OutputSettings::default(),
165            performance: PerformanceConfig::default(),
166            logging: LoggingConfig::default(),
167            repl: ReplConfig::default(),
168            data_directory: None,
169            default_keyspace: None,
170            enable_history: None,
171            enable_completion: None,
172            show_timing: None,
173            page_size: None,
174            enable_paging: None,
175            no_color: false,
176            schema_paths: Vec::new(),
177            execution_query: None,
178            execution_file: None,
179            output_mode: None,
180            query_limit: None,
181            cassandra_version: None,
182            resolved_version: None,
183        }
184    }
185}
186
187impl Config {
188    pub fn load(config_path: Option<PathBuf>, cli: &crate::cli_types::Cli) -> Result<Self> {
189        let mut builder = ConfigBuilder::from_defaults()
190            .with_user_config()? // 1. User config (lowest precedence)
191            .with_project_config()?; // 2. Project config (overrides user)
192
193        // 3. Explicit --config flag (overrides discovered configs)
194        if let Some(path) = config_path {
195            builder = builder.with_explicit_config(path)?;
196        }
197
198        // 4. Environment variables (override files)
199        // 5. CLI flags (highest precedence)
200        Ok(builder.with_env()?.with_flags(cli).build())
201    }
202
203    /// Resolve Cassandra version using precedence chain (Issue #130)
204    ///
205    /// This method implements the version hint precedence:
206    /// 1. User override (--cassandra-version flag)
207    /// 2. SSTable metadata
208    /// 3. metadata.yml
209    /// 4. Unknown
210    ///
211    /// # Arguments
212    ///
213    /// * `platform` - Platform abstraction for file I/O
214    ///
215    /// # Errors
216    ///
217    /// Returns an error only for fatal I/O errors. Missing metadata is not an error.
218    ///
219    /// TODO(Issue #130): Used by :status meta-command (not yet implemented)
220    #[allow(dead_code)]
221    pub async fn resolve_version(
222        &mut self,
223        platform: std::sync::Arc<cqlite_core::Platform>,
224    ) -> Result<()> {
225        use cqlite_core::version_hints::VersionHintResolver;
226        use std::path::PathBuf;
227
228        // Use data_directory if available, otherwise use current directory
229        let default_path = PathBuf::from(".");
230        let data_dir = self
231            .data_directory
232            .as_deref()
233            .unwrap_or(default_path.as_path());
234
235        self.resolved_version = Some(
236            VersionHintResolver::resolve(self.cassandra_version.clone(), data_dir, platform)
237                .await?,
238        );
239
240        Ok(())
241    }
242
243    /// Get resolved version information for display/diagnostics
244    ///
245    /// Returns `None` if version resolution has not been performed yet.
246    /// Call `resolve_version()` first to populate this field.
247    ///
248    /// TODO(Issue #130): Used by :status meta-command (not yet implemented)
249    #[allow(dead_code)]
250    pub fn version_info(&self) -> Option<&cqlite_core::version_hints::ResolvedVersion> {
251        self.resolved_version.as_ref()
252    }
253
254    /// Get version string for display (returns "unknown" if not resolved)
255    ///
256    /// TODO(Issue #130): Used by :status meta-command (not yet implemented)
257    #[allow(dead_code)]
258    pub fn version_string(&self) -> String {
259        self.resolved_version
260            .as_ref()
261            .map(|rv| rv.version_or_unknown().to_string())
262            .unwrap_or_else(|| "not resolved".to_string())
263    }
264
265    /// Get version source description
266    ///
267    /// TODO(Issue #130): Used by :status meta-command (not yet implemented)
268    #[allow(dead_code)]
269    pub fn version_source(&self) -> String {
270        self.resolved_version
271            .as_ref()
272            .map(|rv| rv.source.description().to_string())
273            .unwrap_or_else(|| "not resolved".to_string())
274    }
275
276    fn load_from_file(path: &Path) -> Result<Self> {
277        let content = fs::read_to_string(path)
278            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
279
280        let config: Config = match path.extension().and_then(|ext| ext.to_str()) {
281            Some("toml") => {
282                toml::from_str(&content).with_context(|| "Failed to parse TOML config")?
283            }
284            Some("yaml") | Some("yml") => {
285                serde_yaml::from_str(&content).with_context(|| "Failed to parse YAML config")?
286            }
287            Some("json") => {
288                serde_json::from_str(&content).with_context(|| "Failed to parse JSON config")?
289            }
290            _ => return Err(anyhow::anyhow!("Unsupported config file format")),
291        };
292
293        Ok(config)
294    }
295
296    #[allow(dead_code)]
297    fn load_default() -> Result<Self> {
298        // Look for config file in standard locations
299        let config_paths = [
300            "cqlite.toml",
301            "cqlite.yaml",
302            "cqlite.yml",
303            "cqlite.json",
304            ".cqlite.toml",
305            ".cqlite.yaml",
306            ".cqlite.yml",
307            ".cqlite.json",
308        ];
309
310        for path in &config_paths {
311            if Path::new(path).exists() {
312                return Self::load_from_file(Path::new(path));
313            }
314        }
315
316        // Also check XDG config directory
317        if let Some(config_dir) = dirs::config_dir() {
318            let xdg_paths = [
319                config_dir.join("cqlite").join("config.toml"),
320                config_dir.join("cqlite").join("config.yaml"),
321                config_dir.join("cqlite").join("config.yml"),
322                config_dir.join("cqlite").join("config.json"),
323            ];
324
325            for path in &xdg_paths {
326                if path.exists() {
327                    return Self::load_from_file(path);
328                }
329            }
330        }
331
332        // Return default config if no file found
333        Ok(Self::default())
334    }
335
336    #[allow(dead_code)]
337    pub fn save_to_file(&self, path: &Path) -> Result<()> {
338        let content = match path.extension().and_then(|ext| ext.to_str()) {
339            Some("toml") => toml::to_string_pretty(self)
340                .with_context(|| "Failed to serialize config to TOML")?,
341            Some("yaml") | Some("yml") => {
342                serde_yaml::to_string(self).with_context(|| "Failed to serialize config to YAML")?
343            }
344            Some("json") => serde_json::to_string_pretty(self)
345                .with_context(|| "Failed to serialize config to JSON")?,
346            _ => return Err(anyhow::anyhow!("Unsupported config file format")),
347        };
348
349        if let Some(parent) = path.parent() {
350            fs::create_dir_all(parent).with_context(|| {
351                format!("Failed to create config directory: {}", parent.display())
352            })?;
353        }
354
355        fs::write(path, content)
356            .with_context(|| format!("Failed to write config file: {}", path.display()))?;
357
358        Ok(())
359    }
360}
361
362impl Default for ReplConfig {
363    fn default() -> Self {
364        Self {
365            enable_history: true,
366            enable_completion: true,
367            enable_colors: true,
368            show_timing: false,
369            page_size: 50,
370            enable_paging: true,
371            max_history_size: 1000,
372            prompt: "cqlite> ".to_string(),
373            prompt_continuation: "    -> ".to_string(),
374            history_file: None,
375        }
376    }
377}
378
379/// Configuration for table formatter output behavior
380///
381/// This struct controls how query results are formatted and displayed,
382/// including color support, row limits, pagination settings, and output destination.
383#[derive(Debug, Clone)]
384pub struct OutputConfig {
385    /// Whether to enable colored output in table formatting.
386    /// This is the inverse of the `--no-color` CLI flag.
387    /// When `true`, output will include ANSI color codes for better readability.
388    pub color_enabled: bool,
389
390    /// Maximum number of rows to display in query results.
391    /// When `None`, all rows will be displayed.
392    /// This can be used to prevent overwhelming output from large result sets.
393    pub limit: Option<usize>,
394
395    /// Number of rows per page for pagination.
396    /// When `None`, pagination is disabled and all rows are shown at once.
397    /// Default is 50 rows per page, matching cqlsh behavior.
398    #[allow(dead_code)]
399    pub page_size: Option<usize>,
400
401    /// Output target (stdout or file path).
402    /// When `Stdout`, output is written to standard output.
403    /// When `File(path)`, output is written atomically to the specified file.
404    pub target: crate::output::OutputTarget,
405
406    /// Whether to overwrite existing files when writing to a file target.
407    /// Only relevant when `target` is `File`.
408    pub overwrite: bool,
409}
410
411impl OutputConfig {
412    /// Create a new OutputConfig from resolved Config and CLI flags
413    ///
414    /// This method respects the precedence chain: CLI flags > env vars > config file > defaults.
415    /// The `Config` object passed in has already resolved this chain via ConfigBuilder.
416    ///
417    /// # Arguments
418    ///
419    /// * `config` - The resolved Config object containing env/file/default values
420    /// * `no_color_flag` - The `--no-color` CLI flag (if present, overrides config)
421    /// * `limit_flag` - The `--limit` CLI flag (if present, overrides config)
422    /// * `page_size_flag` - The `--page-size` CLI flag (if present, overrides config)
423    /// * `output_flag` - The `--output` CLI flag for file destination
424    /// * `overwrite_flag` - The `--overwrite` CLI flag for overwriting existing files
425    ///
426    /// # Precedence
427    ///
428    /// - `color_enabled`: --no-color flag > CQLITE_NO_COLOR env > config.output.colors > default (true)
429    /// - `limit`: --limit flag > CQLITE_LIMIT env > config.query_limit > default (None)
430    /// - `page_size`: --page-size flag > CQLITE_PAGE_SIZE env > config.repl.page_size > default (50)
431    /// - `target`: --output flag > CQLITE_OUTPUT env > default (Stdout)
432    /// - `overwrite`: --overwrite flag > default (false)
433    ///
434    /// # Examples
435    ///
436    /// ```
437    /// use cqlite_cli::config::{Config, OutputConfig};
438    /// use cqlite_cli::cli_types::Cli;
439    /// use clap::Parser;
440    ///
441    /// // Create config with defaults
442    /// let cli = Cli::parse_from(&["cqlite"]);
443    /// let config = Config::load(None, &cli).unwrap();
444    /// let output = OutputConfig::from_cli(&config, false, None, None, None, false);
445    /// assert!(output.color_enabled);
446    /// assert_eq!(output.page_size, Some(50));
447    ///
448    /// // CLI flag overrides config
449    /// let output = OutputConfig::from_cli(&config, true, Some(100), Some(25), None, false);
450    /// assert!(!output.color_enabled);
451    /// assert_eq!(output.limit, Some(100));
452    /// assert_eq!(output.page_size, Some(25));
453    /// ```
454    pub fn from_cli(
455        config: &Config,
456        no_color_flag: bool,
457        limit_flag: Option<usize>,
458        page_size_flag: Option<usize>,
459        output_flag: Option<std::path::PathBuf>,
460        overwrite_flag: bool,
461    ) -> Self {
462        use crate::output::OutputTarget;
463
464        Self {
465            // CLI flag overrides config value
466            color_enabled: if no_color_flag {
467                false
468            } else {
469                config.output.colors
470            },
471            // CLI flag overrides config.query_limit (which already has env/file/default precedence)
472            limit: limit_flag.or(config.query_limit),
473            // CLI flag overrides config.repl.page_size (which already has env/file/default precedence)
474            page_size: page_size_flag.or(Some(config.repl.page_size)),
475            // Output target from CLI flag
476            target: output_flag
477                .map(OutputTarget::File)
478                .unwrap_or(OutputTarget::Stdout),
479            // Overwrite flag
480            overwrite: overwrite_flag,
481        }
482    }
483}
484
485impl Default for OutputConfig {
486    /// Default output configuration
487    ///
488    /// Returns an OutputConfig with:
489    /// - `color_enabled`: `true` (colors enabled by default)
490    /// - `limit`: `None` (no row limit)
491    /// - `page_size`: `Some(50)` (50 rows per page, matching cqlsh)
492    /// - `target`: `Stdout` (write to standard output)
493    /// - `overwrite`: `false` (don't overwrite existing files)
494    fn default() -> Self {
495        Self {
496            color_enabled: true,
497            limit: None,
498            page_size: Some(50),
499            target: crate::output::OutputTarget::Stdout,
500            overwrite: false,
501        }
502    }
503}
504
505/// Merge two configs with partial override semantics
506/// Only non-default values from overlay replace base values
507fn merge_partial_config(base: Config, overlay: Config) -> Config {
508    // Determine final no_color value (true if either is true)
509    let final_no_color = overlay.no_color || base.no_color;
510
511    // Determine output colors
512    // Priority: if no_color is true, colors must be false
513    // Otherwise, use overlay value (which has defaults applied during TOML parsing)
514    let final_output_colors = if final_no_color {
515        false
516    } else {
517        overlay.output.colors
518    };
519
520    Config {
521        // Use overlay value if present, otherwise keep base
522        data_directory: overlay.data_directory.or(base.data_directory),
523        default_keyspace: overlay.default_keyspace.or(base.default_keyspace),
524
525        // For schema_paths, use overlay if non-empty
526        schema_paths: if overlay.schema_paths.is_empty() {
527            base.schema_paths
528        } else {
529            overlay.schema_paths
530        },
531
532        // Output mode
533        output_mode: overlay.output_mode.or(base.output_mode),
534
535        // Numeric limits
536        query_limit: overlay.query_limit.or(base.query_limit),
537
538        // Nested structs - merge carefully
539        connection: overlay.connection,
540        output: OutputSettings {
541            max_rows: overlay.output.max_rows.or(base.output.max_rows),
542            pager: overlay.output.pager.or(base.output.pager),
543            colors: final_output_colors,
544            timestamp_format: overlay.output.timestamp_format,
545        },
546        repl: ReplConfig {
547            enable_history: overlay.repl.enable_history,
548            enable_completion: overlay.repl.enable_completion,
549            enable_colors: overlay.repl.enable_colors,
550            show_timing: overlay.repl.show_timing,
551            page_size: overlay.repl.page_size,
552            enable_paging: overlay.repl.enable_paging,
553            max_history_size: overlay.repl.max_history_size,
554            prompt: overlay.repl.prompt,
555            prompt_continuation: overlay.repl.prompt_continuation,
556            history_file: overlay.repl.history_file.or(base.repl.history_file),
557        },
558        performance: overlay.performance,
559        logging: overlay.logging,
560
561        // Legacy fields (backward compat)
562        enable_history: overlay.enable_history.or(base.enable_history),
563        enable_completion: overlay.enable_completion.or(base.enable_completion),
564        show_timing: overlay.show_timing.or(base.show_timing),
565        page_size: overlay.page_size.or(base.page_size),
566        enable_paging: overlay.enable_paging.or(base.enable_paging),
567        no_color: final_no_color,
568
569        // Skip serialization fields
570        execution_query: base.execution_query,
571        execution_file: base.execution_file,
572        cassandra_version: overlay.cassandra_version.or(base.cassandra_version),
573        resolved_version: base.resolved_version,
574
575        // Deprecated - keep base
576        default_database: base.default_database,
577    }
578}
579
580/// Builder for Config with precedence: flags > env > file > defaults
581pub struct ConfigBuilder {
582    config: Config,
583}
584
585impl ConfigBuilder {
586    /// Start with default configuration
587    pub fn from_defaults() -> Self {
588        Self {
589            config: Config::default(),
590        }
591    }
592
593    /// Layer config file (overrides defaults)
594    ///
595    /// Deprecated: Use `with_explicit_config()` instead for --config flag handling
596    #[allow(dead_code)]
597    pub fn with_file(mut self, path: Option<PathBuf>) -> Result<Self> {
598        if let Some(p) = path {
599            let loaded = Config::load_from_file(&p)?;
600            // Merge loaded config, preserving defaults for unset fields
601            self.config = loaded;
602        }
603        Ok(self)
604    }
605
606    /// Layer user config (overrides defaults)
607    pub fn with_user_config(mut self) -> Result<Self> {
608        if let Some(user_path) = Self::user_config_path() {
609            if user_path.exists() {
610                let loaded = Config::load_from_file(&user_path).with_context(|| {
611                    format!("Failed to load user config: {}", user_path.display())
612                })?;
613                self.config = merge_partial_config(self.config, loaded);
614            }
615        }
616        Ok(self)
617    }
618
619    /// Layer project config (overrides user config and defaults)
620    pub fn with_project_config(mut self) -> Result<Self> {
621        let project_path = PathBuf::from("./.cqlite.toml");
622        if project_path.exists() {
623            let loaded = Config::load_from_file(&project_path)
624                .with_context(|| "Failed to load project config")?;
625            self.config = merge_partial_config(self.config, loaded);
626        }
627        Ok(self)
628    }
629
630    /// Layer explicit config from --config flag (overrides discovered configs)
631    pub fn with_explicit_config(mut self, path: PathBuf) -> Result<Self> {
632        let loaded = Config::load_from_file(&path)
633            .with_context(|| format!("Failed to load config file: {}", path.display()))?;
634        self.config = merge_partial_config(self.config, loaded);
635        Ok(self)
636    }
637
638    /// Get platform-specific user config path
639    fn user_config_path() -> Option<PathBuf> {
640        #[cfg(target_os = "macos")]
641        {
642            dirs::home_dir().map(|h| h.join("Library/Application Support/cqlite/config.toml"))
643        }
644        #[cfg(target_os = "windows")]
645        {
646            dirs::config_dir().map(|d| d.join("cqlite").join("config.toml"))
647        }
648        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
649        {
650            std::env::var("XDG_CONFIG_HOME")
651                .ok()
652                .map(|p| PathBuf::from(p).join("cqlite/config.toml"))
653                .or_else(|| dirs::home_dir().map(|h| h.join(".config/cqlite/config.toml")))
654        }
655    }
656
657    /// Layer environment variables (overrides file and defaults)
658    pub fn with_env(mut self) -> Result<Self> {
659        use std::env;
660
661        // CQLITE_DATA_DIR
662        if let Ok(val) = env::var("CQLITE_DATA_DIR") {
663            self.config.data_directory = Some(PathBuf::from(val));
664        }
665
666        // CQLITE_SCHEMA (can be comma-separated paths)
667        if let Ok(val) = env::var("CQLITE_SCHEMA") {
668            let paths: Vec<PathBuf> = val.split(',').map(|s| PathBuf::from(s.trim())).collect();
669            self.config.schema_paths = paths; // Replace, not extend (Issue #126)
670        }
671
672        // CQLITE_LIMIT
673        if let Ok(val) = env::var("CQLITE_LIMIT") {
674            let limit: usize = val.parse().with_context(|| "Invalid CQLITE_LIMIT value")?;
675            if limit == 0 {
676                return Err(anyhow::anyhow!("CQLITE_LIMIT must be greater than 0"));
677            }
678            self.config.query_limit = Some(limit);
679        }
680
681        // CQLITE_PAGE_SIZE
682        if let Ok(val) = env::var("CQLITE_PAGE_SIZE") {
683            let page_size: usize = val
684                .parse()
685                .with_context(|| "Invalid CQLITE_PAGE_SIZE value")?;
686            if page_size == 0 {
687                return Err(anyhow::anyhow!("CQLITE_PAGE_SIZE must be greater than 0"));
688            }
689            self.config.repl.page_size = page_size;
690        }
691
692        // CQLITE_NO_COLOR
693        if let Ok(val) = env::var("CQLITE_NO_COLOR") {
694            let no_color = matches!(val.to_lowercase().as_str(), "1" | "true" | "yes" | "on");
695            self.config.no_color = no_color;
696            self.config.output.colors = !no_color;
697        }
698
699        // CQLITE_OUT
700        if let Ok(val) = env::var("CQLITE_OUT") {
701            self.config.output_mode = Some(val);
702        }
703
704        Ok(self)
705    }
706
707    /// Layer CLI flags (highest precedence)
708    ///
709    /// Note: CLI flags completely override environment variables and config file
710    /// values for the same setting. For example, --schema replaces CQLITE_SCHEMA
711    /// entirely rather than merging paths. This ensures clear precedence semantics.
712    pub fn with_flags(mut self, cli: &crate::cli_types::Cli) -> Self {
713        // Schema path
714        if let Some(ref schema) = cli.schema {
715            self.config.schema_paths = vec![schema.clone()];
716        }
717
718        // Data directory
719        if let Some(ref data_dir) = cli.data_dir {
720            self.config.data_directory = Some(data_dir.clone());
721        }
722
723        // Execute query
724        if let Some(ref query) = cli.execute {
725            self.config.execution_query = Some(query.clone());
726        }
727
728        // Execute file
729        if let Some(ref file) = cli.file {
730            self.config.execution_file = Some(file.clone());
731        }
732
733        // Output mode
734        if let Some(ref out) = cli.out {
735            self.config.output_mode = Some(out.as_str().to_string());
736        }
737
738        // Limit
739        if let Some(limit) = cli.limit {
740            self.config.query_limit = Some(limit);
741        }
742
743        // Page size
744        if let Some(page_size) = cli.page_size {
745            self.config.repl.page_size = page_size;
746        }
747
748        // No color
749        if cli.no_color {
750            self.config.no_color = true;
751            self.config.output.colors = false;
752        }
753
754        // Cassandra version hint (Issue #130)
755        if let Some(ref version) = cli.cassandra_version {
756            self.config.cassandra_version = Some(version.clone());
757        }
758
759        self
760    }
761
762    /// Build final configuration
763    pub fn build(self) -> Config {
764        self.config
765    }
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771    use crate::cli_types::Cli;
772    use clap::Parser;
773    use serial_test::serial;
774    use std::sync::Arc;
775    use tempfile::TempDir;
776
777    #[tokio::test]
778    async fn test_cassandra_version_flag_passed_to_config() {
779        // Parse CLI args with cassandra_version flag
780        let cli = Cli::parse_from(&[
781            "cqlite",
782            "--cassandra-version",
783            "5.0",
784            "--data-dir",
785            "/tmp/data",
786        ]);
787
788        // Build config
789        let config = Config::load(None, &cli).unwrap();
790
791        // Verify cassandra_version was captured
792        assert_eq!(config.cassandra_version, Some("5.0".to_string()));
793    }
794
795    #[tokio::test]
796    async fn test_version_resolution_user_override() {
797        let temp_dir = TempDir::new().unwrap();
798
799        // Create CLI with user override
800        let cli = Cli::parse_from(&[
801            "cqlite",
802            "--cassandra-version",
803            "5.0",
804            "--data-dir",
805            temp_dir.path().to_str().unwrap(),
806        ]);
807
808        // Build config
809        let mut config = Config::load(None, &cli).unwrap();
810
811        // Initialize platform and resolve version
812        let core_config = cqlite_core::Config::default();
813        let platform = Arc::new(cqlite_core::Platform::new(&core_config).await.unwrap());
814
815        config.resolve_version(platform).await.unwrap();
816
817        // Verify user override takes precedence
818        let version_info = config.version_info().unwrap();
819        assert_eq!(
820            version_info.source,
821            cqlite_core::version_hints::VersionSource::UserFlag
822        );
823        assert_eq!(version_info.version, Some("5.0".to_string()));
824    }
825
826    #[tokio::test]
827    async fn test_version_resolution_metadata_yml() {
828        let temp_dir = TempDir::new().unwrap();
829
830        // Create metadata.yml with version
831        let metadata_content = "cassandra_version: \"4.0\"\nkeyspaces: []\n";
832        let metadata_path = temp_dir.path().join("metadata.yml");
833        std::fs::write(&metadata_path, metadata_content).unwrap();
834
835        // Create CLI without user override
836        let cli = Cli::parse_from(&["cqlite", "--data-dir", temp_dir.path().to_str().unwrap()]);
837
838        // Build config
839        let mut config = Config::load(None, &cli).unwrap();
840
841        // Initialize platform and resolve version
842        let core_config = cqlite_core::Config::default();
843        let platform = Arc::new(cqlite_core::Platform::new(&core_config).await.unwrap());
844
845        config.resolve_version(platform).await.unwrap();
846
847        // Verify metadata.yml was used
848        let version_info = config.version_info().unwrap();
849        assert_eq!(
850            version_info.source,
851            cqlite_core::version_hints::VersionSource::DatasetMetadata
852        );
853        assert_eq!(version_info.version, Some("4.0".to_string()));
854    }
855
856    #[tokio::test]
857    async fn test_version_resolution_unknown() {
858        let temp_dir = TempDir::new().unwrap();
859
860        // Create CLI without version and no metadata.yml
861        let cli = Cli::parse_from(&["cqlite", "--data-dir", temp_dir.path().to_str().unwrap()]);
862
863        // Build config
864        let mut config = Config::load(None, &cli).unwrap();
865
866        // Initialize platform and resolve version
867        let core_config = cqlite_core::Config::default();
868        let platform = Arc::new(cqlite_core::Platform::new(&core_config).await.unwrap());
869
870        config.resolve_version(platform).await.unwrap();
871
872        // Verify unknown fallback
873        let version_info = config.version_info().unwrap();
874        assert_eq!(
875            version_info.source,
876            cqlite_core::version_hints::VersionSource::Unknown
877        );
878        assert_eq!(version_info.version, None);
879        assert_eq!(version_info.version_or_unknown(), "unknown");
880    }
881
882    #[tokio::test]
883    async fn test_version_precedence_user_overrides_metadata() {
884        let temp_dir = TempDir::new().unwrap();
885
886        // Create metadata.yml with version 4.0
887        let metadata_content = "cassandra_version: \"4.0\"\nkeyspaces: []\n";
888        let metadata_path = temp_dir.path().join("metadata.yml");
889        std::fs::write(&metadata_path, metadata_content).unwrap();
890
891        // Create CLI with user override 5.0
892        let cli = Cli::parse_from(&[
893            "cqlite",
894            "--cassandra-version",
895            "5.0",
896            "--data-dir",
897            temp_dir.path().to_str().unwrap(),
898        ]);
899
900        // Build config
901        let mut config = Config::load(None, &cli).unwrap();
902
903        // Initialize platform and resolve version
904        let core_config = cqlite_core::Config::default();
905        let platform = Arc::new(cqlite_core::Platform::new(&core_config).await.unwrap());
906
907        config.resolve_version(platform).await.unwrap();
908
909        // Verify user override takes precedence over metadata.yml
910        let version_info = config.version_info().unwrap();
911        assert_eq!(
912            version_info.source,
913            cqlite_core::version_hints::VersionSource::UserFlag
914        );
915        assert_eq!(version_info.version, Some("5.0".to_string()));
916    }
917
918    #[tokio::test]
919    async fn test_version_string_helpers() {
920        let temp_dir = TempDir::new().unwrap();
921
922        // Create CLI with version
923        let cli = Cli::parse_from(&[
924            "cqlite",
925            "--cassandra-version",
926            "5.0",
927            "--data-dir",
928            temp_dir.path().to_str().unwrap(),
929        ]);
930
931        // Build config
932        let mut config = Config::load(None, &cli).unwrap();
933
934        // Before resolution
935        assert_eq!(config.version_string(), "not resolved");
936        assert_eq!(config.version_source(), "not resolved");
937
938        // After resolution
939        let core_config = cqlite_core::Config::default();
940        let platform = Arc::new(cqlite_core::Platform::new(&core_config).await.unwrap());
941        config.resolve_version(platform).await.unwrap();
942
943        assert_eq!(config.version_string(), "5.0");
944        assert!(config.version_source().contains("User-provided flag"));
945    }
946
947    #[test]
948    fn test_config_default_includes_version_fields() {
949        let config = Config::default();
950        assert_eq!(config.cassandra_version, None);
951        assert_eq!(config.resolved_version, None);
952    }
953
954    #[test]
955    fn test_config_builder_preserves_version_flag() {
956        let cli = Cli::parse_from(&["cqlite", "--cassandra-version", "4.0"]);
957
958        let config = ConfigBuilder::from_defaults().with_flags(&cli).build();
959
960        assert_eq!(config.cassandra_version, Some("4.0".to_string()));
961    }
962
963    #[test]
964    #[serial]
965    fn test_env_var_replaces_config_file_schema_paths() {
966        use std::env;
967
968        // Set up environment variable
969        env::set_var("CQLITE_SCHEMA", "/env/path1,/env/path2");
970
971        // Create config with file-based schema paths
972        let mut config = Config::default();
973        config.schema_paths = vec![PathBuf::from("/file/path1"), PathBuf::from("/file/path2")];
974
975        // Apply env vars
976        let builder = ConfigBuilder { config };
977        let result = builder.with_env().unwrap();
978
979        // Verify env var REPLACED file paths, not extended
980        assert_eq!(result.config.schema_paths.len(), 2);
981        assert_eq!(result.config.schema_paths[0], PathBuf::from("/env/path1"));
982        assert_eq!(result.config.schema_paths[1], PathBuf::from("/env/path2"));
983
984        // Clean up
985        env::remove_var("CQLITE_SCHEMA");
986    }
987
988    #[test]
989    #[serial]
990    fn test_env_var_single_schema_path_replaces_multiple() {
991        use std::env;
992
993        // Set up environment variable with single path
994        env::set_var("CQLITE_SCHEMA", "/env/single/path");
995
996        // Create config with multiple file-based schema paths
997        let mut config = Config::default();
998        config.schema_paths = vec![
999            PathBuf::from("/file/path1"),
1000            PathBuf::from("/file/path2"),
1001            PathBuf::from("/file/path3"),
1002        ];
1003
1004        // Apply env vars
1005        let builder = ConfigBuilder { config };
1006        let result = builder.with_env().unwrap();
1007
1008        // Verify single env path replaced all file paths
1009        assert_eq!(result.config.schema_paths.len(), 1);
1010        assert_eq!(
1011            result.config.schema_paths[0],
1012            PathBuf::from("/env/single/path")
1013        );
1014
1015        // Clean up
1016        env::remove_var("CQLITE_SCHEMA");
1017    }
1018
1019    #[test]
1020    #[serial]
1021    fn test_cli_flag_overrides_env_var_schema() {
1022        use std::env;
1023
1024        // Set up environment variable
1025        env::set_var("CQLITE_SCHEMA", "/env/path1,/env/path2");
1026
1027        // Create config with file paths and apply env
1028        let mut config = Config::default();
1029        config.schema_paths = vec![PathBuf::from("/file/path")];
1030
1031        let builder = ConfigBuilder { config };
1032        let result = builder.with_env().unwrap();
1033
1034        // At this point, env var should have replaced file paths
1035        assert_eq!(result.config.schema_paths.len(), 2);
1036
1037        // Now apply CLI flag
1038        let cli = Cli::parse_from(&["cqlite", "--schema", "/cli/path"]);
1039        let final_config = result.with_flags(&cli).build();
1040
1041        // Verify CLI flag replaced everything
1042        assert_eq!(final_config.schema_paths.len(), 1);
1043        assert_eq!(final_config.schema_paths[0], PathBuf::from("/cli/path"));
1044
1045        // Clean up
1046        env::remove_var("CQLITE_SCHEMA");
1047    }
1048
1049    #[test]
1050    #[serial]
1051    fn test_schema_precedence_chain_complete() {
1052        use std::env;
1053
1054        // Test: file < env < CLI flag
1055
1056        // Start with file-based config
1057        let mut config = Config::default();
1058        config.schema_paths = vec![PathBuf::from("/file/path")];
1059
1060        // Apply env var (should replace file)
1061        env::set_var("CQLITE_SCHEMA", "/env/path");
1062        let builder = ConfigBuilder { config };
1063        let with_env = builder.with_env().unwrap();
1064        assert_eq!(
1065            with_env.config.schema_paths,
1066            vec![PathBuf::from("/env/path")]
1067        );
1068
1069        // Apply CLI flag (should replace env)
1070        let cli = Cli::parse_from(&["cqlite", "--schema", "/cli/path"]);
1071        let final_config = with_env.with_flags(&cli).build();
1072        assert_eq!(final_config.schema_paths, vec![PathBuf::from("/cli/path")]);
1073
1074        // Clean up
1075        env::remove_var("CQLITE_SCHEMA");
1076    }
1077
1078    #[test]
1079    #[serial]
1080    fn test_no_env_var_preserves_file_schema() {
1081        use std::env;
1082
1083        // Make sure env var is NOT set
1084        env::remove_var("CQLITE_SCHEMA");
1085
1086        // Create config with file-based schema paths
1087        let mut config = Config::default();
1088        config.schema_paths = vec![PathBuf::from("/file/path1"), PathBuf::from("/file/path2")];
1089
1090        // Apply env vars (should not change anything)
1091        let builder = ConfigBuilder { config };
1092        let result = builder.with_env().unwrap();
1093
1094        // Verify file paths are preserved
1095        assert_eq!(result.config.schema_paths.len(), 2);
1096        assert_eq!(result.config.schema_paths[0], PathBuf::from("/file/path1"));
1097        assert_eq!(result.config.schema_paths[1], PathBuf::from("/file/path2"));
1098    }
1099
1100    #[test]
1101    #[serial]
1102    fn test_env_var_with_whitespace_trimming() {
1103        use std::env;
1104
1105        // Set up environment variable with whitespace
1106        env::set_var("CQLITE_SCHEMA", " /path1 , /path2 , /path3 ");
1107
1108        // Create config
1109        let config = Config::default();
1110
1111        // Apply env vars
1112        let builder = ConfigBuilder { config };
1113        let result = builder.with_env().unwrap();
1114
1115        // Verify paths are trimmed
1116        assert_eq!(result.config.schema_paths.len(), 3);
1117        assert_eq!(result.config.schema_paths[0], PathBuf::from("/path1"));
1118        assert_eq!(result.config.schema_paths[1], PathBuf::from("/path2"));
1119        assert_eq!(result.config.schema_paths[2], PathBuf::from("/path3"));
1120
1121        // Clean up
1122        env::remove_var("CQLITE_SCHEMA");
1123    }
1124
1125    // OutputConfig precedence chain tests (Issue #118)
1126
1127    #[test]
1128    #[serial]
1129    fn test_output_config_uses_defaults_when_no_flags_or_env() {
1130        use std::env;
1131
1132        // Ensure no env vars are set
1133        env::remove_var("CQLITE_LIMIT");
1134        env::remove_var("CQLITE_PAGE_SIZE");
1135        env::remove_var("CQLITE_NO_COLOR");
1136
1137        // Create CLI with no flags
1138        let cli = Cli::parse_from(&["cqlite"]);
1139
1140        // Build config (should have defaults)
1141        let config = Config::load(None, &cli).unwrap();
1142
1143        // Create OutputConfig with no CLI flags
1144        let output = OutputConfig::from_cli(&config, false, None, None, None, false);
1145
1146        // Verify defaults
1147        assert!(output.color_enabled); // Default is true
1148        assert_eq!(output.limit, None); // Default is None
1149        assert_eq!(output.page_size, Some(50)); // Default is 50
1150    }
1151
1152    #[test]
1153    #[serial]
1154    fn test_output_config_env_vars_override_defaults() {
1155        use std::env;
1156
1157        // Set env vars
1158        env::set_var("CQLITE_LIMIT", "100");
1159        env::set_var("CQLITE_PAGE_SIZE", "25");
1160        env::set_var("CQLITE_NO_COLOR", "true");
1161
1162        // Create CLI with no flags
1163        let cli = Cli::parse_from(&["cqlite"]);
1164
1165        // Build config (should pick up env vars)
1166        let config = Config::load(None, &cli).unwrap();
1167
1168        // Create OutputConfig with no CLI flags
1169        let output = OutputConfig::from_cli(&config, false, None, None, None, false);
1170
1171        // Verify env vars were used
1172        assert!(!output.color_enabled); // CQLITE_NO_COLOR=true
1173        assert_eq!(output.limit, Some(100)); // CQLITE_LIMIT=100
1174        assert_eq!(output.page_size, Some(25)); // CQLITE_PAGE_SIZE=25
1175
1176        // Clean up
1177        env::remove_var("CQLITE_LIMIT");
1178        env::remove_var("CQLITE_PAGE_SIZE");
1179        env::remove_var("CQLITE_NO_COLOR");
1180    }
1181
1182    #[test]
1183    #[serial]
1184    fn test_output_config_cli_flags_override_env_vars() {
1185        use std::env;
1186
1187        // Set env vars
1188        env::set_var("CQLITE_LIMIT", "100");
1189        env::set_var("CQLITE_PAGE_SIZE", "25");
1190        env::set_var("CQLITE_NO_COLOR", "false");
1191
1192        // Create CLI with flags (should override env)
1193        let cli = Cli::parse_from(&[
1194            "cqlite",
1195            "--limit",
1196            "200",
1197            "--page-size",
1198            "10",
1199            "--no-color",
1200        ]);
1201
1202        // Build config
1203        let config = Config::load(None, &cli).unwrap();
1204
1205        // Create OutputConfig with CLI flags
1206        let output = OutputConfig::from_cli(
1207            &config,
1208            cli.no_color,
1209            cli.limit,
1210            cli.page_size,
1211            cli.output.clone(),
1212            cli.overwrite,
1213        );
1214
1215        // Verify CLI flags overrode env vars
1216        assert!(!output.color_enabled); // --no-color flag
1217        assert_eq!(output.limit, Some(200)); // --limit 200
1218        assert_eq!(output.page_size, Some(10)); // --page-size 10
1219
1220        // Clean up
1221        env::remove_var("CQLITE_LIMIT");
1222        env::remove_var("CQLITE_PAGE_SIZE");
1223        env::remove_var("CQLITE_NO_COLOR");
1224    }
1225
1226    #[test]
1227    #[serial]
1228    fn test_output_config_partial_cli_flags_preserve_env_vars() {
1229        use std::env;
1230
1231        // Set env vars
1232        env::set_var("CQLITE_LIMIT", "100");
1233        env::set_var("CQLITE_PAGE_SIZE", "25");
1234
1235        // Create CLI with only --no-color flag
1236        let cli = Cli::parse_from(&["cqlite", "--no-color"]);
1237
1238        // Build config
1239        let config = Config::load(None, &cli).unwrap();
1240
1241        // Create OutputConfig with partial CLI flags
1242        let output = OutputConfig::from_cli(
1243            &config,
1244            cli.no_color,
1245            cli.limit,
1246            cli.page_size,
1247            cli.output.clone(),
1248            cli.overwrite,
1249        );
1250
1251        // Verify: --no-color overrides, but env vars are used for limit/page_size
1252        assert!(!output.color_enabled); // --no-color flag
1253        assert_eq!(output.limit, Some(100)); // From CQLITE_LIMIT env
1254        assert_eq!(output.page_size, Some(25)); // From CQLITE_PAGE_SIZE env
1255
1256        // Clean up
1257        env::remove_var("CQLITE_LIMIT");
1258        env::remove_var("CQLITE_PAGE_SIZE");
1259    }
1260
1261    #[test]
1262    #[serial]
1263    fn test_output_config_no_color_flag_false_preserves_env() {
1264        use std::env;
1265
1266        // Set env var to disable colors
1267        env::set_var("CQLITE_NO_COLOR", "true");
1268
1269        // Create CLI without --no-color flag
1270        let cli = Cli::parse_from(&["cqlite"]);
1271
1272        // Build config (should pick up env var)
1273        let config = Config::load(None, &cli).unwrap();
1274
1275        // Create OutputConfig with no_color_flag=false (no flag provided)
1276        let output = OutputConfig::from_cli(&config, false, None, None, None, false);
1277
1278        // Verify env var was respected
1279        assert!(!output.color_enabled); // CQLITE_NO_COLOR=true from env
1280
1281        // Clean up
1282        env::remove_var("CQLITE_NO_COLOR");
1283    }
1284
1285    #[test]
1286    #[serial]
1287    fn test_output_config_complete_precedence_chain() {
1288        use std::env;
1289
1290        // Test the complete chain: flags > env > defaults
1291
1292        // Step 1: Defaults only
1293        env::remove_var("CQLITE_LIMIT");
1294        env::remove_var("CQLITE_PAGE_SIZE");
1295        env::remove_var("CQLITE_NO_COLOR");
1296
1297        let cli = Cli::parse_from(&["cqlite"]);
1298        let config = Config::load(None, &cli).unwrap();
1299        let output = OutputConfig::from_cli(&config, false, None, None, None, false);
1300
1301        assert!(output.color_enabled);
1302        assert_eq!(output.limit, None);
1303        assert_eq!(output.page_size, Some(50));
1304
1305        // Step 2: Env vars override defaults
1306        env::set_var("CQLITE_LIMIT", "150");
1307        env::set_var("CQLITE_PAGE_SIZE", "30");
1308        env::set_var("CQLITE_NO_COLOR", "true");
1309
1310        let cli = Cli::parse_from(&["cqlite"]);
1311        let config = Config::load(None, &cli).unwrap();
1312        let output = OutputConfig::from_cli(&config, false, None, None, None, false);
1313
1314        assert!(!output.color_enabled);
1315        assert_eq!(output.limit, Some(150));
1316        assert_eq!(output.page_size, Some(30));
1317
1318        // Step 3: CLI flags override env vars
1319        let cli = Cli::parse_from(&["cqlite", "--limit", "300", "--page-size", "15"]);
1320        let config = Config::load(None, &cli).unwrap();
1321        let output = OutputConfig::from_cli(
1322            &config,
1323            cli.no_color,
1324            cli.limit,
1325            cli.page_size,
1326            cli.output.clone(),
1327            cli.overwrite,
1328        );
1329
1330        // CLI flags override env, but --no-color not provided so env var still applies
1331        assert!(!output.color_enabled); // Still from CQLITE_NO_COLOR env
1332        assert_eq!(output.limit, Some(300)); // From --limit flag
1333        assert_eq!(output.page_size, Some(15)); // From --page-size flag
1334
1335        // Clean up
1336        env::remove_var("CQLITE_LIMIT");
1337        env::remove_var("CQLITE_PAGE_SIZE");
1338        env::remove_var("CQLITE_NO_COLOR");
1339    }
1340}