cqlsh_rs/
cli.rs

1//! Command-line argument parsing for cqlsh-rs.
2//!
3//! Implements 100% CLI compatibility with Python cqlsh, accepting all flags
4//! from `cqlsh --help` across Cassandra 3.11, 4.x, and 5.x.
5
6use clap::Parser;
7use clap_complete::Shell;
8
9/// Build a version string that includes the git SHA.
10///
11/// Produces `0.1.0 (abc1234)` or `0.1.0 (abc1234-dirty)` when the
12/// working tree had uncommitted changes at build time.
13fn long_version() -> &'static str {
14    // Concatenation of compile-time literals — zero runtime cost.
15    if env!("CQLSH_GIT_DIRTY") == "true" {
16        concat!(
17            env!("CARGO_PKG_VERSION"),
18            " (",
19            env!("CQLSH_GIT_SHA"),
20            "-dirty)"
21        )
22    } else {
23        concat!(env!("CARGO_PKG_VERSION"), " (", env!("CQLSH_GIT_SHA"), ")")
24    }
25}
26
27/// The Apache Cassandra interactive CQL shell (Rust implementation).
28///
29/// Connects to a Cassandra cluster and provides an interactive shell
30/// for executing CQL statements.
31#[derive(Parser, Debug, Clone)]
32#[command(name = "cqlsh", version, long_version = long_version(), about, disable_help_flag = false)]
33pub struct CliArgs {
34    /// Contact point hostname (default: 127.0.0.1)
35    #[arg(value_name = "host")]
36    pub host: Option<String>,
37
38    /// Native transport port (default: 9042)
39    #[arg(value_name = "port")]
40    pub port: Option<u16>,
41
42    /// Force colored output
43    #[arg(short = 'C', long = "color")]
44    pub color: bool,
45
46    /// Disable colored output
47    #[arg(long = "no-color")]
48    pub no_color: bool,
49
50    /// Browser for CQL HELP (unused in modern cqlsh)
51    #[arg(long = "browser", value_name = "BROWSER")]
52    pub browser: Option<String>,
53
54    /// Enable SSL/TLS connection
55    #[arg(long = "ssl")]
56    pub ssl: bool,
57
58    /// Disable file I/O commands (COPY, SOURCE, CAPTURE)
59    #[arg(long = "no-file-io")]
60    pub no_file_io: bool,
61
62    /// Show additional debug info
63    #[arg(long = "debug")]
64    pub debug: bool,
65
66    /// Collect coverage (internal, accepted but ignored)
67    #[arg(long = "coverage", hide = true)]
68    pub coverage: bool,
69
70    /// Execute a CQL statement and exit
71    #[arg(short = 'e', long = "execute", value_name = "STATEMENT")]
72    pub execute: Option<String>,
73
74    /// Execute statements from a file
75    #[arg(short = 'f', long = "file", value_name = "FILE")]
76    pub file: Option<String>,
77
78    /// Default keyspace
79    #[arg(short = 'k', long = "keyspace", value_name = "KEYSPACE")]
80    pub keyspace: Option<String>,
81
82    /// Authentication username
83    #[arg(short = 'u', long = "username", value_name = "USERNAME")]
84    pub username: Option<String>,
85
86    /// Authentication password
87    #[arg(short = 'p', long = "password", value_name = "PASSWORD")]
88    pub password: Option<String>,
89
90    /// Connection timeout in seconds
91    #[arg(long = "connect-timeout", value_name = "SECONDS")]
92    pub connect_timeout: Option<u64>,
93
94    /// Per-request timeout in seconds
95    #[arg(long = "request-timeout", value_name = "SECONDS")]
96    pub request_timeout: Option<u64>,
97
98    /// Force TTY mode
99    #[arg(short = 't', long = "tty")]
100    pub tty: bool,
101
102    /// Set character encoding (default: utf-8)
103    #[arg(long = "encoding", value_name = "ENCODING")]
104    pub encoding: Option<String>,
105
106    /// Path to cqlshrc file (default: ~/.cassandra/cqlshrc)
107    #[arg(long = "cqlshrc", value_name = "FILE")]
108    pub cqlshrc: Option<String>,
109
110    /// CQL version to use
111    #[arg(long = "cqlversion", value_name = "VERSION")]
112    pub cqlversion: Option<String>,
113
114    /// Native protocol version
115    #[arg(long = "protocol-version", value_name = "VERSION")]
116    pub protocol_version: Option<u8>,
117
118    /// Initial consistency level
119    #[arg(long = "consistency-level", value_name = "LEVEL")]
120    pub consistency_level: Option<String>,
121
122    /// Initial serial consistency level
123    #[arg(long = "serial-consistency-level", value_name = "LEVEL")]
124    pub serial_consistency_level: Option<String>,
125
126    /// Disable compact storage interpretation
127    #[arg(long = "no_compact")]
128    pub no_compact: bool,
129
130    /// Disable saving of command history
131    #[arg(long = "disable-history")]
132    pub disable_history: bool,
133
134    /// Secure connect bundle for Astra DB
135    #[arg(short = 'b', long = "secure-connect-bundle", value_name = "BUNDLE")]
136    pub secure_connect_bundle: Option<String>,
137
138    /// Generate shell completion script for the given shell (bash, zsh, fish, elvish, powershell)
139    #[arg(long = "completions", value_name = "SHELL")]
140    pub completions: Option<Shell>,
141
142    /// Generate man page to stdout (hidden, used by release pipeline)
143    #[arg(long = "generate-man", hide = true)]
144    pub generate_man: bool,
145}
146
147impl CliArgs {
148    /// Validate CLI arguments for mutual exclusivity and ranges.
149    pub fn validate(&self) -> Result<(), String> {
150        if self.color && self.no_color {
151            return Err("Cannot use both --color and --no-color".to_string());
152        }
153
154        if self.execute.is_some() && self.file.is_some() {
155            return Err("Cannot use both --execute and --file".to_string());
156        }
157
158        if let Some(pv) = self.protocol_version {
159            if !(1..=6).contains(&pv) {
160                return Err(format!(
161                    "Protocol version must be between 1 and 6, got {}",
162                    pv
163                ));
164            }
165        }
166
167        Ok(())
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use clap::Parser;
175
176    fn parse(args: &[&str]) -> CliArgs {
177        let mut full_args = vec!["cqlsh-rs"];
178        full_args.extend_from_slice(args);
179        CliArgs::parse_from(full_args)
180    }
181
182    #[test]
183    fn no_args_defaults() {
184        let args = parse(&[]);
185        assert!(args.host.is_none());
186        assert!(args.port.is_none());
187        assert!(!args.color);
188        assert!(!args.no_color);
189        assert!(!args.ssl);
190        assert!(!args.debug);
191        assert!(!args.tty);
192        assert!(!args.no_file_io);
193        assert!(!args.no_compact);
194        assert!(!args.disable_history);
195        assert!(args.execute.is_none());
196        assert!(args.file.is_none());
197        assert!(args.keyspace.is_none());
198        assert!(args.username.is_none());
199        assert!(args.password.is_none());
200        assert!(args.connect_timeout.is_none());
201        assert!(args.request_timeout.is_none());
202        assert!(args.encoding.is_none());
203        assert!(args.cqlshrc.is_none());
204        assert!(args.cqlversion.is_none());
205        assert!(args.protocol_version.is_none());
206        assert!(args.consistency_level.is_none());
207        assert!(args.serial_consistency_level.is_none());
208        assert!(args.browser.is_none());
209        assert!(args.secure_connect_bundle.is_none());
210    }
211
212    #[test]
213    fn positional_host() {
214        let args = parse(&["192.168.1.1"]);
215        assert_eq!(args.host.as_deref(), Some("192.168.1.1"));
216        assert!(args.port.is_none());
217    }
218
219    #[test]
220    fn positional_host_and_port() {
221        let args = parse(&["192.168.1.1", "9043"]);
222        assert_eq!(args.host.as_deref(), Some("192.168.1.1"));
223        assert_eq!(args.port, Some(9043));
224    }
225
226    #[test]
227    fn execute_flag_short() {
228        let args = parse(&["-e", "SELECT * FROM system.local"]);
229        assert_eq!(args.execute.as_deref(), Some("SELECT * FROM system.local"));
230    }
231
232    #[test]
233    fn execute_flag_long() {
234        let args = parse(&["--execute", "DESC KEYSPACES"]);
235        assert_eq!(args.execute.as_deref(), Some("DESC KEYSPACES"));
236    }
237
238    #[test]
239    fn file_flag() {
240        let args = parse(&["-f", "/tmp/schema.cql"]);
241        assert_eq!(args.file.as_deref(), Some("/tmp/schema.cql"));
242    }
243
244    #[test]
245    fn keyspace_flag() {
246        let args = parse(&["-k", "my_keyspace"]);
247        assert_eq!(args.keyspace.as_deref(), Some("my_keyspace"));
248    }
249
250    #[test]
251    fn auth_flags() {
252        let args = parse(&["-u", "admin", "-p", "secret"]);
253        assert_eq!(args.username.as_deref(), Some("admin"));
254        assert_eq!(args.password.as_deref(), Some("secret"));
255    }
256
257    #[test]
258    fn ssl_flag() {
259        let args = parse(&["--ssl"]);
260        assert!(args.ssl);
261    }
262
263    #[test]
264    fn color_flag() {
265        let args = parse(&["-C"]);
266        assert!(args.color);
267    }
268
269    #[test]
270    fn no_color_flag() {
271        let args = parse(&["--no-color"]);
272        assert!(args.no_color);
273    }
274
275    #[test]
276    fn debug_flag() {
277        let args = parse(&["--debug"]);
278        assert!(args.debug);
279    }
280
281    #[test]
282    fn tty_flag_short() {
283        let args = parse(&["-t"]);
284        assert!(args.tty);
285    }
286
287    #[test]
288    fn tty_flag_long() {
289        let args = parse(&["--tty"]);
290        assert!(args.tty);
291    }
292
293    #[test]
294    fn timeout_flags() {
295        let args = parse(&["--connect-timeout", "30", "--request-timeout", "60"]);
296        assert_eq!(args.connect_timeout, Some(30));
297        assert_eq!(args.request_timeout, Some(60));
298    }
299
300    #[test]
301    fn encoding_flag() {
302        let args = parse(&["--encoding", "latin-1"]);
303        assert_eq!(args.encoding.as_deref(), Some("latin-1"));
304    }
305
306    #[test]
307    fn cqlshrc_flag() {
308        let args = parse(&["--cqlshrc", "/etc/cqlshrc"]);
309        assert_eq!(args.cqlshrc.as_deref(), Some("/etc/cqlshrc"));
310    }
311
312    #[test]
313    fn cqlversion_flag() {
314        let args = parse(&["--cqlversion", "3.4.5"]);
315        assert_eq!(args.cqlversion.as_deref(), Some("3.4.5"));
316    }
317
318    #[test]
319    fn protocol_version_flag() {
320        let args = parse(&["--protocol-version", "4"]);
321        assert_eq!(args.protocol_version, Some(4));
322    }
323
324    #[test]
325    fn consistency_level_flag() {
326        let args = parse(&["--consistency-level", "QUORUM"]);
327        assert_eq!(args.consistency_level.as_deref(), Some("QUORUM"));
328    }
329
330    #[test]
331    fn serial_consistency_level_flag() {
332        let args = parse(&["--serial-consistency-level", "LOCAL_SERIAL"]);
333        assert_eq!(
334            args.serial_consistency_level.as_deref(),
335            Some("LOCAL_SERIAL")
336        );
337    }
338
339    #[test]
340    fn no_file_io_flag() {
341        let args = parse(&["--no-file-io"]);
342        assert!(args.no_file_io);
343    }
344
345    #[test]
346    fn no_compact_flag() {
347        let args = parse(&["--no_compact"]);
348        assert!(args.no_compact);
349    }
350
351    #[test]
352    fn disable_history_flag() {
353        let args = parse(&["--disable-history"]);
354        assert!(args.disable_history);
355    }
356
357    #[test]
358    fn secure_connect_bundle_flag() {
359        let args = parse(&["-b", "/path/to/bundle.zip"]);
360        assert_eq!(
361            args.secure_connect_bundle.as_deref(),
362            Some("/path/to/bundle.zip")
363        );
364    }
365
366    #[test]
367    fn browser_flag() {
368        let args = parse(&["--browser", "firefox"]);
369        assert_eq!(args.browser.as_deref(), Some("firefox"));
370    }
371
372    #[test]
373    fn combined_flags() {
374        let args = parse(&[
375            "10.0.0.1",
376            "9142",
377            "-u",
378            "admin",
379            "-p",
380            "pass",
381            "-k",
382            "test_ks",
383            "--ssl",
384            "-C",
385            "--connect-timeout",
386            "15",
387        ]);
388        assert_eq!(args.host.as_deref(), Some("10.0.0.1"));
389        assert_eq!(args.port, Some(9142));
390        assert_eq!(args.username.as_deref(), Some("admin"));
391        assert_eq!(args.password.as_deref(), Some("pass"));
392        assert_eq!(args.keyspace.as_deref(), Some("test_ks"));
393        assert!(args.ssl);
394        assert!(args.color);
395        assert_eq!(args.connect_timeout, Some(15));
396    }
397
398    // Validation tests
399
400    #[test]
401    fn validate_color_conflict() {
402        let args = parse(&["-C", "--no-color"]);
403        let result = args.validate();
404        assert!(result.is_err());
405        assert!(result.unwrap_err().contains("--color"));
406    }
407
408    #[test]
409    fn validate_execute_and_file_conflict() {
410        let args = parse(&["-e", "SELECT 1", "-f", "test.cql"]);
411        let result = args.validate();
412        assert!(result.is_err());
413        assert!(result.unwrap_err().contains("--execute"));
414    }
415
416    #[test]
417    fn validate_protocol_version_range() {
418        let args = parse(&["--protocol-version", "4"]);
419        assert!(args.validate().is_ok());
420    }
421
422    #[test]
423    fn validate_valid_args() {
424        let args = parse(&["-u", "admin", "--ssl", "-k", "test"]);
425        assert!(args.validate().is_ok());
426    }
427
428    #[test]
429    fn completions_flag() {
430        let args = parse(&["--completions", "bash"]);
431        assert_eq!(args.completions, Some(Shell::Bash));
432    }
433
434    #[test]
435    fn completions_flag_zsh() {
436        let args = parse(&["--completions", "zsh"]);
437        assert_eq!(args.completions, Some(Shell::Zsh));
438    }
439
440    #[test]
441    fn unknown_flag_produces_error() {
442        let result = CliArgs::try_parse_from(["cqlsh-rs", "--nonexistent"]);
443        assert!(result.is_err());
444    }
445}