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