cqlsh_rs/
config.rs

1//! Configuration file parsing and merged configuration for cqlsh-rs.
2//!
3//! Handles `~/.cassandra/cqlshrc` (INI format) parsing, environment variable loading,
4//! and merging with CLI arguments following the precedence rule:
5//! CLI > environment variables > cqlshrc > defaults.
6//!
7//! Many fields are defined ahead of their use in later development phases.
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result};
13use configparser::ini::Ini;
14use thiserror::Error;
15
16use crate::cli::CliArgs;
17
18/// Errors specific to configuration loading.
19#[derive(Error, Debug)]
20pub enum ConfigError {
21    #[error("failed to parse cqlshrc file at {path}: {reason}")]
22    ParseError { path: String, reason: String },
23
24    #[error("invalid value for {key}: {reason}")]
25    InvalidValue { key: String, reason: String },
26}
27
28/// Represents the parsed contents of a cqlshrc INI file.
29#[derive(Debug, Clone, Default)]
30pub struct CqlshrcConfig {
31    pub authentication: AuthenticationSection,
32    pub connection: ConnectionSection,
33    pub ssl: SslSection,
34    pub certfiles: HashMap<String, String>,
35    pub ui: UiSection,
36    pub cql: CqlSection,
37    pub csv: CsvSection,
38    pub copy: CopySection,
39    pub copy_to: CopyToSection,
40    pub copy_from: CopyFromSection,
41    pub tracing: TracingSection,
42}
43
44#[derive(Debug, Clone, Default)]
45pub struct AuthenticationSection {
46    pub credentials: Option<String>,
47    pub username: Option<String>,
48    pub password: Option<String>,
49    pub keyspace: Option<String>,
50}
51
52#[derive(Debug, Clone, Default)]
53pub struct ConnectionSection {
54    pub hostname: Option<String>,
55    pub port: Option<u16>,
56    pub factory: Option<String>,
57    pub timeout: Option<u64>,
58    pub request_timeout: Option<u64>,
59    pub connect_timeout: Option<u64>,
60    pub client_timeout: Option<u64>,
61}
62
63#[derive(Debug, Clone, Default)]
64pub struct SslSection {
65    pub certfile: Option<String>,
66    pub validate: Option<bool>,
67    pub userkey: Option<String>,
68    pub usercert: Option<String>,
69    pub version: Option<String>,
70}
71
72#[derive(Debug, Clone, Default)]
73pub struct UiSection {
74    pub color: Option<bool>,
75    pub datetimeformat: Option<String>,
76    pub timezone: Option<String>,
77    pub float_precision: Option<u32>,
78    pub double_precision: Option<u32>,
79    pub max_trace_wait: Option<f64>,
80    pub encoding: Option<String>,
81    pub completekey: Option<String>,
82    pub browser: Option<String>,
83}
84
85#[derive(Debug, Clone, Default)]
86pub struct CqlSection {
87    pub version: Option<String>,
88}
89
90#[derive(Debug, Clone, Default)]
91pub struct CsvSection {
92    pub field_size_limit: Option<usize>,
93}
94
95#[derive(Debug, Clone, Default)]
96pub struct CopySection {
97    pub numprocesses: Option<u32>,
98    pub maxattempts: Option<u32>,
99    pub reportfrequency: Option<f64>,
100}
101
102#[derive(Debug, Clone, Default)]
103pub struct CopyToSection {
104    pub pagesize: Option<u32>,
105    pub pagetimeout: Option<u64>,
106    pub begintoken: Option<String>,
107    pub endtoken: Option<String>,
108    pub maxrequests: Option<u32>,
109    pub maxoutputsize: Option<i64>,
110    pub floatprecision: Option<u32>,
111    pub doubleprecision: Option<u32>,
112}
113
114#[derive(Debug, Clone, Default)]
115pub struct CopyFromSection {
116    pub maxbatchsize: Option<u32>,
117    pub minbatchsize: Option<u32>,
118    pub chunksize: Option<u32>,
119    pub ingestrate: Option<u64>,
120    pub maxparseerrors: Option<i64>,
121    pub maxinserterrors: Option<i64>,
122    pub preparedstatements: Option<bool>,
123    pub ttl: Option<i64>,
124}
125
126#[derive(Debug, Clone, Default)]
127pub struct TracingSection {
128    pub max_trace_wait: Option<f64>,
129}
130
131impl CqlshrcConfig {
132    /// Load a cqlshrc file from the given path. Returns default config if file doesn't exist.
133    pub fn load(path: &Path) -> Result<Self> {
134        if !path.exists() {
135            return Ok(Self::default());
136        }
137
138        let mut ini = Ini::new_cs(); // case-sensitive
139        ini.load(path).map_err(|e| ConfigError::ParseError {
140            path: path.display().to_string(),
141            reason: e,
142        })?;
143
144        Ok(Self::from_ini(&ini))
145    }
146
147    /// Parse a cqlshrc from a string (useful for testing).
148    pub fn parse(content: &str) -> Result<Self> {
149        let mut ini = Ini::new_cs();
150        ini.read(content.to_string())
151            .map_err(|e| ConfigError::ParseError {
152                path: "<string>".to_string(),
153                reason: e,
154            })?;
155
156        Ok(Self::from_ini(&ini))
157    }
158
159    fn from_ini(ini: &Ini) -> Self {
160        Self {
161            authentication: AuthenticationSection {
162                credentials: ini.get("authentication", "credentials"),
163                username: ini.get("authentication", "username"),
164                password: ini.get("authentication", "password"),
165                keyspace: ini.get("authentication", "keyspace"),
166            },
167            connection: ConnectionSection {
168                hostname: ini.get("connection", "hostname"),
169                port: ini.get("connection", "port").and_then(|v| v.parse().ok()),
170                factory: ini.get("connection", "factory"),
171                timeout: ini
172                    .get("connection", "timeout")
173                    .and_then(|v| v.parse().ok()),
174                request_timeout: ini
175                    .get("connection", "request_timeout")
176                    .and_then(|v| v.parse().ok()),
177                connect_timeout: ini
178                    .get("connection", "connect_timeout")
179                    .and_then(|v| v.parse().ok()),
180                client_timeout: ini
181                    .get("connection", "client_timeout")
182                    .and_then(|v| v.parse().ok()),
183            },
184            ssl: SslSection {
185                certfile: ini.get("ssl", "certfile"),
186                validate: ini.get("ssl", "validate").map(|v| parse_bool(&v)),
187                userkey: ini.get("ssl", "userkey"),
188                usercert: ini.get("ssl", "usercert"),
189                version: ini.get("ssl", "version"),
190            },
191            certfiles: ini
192                .get_map()
193                .and_then(|m| m.get("certfiles").cloned())
194                .unwrap_or_default()
195                .into_iter()
196                .filter_map(|(k, v)| v.map(|val| (k, val)))
197                .collect(),
198            ui: UiSection {
199                color: ini.get("ui", "color").map(|v| parse_bool(&v)),
200                datetimeformat: ini.get("ui", "datetimeformat"),
201                timezone: ini.get("ui", "timezone"),
202                float_precision: ini
203                    .get("ui", "float_precision")
204                    .and_then(|v| v.parse().ok()),
205                double_precision: ini
206                    .get("ui", "double_precision")
207                    .and_then(|v| v.parse().ok()),
208                max_trace_wait: ini.get("ui", "max_trace_wait").and_then(|v| v.parse().ok()),
209                encoding: ini.get("ui", "encoding"),
210                completekey: ini.get("ui", "completekey"),
211                browser: ini.get("ui", "browser"),
212            },
213            cql: CqlSection {
214                version: ini.get("cql", "version"),
215            },
216            csv: CsvSection {
217                field_size_limit: ini
218                    .get("csv", "field_size_limit")
219                    .and_then(|v| v.parse().ok()),
220            },
221            copy: CopySection {
222                numprocesses: ini.get("copy", "numprocesses").and_then(|v| v.parse().ok()),
223                maxattempts: ini.get("copy", "maxattempts").and_then(|v| v.parse().ok()),
224                reportfrequency: ini
225                    .get("copy", "reportfrequency")
226                    .and_then(|v| v.parse().ok()),
227            },
228            copy_to: CopyToSection {
229                pagesize: ini.get("copy-to", "pagesize").and_then(|v| v.parse().ok()),
230                pagetimeout: ini
231                    .get("copy-to", "pagetimeout")
232                    .and_then(|v| v.parse().ok()),
233                begintoken: ini.get("copy-to", "begintoken").filter(|s| !s.is_empty()),
234                endtoken: ini.get("copy-to", "endtoken").filter(|s| !s.is_empty()),
235                maxrequests: ini
236                    .get("copy-to", "maxrequests")
237                    .and_then(|v| v.parse().ok()),
238                maxoutputsize: ini
239                    .get("copy-to", "maxoutputsize")
240                    .and_then(|v| v.parse().ok()),
241                floatprecision: ini
242                    .get("copy-to", "floatprecision")
243                    .and_then(|v| v.parse().ok()),
244                doubleprecision: ini
245                    .get("copy-to", "doubleprecision")
246                    .and_then(|v| v.parse().ok()),
247            },
248            copy_from: CopyFromSection {
249                maxbatchsize: ini
250                    .get("copy-from", "maxbatchsize")
251                    .and_then(|v| v.parse().ok()),
252                minbatchsize: ini
253                    .get("copy-from", "minbatchsize")
254                    .and_then(|v| v.parse().ok()),
255                chunksize: ini
256                    .get("copy-from", "chunksize")
257                    .and_then(|v| v.parse().ok()),
258                ingestrate: ini
259                    .get("copy-from", "ingestrate")
260                    .and_then(|v| v.parse().ok()),
261                maxparseerrors: ini
262                    .get("copy-from", "maxparseerrors")
263                    .and_then(|v| v.parse().ok()),
264                maxinserterrors: ini
265                    .get("copy-from", "maxinserterrors")
266                    .and_then(|v| v.parse().ok()),
267                preparedstatements: ini
268                    .get("copy-from", "preparedstatements")
269                    .map(|v| parse_bool(&v)),
270                ttl: ini.get("copy-from", "ttl").and_then(|v| v.parse().ok()),
271            },
272            tracing: TracingSection {
273                max_trace_wait: ini
274                    .get("tracing", "max_trace_wait")
275                    .and_then(|v| v.parse().ok()),
276            },
277        }
278    }
279}
280
281/// Parse boolean values in the same way Python cqlsh does:
282/// "true", "yes", "on", "1" → true, everything else → false.
283fn parse_bool(val: &str) -> bool {
284    matches!(val.to_lowercase().as_str(), "true" | "yes" | "on" | "1")
285}
286
287/// The fully resolved configuration after merging CLI args, environment variables,
288/// cqlshrc file, and defaults. Follows precedence: CLI > env > cqlshrc > defaults.
289#[derive(Debug, Clone)]
290pub struct MergedConfig {
291    pub host: String,
292    pub port: u16,
293    pub username: Option<String>,
294    pub password: Option<String>,
295    pub keyspace: Option<String>,
296    pub ssl: bool,
297    pub color: ColorMode,
298    pub debug: bool,
299    pub tty: bool,
300    pub no_file_io: bool,
301    pub no_compact: bool,
302    pub disable_history: bool,
303    pub execute: Option<String>,
304    pub file: Option<String>,
305    pub connect_timeout: u64,
306    pub request_timeout: u64,
307    pub encoding: String,
308    pub cqlversion: Option<String>,
309    pub protocol_version: Option<u8>,
310    pub consistency_level: Option<String>,
311    pub serial_consistency_level: Option<String>,
312    pub browser: Option<String>,
313    pub secure_connect_bundle: Option<String>,
314    pub cqlshrc_path: PathBuf,
315    pub cqlshrc: CqlshrcConfig,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub enum ColorMode {
320    On,
321    Off,
322    Auto,
323}
324
325/// Default connect timeout in seconds (matches Python cqlsh).
326const DEFAULT_CONNECT_TIMEOUT: u64 = 5;
327/// Default request timeout in seconds (matches Python cqlsh).
328const DEFAULT_REQUEST_TIMEOUT: u64 = 10;
329/// Default host.
330const DEFAULT_HOST: &str = "127.0.0.1";
331/// Default port.
332const DEFAULT_PORT: u16 = 9042;
333
334/// Load environment variables relevant to cqlsh.
335#[derive(Debug, Clone, Default)]
336pub struct EnvConfig {
337    pub host: Option<String>,
338    pub port: Option<u16>,
339    pub ssl_certfile: Option<String>,
340    pub ssl_validate: Option<bool>,
341    pub connect_timeout: Option<u64>,
342    pub request_timeout: Option<u64>,
343    pub history_file: Option<String>,
344}
345
346impl EnvConfig {
347    /// Read cqlsh-related environment variables.
348    pub fn from_env() -> Self {
349        Self {
350            host: std::env::var("CQLSH_HOST").ok(),
351            port: std::env::var("CQLSH_PORT")
352                .ok()
353                .and_then(|v| v.parse().ok()),
354            ssl_certfile: std::env::var("SSL_CERTFILE").ok(),
355            ssl_validate: std::env::var("SSL_VALIDATE").ok().map(|v| parse_bool(&v)),
356            connect_timeout: std::env::var("CQLSH_DEFAULT_CONNECT_TIMEOUT_SECONDS")
357                .ok()
358                .and_then(|v| v.parse().ok()),
359            request_timeout: std::env::var("CQLSH_DEFAULT_REQUEST_TIMEOUT_SECONDS")
360                .ok()
361                .and_then(|v| v.parse().ok()),
362            history_file: std::env::var("CQL_HISTORY").ok(),
363        }
364    }
365}
366
367impl MergedConfig {
368    /// Build a merged configuration from CLI args, environment, and cqlshrc.
369    ///
370    /// Precedence: CLI > environment > cqlshrc > defaults.
371    pub fn build(
372        cli: &CliArgs,
373        env: &EnvConfig,
374        cqlshrc: CqlshrcConfig,
375        cqlshrc_path: PathBuf,
376    ) -> Self {
377        // Host: CLI > env > cqlshrc > default
378        let host = cli
379            .host
380            .clone()
381            .or_else(|| env.host.clone())
382            .or_else(|| cqlshrc.connection.hostname.clone())
383            .unwrap_or_else(|| DEFAULT_HOST.to_string());
384
385        // Port: CLI > env > cqlshrc > default
386        let port = cli
387            .port
388            .or(env.port)
389            .or(cqlshrc.connection.port)
390            .unwrap_or(DEFAULT_PORT);
391
392        // Username: CLI > cqlshrc
393        let username = cli
394            .username
395            .clone()
396            .or_else(|| cqlshrc.authentication.username.clone());
397
398        // Password: CLI > cqlshrc
399        let password = cli
400            .password
401            .clone()
402            .or_else(|| cqlshrc.authentication.password.clone());
403
404        // Keyspace: CLI > cqlshrc
405        let keyspace = cli
406            .keyspace
407            .clone()
408            .or_else(|| cqlshrc.authentication.keyspace.clone());
409
410        // Color: CLI flags > cqlshrc > auto
411        let color = if cli.color {
412            ColorMode::On
413        } else if cli.no_color {
414            ColorMode::Off
415        } else {
416            match &cqlshrc.ui.color {
417                Some(true) => ColorMode::On,
418                Some(false) => ColorMode::Off,
419                None => ColorMode::Auto,
420            }
421        };
422
423        // Connect timeout: CLI > env > cqlshrc > default
424        let connect_timeout = cli
425            .connect_timeout
426            .or(env.connect_timeout)
427            .or(cqlshrc.connection.connect_timeout)
428            .unwrap_or(DEFAULT_CONNECT_TIMEOUT);
429
430        // Request timeout: CLI > env > cqlshrc > default
431        let request_timeout = cli
432            .request_timeout
433            .or(env.request_timeout)
434            .or(cqlshrc.connection.request_timeout)
435            .unwrap_or(DEFAULT_REQUEST_TIMEOUT);
436
437        // Encoding: CLI > cqlshrc > default
438        let encoding = cli
439            .encoding
440            .clone()
441            .or_else(|| cqlshrc.ui.encoding.clone())
442            .unwrap_or_else(|| "utf-8".to_string());
443
444        // CQL version: CLI > cqlshrc
445        let cqlversion = cli
446            .cqlversion
447            .clone()
448            .or_else(|| cqlshrc.cql.version.clone());
449
450        // Browser: CLI > cqlshrc
451        let browser = cli.browser.clone().or_else(|| cqlshrc.ui.browser.clone());
452
453        MergedConfig {
454            host,
455            port,
456            username,
457            password,
458            keyspace,
459            ssl: cli.ssl,
460            color,
461            debug: cli.debug,
462            tty: cli.tty,
463            no_file_io: cli.no_file_io,
464            no_compact: cli.no_compact,
465            disable_history: cli.disable_history,
466            execute: cli.execute.clone(),
467            file: cli.file.clone(),
468            connect_timeout,
469            request_timeout,
470            encoding,
471            cqlversion,
472            protocol_version: cli.protocol_version,
473            consistency_level: cli.consistency_level.clone(),
474            serial_consistency_level: cli.serial_consistency_level.clone(),
475            browser,
476            secure_connect_bundle: cli.secure_connect_bundle.clone(),
477            cqlshrc_path,
478            cqlshrc,
479        }
480    }
481}
482
483/// Resolve the cqlshrc file path based on CLI flag or default location.
484pub fn resolve_cqlshrc_path(cli_path: Option<&str>) -> PathBuf {
485    if let Some(path) = cli_path {
486        PathBuf::from(path)
487    } else {
488        default_cqlshrc_path()
489    }
490}
491
492/// Return the default cqlshrc path: ~/.cassandra/cqlshrc
493pub fn default_cqlshrc_path() -> PathBuf {
494    dirs::home_dir()
495        .unwrap_or_else(|| PathBuf::from("."))
496        .join(".cassandra")
497        .join("cqlshrc")
498}
499
500/// Load the full configuration pipeline: resolve path → load cqlshrc → read env → merge.
501pub fn load_config(cli: &CliArgs) -> Result<MergedConfig> {
502    let cqlshrc_path = resolve_cqlshrc_path(cli.cqlshrc.as_deref());
503    let cqlshrc = CqlshrcConfig::load(&cqlshrc_path)
504        .with_context(|| format!("loading cqlshrc from {}", cqlshrc_path.display()))?;
505    let env = EnvConfig::from_env();
506    Ok(MergedConfig::build(cli, &env, cqlshrc, cqlshrc_path))
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    // --- parse_bool tests ---
514
515    #[test]
516    fn parse_bool_true_variants() {
517        assert!(parse_bool("true"));
518        assert!(parse_bool("True"));
519        assert!(parse_bool("TRUE"));
520        assert!(parse_bool("yes"));
521        assert!(parse_bool("Yes"));
522        assert!(parse_bool("on"));
523        assert!(parse_bool("ON"));
524        assert!(parse_bool("1"));
525    }
526
527    #[test]
528    fn parse_bool_false_variants() {
529        assert!(!parse_bool("false"));
530        assert!(!parse_bool("False"));
531        assert!(!parse_bool("no"));
532        assert!(!parse_bool("off"));
533        assert!(!parse_bool("0"));
534        assert!(!parse_bool(""));
535        assert!(!parse_bool("anything"));
536    }
537
538    // --- CqlshrcConfig parsing tests ---
539
540    #[test]
541    fn parse_empty_config() {
542        let config = CqlshrcConfig::parse("").unwrap();
543        assert!(config.authentication.username.is_none());
544        assert!(config.connection.hostname.is_none());
545        assert!(config.ui.color.is_none());
546    }
547
548    #[test]
549    fn parse_authentication_section() {
550        let config = CqlshrcConfig::parse(
551            "[authentication]\nusername = admin\npassword = secret\nkeyspace = test_ks\n",
552        )
553        .unwrap();
554        assert_eq!(config.authentication.username.as_deref(), Some("admin"));
555        assert_eq!(config.authentication.password.as_deref(), Some("secret"));
556        assert_eq!(config.authentication.keyspace.as_deref(), Some("test_ks"));
557    }
558
559    #[test]
560    fn parse_connection_section() {
561        let config = CqlshrcConfig::parse(
562            "[connection]\nhostname = 10.0.0.1\nport = 9043\ntimeout = 30\nrequest_timeout = 60\nconnect_timeout = 15\n",
563        )
564        .unwrap();
565        assert_eq!(config.connection.hostname.as_deref(), Some("10.0.0.1"));
566        assert_eq!(config.connection.port, Some(9043));
567        assert_eq!(config.connection.timeout, Some(30));
568        assert_eq!(config.connection.request_timeout, Some(60));
569        assert_eq!(config.connection.connect_timeout, Some(15));
570    }
571
572    #[test]
573    fn parse_ssl_section() {
574        let config = CqlshrcConfig::parse(
575            "[ssl]\ncertfile = /path/to/cert.pem\nvalidate = true\nuserkey = /path/to/key.pem\nusercert = /path/to/usercert.pem\nversion = TLSv1_2\n",
576        )
577        .unwrap();
578        assert_eq!(config.ssl.certfile.as_deref(), Some("/path/to/cert.pem"));
579        assert_eq!(config.ssl.validate, Some(true));
580        assert_eq!(config.ssl.userkey.as_deref(), Some("/path/to/key.pem"));
581        assert_eq!(config.ssl.version.as_deref(), Some("TLSv1_2"));
582    }
583
584    #[test]
585    fn parse_ui_section() {
586        let config = CqlshrcConfig::parse(
587            "[ui]\ncolor = on\ndatetimeformat = %Y-%m-%d %H:%M:%S%z\ntimezone = UTC\nfloat_precision = 5\ndouble_precision = 12\nmax_trace_wait = 10.0\nencoding = utf-8\ncompletekey = tab\n",
588        )
589        .unwrap();
590        assert_eq!(config.ui.color, Some(true));
591        assert_eq!(
592            config.ui.datetimeformat.as_deref(),
593            Some("%Y-%m-%d %H:%M:%S%z")
594        );
595        assert_eq!(config.ui.timezone.as_deref(), Some("UTC"));
596        assert_eq!(config.ui.float_precision, Some(5));
597        assert_eq!(config.ui.double_precision, Some(12));
598        assert_eq!(config.ui.max_trace_wait, Some(10.0));
599        assert_eq!(config.ui.encoding.as_deref(), Some("utf-8"));
600        assert_eq!(config.ui.completekey.as_deref(), Some("tab"));
601    }
602
603    #[test]
604    fn parse_cql_section() {
605        let config = CqlshrcConfig::parse("[cql]\nversion = 3.4.7\n").unwrap();
606        assert_eq!(config.cql.version.as_deref(), Some("3.4.7"));
607    }
608
609    #[test]
610    fn parse_csv_section() {
611        let config = CqlshrcConfig::parse("[csv]\nfield_size_limit = 131072\n").unwrap();
612        assert_eq!(config.csv.field_size_limit, Some(131072));
613    }
614
615    #[test]
616    fn parse_copy_section() {
617        let config = CqlshrcConfig::parse(
618            "[copy]\nnumprocesses = 4\nmaxattempts = 5\nreportfrequency = 0.25\n",
619        )
620        .unwrap();
621        assert_eq!(config.copy.numprocesses, Some(4));
622        assert_eq!(config.copy.maxattempts, Some(5));
623        assert_eq!(config.copy.reportfrequency, Some(0.25));
624    }
625
626    #[test]
627    fn parse_copy_to_section() {
628        let config = CqlshrcConfig::parse(
629            "[copy-to]\npagesize = 1000\npagetimeout = 10\nmaxrequests = 6\nmaxoutputsize = -1\nfloatprecision = 5\ndoubleprecision = 12\n",
630        )
631        .unwrap();
632        assert_eq!(config.copy_to.pagesize, Some(1000));
633        assert_eq!(config.copy_to.pagetimeout, Some(10));
634        assert_eq!(config.copy_to.maxrequests, Some(6));
635        assert_eq!(config.copy_to.maxoutputsize, Some(-1));
636        assert_eq!(config.copy_to.floatprecision, Some(5));
637        assert_eq!(config.copy_to.doubleprecision, Some(12));
638    }
639
640    #[test]
641    fn parse_copy_from_section() {
642        let config = CqlshrcConfig::parse(
643            "[copy-from]\nmaxbatchsize = 20\nminbatchsize = 10\nchunksize = 5000\ningestrate = 100000\nmaxparseerrors = -1\nmaxinserterrors = 1000\npreparedstatements = true\nttl = 3600\n",
644        )
645        .unwrap();
646        assert_eq!(config.copy_from.maxbatchsize, Some(20));
647        assert_eq!(config.copy_from.minbatchsize, Some(10));
648        assert_eq!(config.copy_from.chunksize, Some(5000));
649        assert_eq!(config.copy_from.ingestrate, Some(100000));
650        assert_eq!(config.copy_from.maxparseerrors, Some(-1));
651        assert_eq!(config.copy_from.maxinserterrors, Some(1000));
652        assert_eq!(config.copy_from.preparedstatements, Some(true));
653        assert_eq!(config.copy_from.ttl, Some(3600));
654    }
655
656    #[test]
657    fn parse_tracing_section() {
658        let config = CqlshrcConfig::parse("[tracing]\nmax_trace_wait = 10.0\n").unwrap();
659        assert_eq!(config.tracing.max_trace_wait, Some(10.0));
660    }
661
662    #[test]
663    fn parse_certfiles_section() {
664        let config = CqlshrcConfig::parse(
665            "[certfiles]\n172.31.10.22 = ~/keys/node0.cer.pem\n172.31.8.141 = ~/keys/node1.cer.pem\n",
666        )
667        .unwrap();
668        assert_eq!(
669            config.certfiles.get("172.31.10.22").map(|s| s.as_str()),
670            Some("~/keys/node0.cer.pem")
671        );
672        assert_eq!(
673            config.certfiles.get("172.31.8.141").map(|s| s.as_str()),
674            Some("~/keys/node1.cer.pem")
675        );
676    }
677
678    #[test]
679    fn parse_full_sample_config() {
680        let content = r#"
681[authentication]
682username = cassandra
683password = cassandra
684keyspace = my_keyspace
685
686[connection]
687hostname = 127.0.0.1
688port = 9042
689timeout = 10
690request_timeout = 10
691connect_timeout = 5
692
693[ssl]
694certfile = /path/to/ca-cert.pem
695validate = true
696
697[ui]
698color = on
699datetimeformat = %Y-%m-%d %H:%M:%S%z
700timezone = UTC
701float_precision = 5
702double_precision = 12
703
704[cql]
705version = 3.4.7
706
707[csv]
708field_size_limit = 131072
709
710[copy]
711numprocesses = 4
712maxattempts = 5
713
714[copy-to]
715pagesize = 1000
716floatprecision = 5
717
718[copy-from]
719maxbatchsize = 20
720chunksize = 5000
721preparedstatements = true
722ttl = 3600
723
724[tracing]
725max_trace_wait = 10.0
726"#;
727        let config = CqlshrcConfig::parse(content).unwrap();
728        assert_eq!(config.authentication.username.as_deref(), Some("cassandra"));
729        assert_eq!(config.connection.port, Some(9042));
730        assert_eq!(config.ui.color, Some(true));
731        assert_eq!(config.cql.version.as_deref(), Some("3.4.7"));
732        assert_eq!(config.copy.numprocesses, Some(4));
733        assert_eq!(config.copy_to.pagesize, Some(1000));
734        assert_eq!(config.copy_from.ttl, Some(3600));
735        assert_eq!(config.tracing.max_trace_wait, Some(10.0));
736    }
737
738    #[test]
739    fn parse_unknown_keys_ignored() {
740        let config =
741            CqlshrcConfig::parse("[authentication]\nunknown_key = value\nusername = test\n")
742                .unwrap();
743        assert_eq!(config.authentication.username.as_deref(), Some("test"));
744    }
745
746    #[test]
747    fn load_nonexistent_file_returns_default() {
748        let config = CqlshrcConfig::load(Path::new("/nonexistent/path/cqlshrc")).unwrap();
749        assert!(config.authentication.username.is_none());
750        assert!(config.connection.hostname.is_none());
751    }
752
753    // --- MergedConfig precedence tests ---
754
755    fn default_cli() -> CliArgs {
756        CliArgs {
757            host: None,
758            port: None,
759            color: false,
760            no_color: false,
761            browser: None,
762            ssl: false,
763            no_file_io: false,
764            debug: false,
765            coverage: false,
766            execute: None,
767            file: None,
768            keyspace: None,
769            username: None,
770            password: None,
771            connect_timeout: None,
772            request_timeout: None,
773            tty: false,
774            encoding: None,
775            cqlshrc: None,
776            cqlversion: None,
777            protocol_version: None,
778            consistency_level: None,
779            serial_consistency_level: None,
780            no_compact: false,
781            disable_history: false,
782            secure_connect_bundle: None,
783            completions: None,
784            generate_man: false,
785        }
786    }
787
788    #[test]
789    fn merged_defaults() {
790        let cli = default_cli();
791        let env = EnvConfig::default();
792        let cqlshrc = CqlshrcConfig::default();
793        let config = MergedConfig::build(&cli, &env, cqlshrc, default_cqlshrc_path());
794
795        assert_eq!(config.host, "127.0.0.1");
796        assert_eq!(config.port, 9042);
797        assert!(config.username.is_none());
798        assert!(config.password.is_none());
799        assert!(config.keyspace.is_none());
800        assert!(!config.ssl);
801        assert_eq!(config.color, ColorMode::Auto);
802        assert_eq!(config.connect_timeout, DEFAULT_CONNECT_TIMEOUT);
803        assert_eq!(config.request_timeout, DEFAULT_REQUEST_TIMEOUT);
804        assert_eq!(config.encoding, "utf-8");
805    }
806
807    #[test]
808    fn cli_overrides_everything() {
809        let cli = CliArgs {
810            host: Some("cli-host".to_string()),
811            port: Some(9999),
812            username: Some("cli-user".to_string()),
813            connect_timeout: Some(99),
814            ..default_cli()
815        };
816        let env = EnvConfig {
817            host: Some("env-host".to_string()),
818            port: Some(8888),
819            connect_timeout: Some(88),
820            ..EnvConfig::default()
821        };
822        let mut cqlshrc = CqlshrcConfig::default();
823        cqlshrc.connection.hostname = Some("cqlshrc-host".to_string());
824        cqlshrc.connection.port = Some(7777);
825        cqlshrc.connection.connect_timeout = Some(77);
826        cqlshrc.authentication.username = Some("cqlshrc-user".to_string());
827
828        let config = MergedConfig::build(&cli, &env, cqlshrc, default_cqlshrc_path());
829
830        assert_eq!(config.host, "cli-host");
831        assert_eq!(config.port, 9999);
832        assert_eq!(config.username.as_deref(), Some("cli-user"));
833        assert_eq!(config.connect_timeout, 99);
834    }
835
836    #[test]
837    fn env_overrides_cqlshrc() {
838        let cli = default_cli();
839        let env = EnvConfig {
840            host: Some("env-host".to_string()),
841            port: Some(8888),
842            connect_timeout: Some(88),
843            ..EnvConfig::default()
844        };
845        let mut cqlshrc = CqlshrcConfig::default();
846        cqlshrc.connection.hostname = Some("cqlshrc-host".to_string());
847        cqlshrc.connection.port = Some(7777);
848        cqlshrc.connection.connect_timeout = Some(77);
849
850        let config = MergedConfig::build(&cli, &env, cqlshrc, default_cqlshrc_path());
851
852        assert_eq!(config.host, "env-host");
853        assert_eq!(config.port, 8888);
854        assert_eq!(config.connect_timeout, 88);
855    }
856
857    #[test]
858    fn cqlshrc_overrides_defaults() {
859        let cli = default_cli();
860        let env = EnvConfig::default();
861        let mut cqlshrc = CqlshrcConfig::default();
862        cqlshrc.connection.hostname = Some("cqlshrc-host".to_string());
863        cqlshrc.connection.port = Some(7777);
864        cqlshrc.connection.connect_timeout = Some(77);
865        cqlshrc.connection.request_timeout = Some(99);
866        cqlshrc.authentication.username = Some("cqlshrc-user".to_string());
867        cqlshrc.authentication.keyspace = Some("cqlshrc-ks".to_string());
868
869        let config = MergedConfig::build(&cli, &env, cqlshrc, default_cqlshrc_path());
870
871        assert_eq!(config.host, "cqlshrc-host");
872        assert_eq!(config.port, 7777);
873        assert_eq!(config.connect_timeout, 77);
874        assert_eq!(config.request_timeout, 99);
875        assert_eq!(config.username.as_deref(), Some("cqlshrc-user"));
876        assert_eq!(config.keyspace.as_deref(), Some("cqlshrc-ks"));
877    }
878
879    #[test]
880    fn color_mode_cli_on() {
881        let cli = CliArgs {
882            color: true,
883            ..default_cli()
884        };
885        let config = MergedConfig::build(
886            &cli,
887            &EnvConfig::default(),
888            CqlshrcConfig::default(),
889            default_cqlshrc_path(),
890        );
891        assert_eq!(config.color, ColorMode::On);
892    }
893
894    #[test]
895    fn color_mode_cli_off() {
896        let cli = CliArgs {
897            no_color: true,
898            ..default_cli()
899        };
900        let config = MergedConfig::build(
901            &cli,
902            &EnvConfig::default(),
903            CqlshrcConfig::default(),
904            default_cqlshrc_path(),
905        );
906        assert_eq!(config.color, ColorMode::Off);
907    }
908
909    #[test]
910    fn color_mode_cqlshrc_on() {
911        let mut cqlshrc = CqlshrcConfig::default();
912        cqlshrc.ui.color = Some(true);
913        let config = MergedConfig::build(
914            &default_cli(),
915            &EnvConfig::default(),
916            cqlshrc,
917            default_cqlshrc_path(),
918        );
919        assert_eq!(config.color, ColorMode::On);
920    }
921
922    #[test]
923    fn color_mode_cqlshrc_off() {
924        let mut cqlshrc = CqlshrcConfig::default();
925        cqlshrc.ui.color = Some(false);
926        let config = MergedConfig::build(
927            &default_cli(),
928            &EnvConfig::default(),
929            cqlshrc,
930            default_cqlshrc_path(),
931        );
932        assert_eq!(config.color, ColorMode::Off);
933    }
934
935    #[test]
936    fn color_mode_auto_when_unset() {
937        let config = MergedConfig::build(
938            &default_cli(),
939            &EnvConfig::default(),
940            CqlshrcConfig::default(),
941            default_cqlshrc_path(),
942        );
943        assert_eq!(config.color, ColorMode::Auto);
944    }
945
946    #[test]
947    fn encoding_precedence() {
948        // CLI > cqlshrc > default
949        let mut cqlshrc = CqlshrcConfig::default();
950        cqlshrc.ui.encoding = Some("latin-1".to_string());
951
952        // With only cqlshrc set
953        let config = MergedConfig::build(
954            &default_cli(),
955            &EnvConfig::default(),
956            cqlshrc.clone(),
957            default_cqlshrc_path(),
958        );
959        assert_eq!(config.encoding, "latin-1");
960
961        // CLI overrides
962        let cli = CliArgs {
963            encoding: Some("utf-16".to_string()),
964            ..default_cli()
965        };
966        let config =
967            MergedConfig::build(&cli, &EnvConfig::default(), cqlshrc, default_cqlshrc_path());
968        assert_eq!(config.encoding, "utf-16");
969    }
970
971    #[test]
972    fn cqlversion_precedence() {
973        let mut cqlshrc = CqlshrcConfig::default();
974        cqlshrc.cql.version = Some("3.4.5".to_string());
975
976        let config = MergedConfig::build(
977            &default_cli(),
978            &EnvConfig::default(),
979            cqlshrc.clone(),
980            default_cqlshrc_path(),
981        );
982        assert_eq!(config.cqlversion.as_deref(), Some("3.4.5"));
983
984        let cli = CliArgs {
985            cqlversion: Some("3.4.7".to_string()),
986            ..default_cli()
987        };
988        let config =
989            MergedConfig::build(&cli, &EnvConfig::default(), cqlshrc, default_cqlshrc_path());
990        assert_eq!(config.cqlversion.as_deref(), Some("3.4.7"));
991    }
992
993    #[test]
994    fn resolve_cqlshrc_path_custom() {
995        let path = resolve_cqlshrc_path(Some("/etc/custom/cqlshrc"));
996        assert_eq!(path, PathBuf::from("/etc/custom/cqlshrc"));
997    }
998
999    #[test]
1000    fn resolve_cqlshrc_path_default() {
1001        let path = resolve_cqlshrc_path(None);
1002        assert!(path.ends_with(".cassandra/cqlshrc"));
1003    }
1004
1005    // --- File loading tests ---
1006
1007    #[test]
1008    fn load_config_from_tempfile() {
1009        let dir = tempfile::tempdir().unwrap();
1010        let cqlshrc_path = dir.path().join("cqlshrc");
1011        std::fs::write(
1012            &cqlshrc_path,
1013            "[authentication]\nusername = file_user\n[connection]\nport = 9999\n",
1014        )
1015        .unwrap();
1016
1017        let config = CqlshrcConfig::load(&cqlshrc_path).unwrap();
1018        assert_eq!(config.authentication.username.as_deref(), Some("file_user"));
1019        assert_eq!(config.connection.port, Some(9999));
1020    }
1021
1022    #[test]
1023    fn ssl_validate_false() {
1024        let config = CqlshrcConfig::parse("[ssl]\nvalidate = false\n").unwrap();
1025        assert_eq!(config.ssl.validate, Some(false));
1026    }
1027
1028    #[test]
1029    fn copy_from_preparedstatements_false() {
1030        let config = CqlshrcConfig::parse("[copy-from]\npreparedstatements = false\n").unwrap();
1031        assert_eq!(config.copy_from.preparedstatements, Some(false));
1032    }
1033
1034    #[test]
1035    fn invalid_numeric_ignored() {
1036        let config = CqlshrcConfig::parse("[connection]\nport = not_a_number\n").unwrap();
1037        assert!(config.connection.port.is_none());
1038    }
1039
1040    #[test]
1041    fn copy_to_empty_begintoken() {
1042        let config = CqlshrcConfig::parse("[copy-to]\nbegintoken = \n").unwrap();
1043        assert!(config.copy_to.begintoken.is_none());
1044    }
1045}