Skip to main content

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