Skip to main content

rudb_cli/
args.rs

1//! The command line, in DuckDB's spelling.
2//!
3//! DuckDB's shell descends from SQLite's, which means single dash long options, a positional
4//! argument that is the database rather than a script, and a second positional argument that is
5//! SQL. None of that is what a Rust program would choose and all of it is what a script written
6//! against `duckdb` expects, so it is what this parses.
7
8use std::path::PathBuf;
9
10use crate::format::Format;
11
12/// One thing to run before the shell reads its input.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Command {
15    /// SQL, or a dot command, given on the command line.
16    Sql(String),
17    /// A file of them.
18    File(PathBuf),
19}
20
21/// What the shell was asked to do.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum Action {
24    /// Open a database and run.
25    Run(Box<Options>),
26    /// Print the version and stop.
27    Version,
28    /// Print the usage and stop.
29    Help,
30    /// Print the build configuration and stop.
31    Config,
32    /// The command line does not make sense, and this says why.
33    Wrong(String),
34}
35
36/// Everything the command line can set.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Options {
39    /// The database to open. `:memory:` until there is a storage format to open a file with.
40    pub database: String,
41    /// What to run before reading input, in the order it was given.
42    pub commands: Vec<Command>,
43    /// Whether to stop after the commands rather than reading input.
44    pub stop_after_commands: bool,
45    /// Set by `-interactive` and `-batch`, which override the guess made from whether input is a
46    /// terminal.
47    pub interactive: Option<bool>,
48    /// Print each statement before running it.
49    pub echo: bool,
50    /// Stop at the first error even when reading a script.
51    pub bail: bool,
52    /// Open without allowing writes.
53    pub readonly: bool,
54    /// How results are printed, and everything that goes with it.
55    pub settings: crate::format::Settings,
56}
57
58impl Default for Options {
59    fn default() -> Self {
60        Self {
61            database: ":memory:".to_string(),
62            commands: Vec::new(),
63            stop_after_commands: false,
64            interactive: None,
65            echo: false,
66            bail: false,
67            readonly: false,
68            settings: crate::format::Settings::default(),
69        }
70    }
71}
72
73/// Reads the command line.
74///
75/// Unknown options are an error rather than a positional argument. SQLite treats an unrecognized
76/// dash argument as a filename and DuckDB inherits that, which turns a typo into a database called
77/// `-csvv`, so this is one of the few places the shell deliberately does not copy the behaviour.
78pub fn parse(arguments: &[String]) -> Action {
79    let mut options = Options::default();
80    let mut positional = 0;
81    let mut at = 0;
82    while at < arguments.len() {
83        let argument = arguments[at].as_str();
84        at += 1;
85        let mut next = |name: &str| -> Result<String, String> {
86            if at < arguments.len() {
87                let value = arguments[at].clone();
88                at += 1;
89                Ok(value)
90            } else {
91                Err(format!("{name} wants a value"))
92            }
93        };
94        match argument {
95            "-version" | "--version" | "-V" => return Action::Version,
96            "-h" | "-help" | "--help" => return Action::Help,
97            "--print-config" => return Action::Config,
98            "-c" | "-s" | "--command" => match next(argument) {
99                Ok(sql) => {
100                    options.commands.push(Command::Sql(sql));
101                    options.stop_after_commands = true;
102                }
103                Err(why) => return Action::Wrong(why),
104            },
105            "-cmd" => match next(argument) {
106                Ok(sql) => options.commands.push(Command::Sql(sql)),
107                Err(why) => return Action::Wrong(why),
108            },
109            "-f" | "-file" => match next(argument) {
110                Ok(path) => {
111                    options.commands.push(Command::File(PathBuf::from(path)));
112                    options.stop_after_commands = true;
113                }
114                Err(why) => return Action::Wrong(why),
115            },
116            "-init" => match next(argument) {
117                Ok(path) => options.commands.push(Command::File(PathBuf::from(path))),
118                Err(why) => return Action::Wrong(why),
119            },
120            "-separator" => match next(argument) {
121                Ok(value) => options.settings.separator = value,
122                Err(why) => return Action::Wrong(why),
123            },
124            "-newline" => match next(argument) {
125                Ok(value) => options.settings.newline = value,
126                Err(why) => return Action::Wrong(why),
127            },
128            "-nullvalue" => match next(argument) {
129                Ok(value) => options.settings.nullvalue = value,
130                Err(why) => return Action::Wrong(why),
131            },
132            "-header" => options.settings.header = true,
133            "-noheader" => options.settings.header = false,
134            "-echo" => options.echo = true,
135            "-bail" => options.bail = true,
136            "-readonly" => options.readonly = true,
137            "-interactive" => options.interactive = Some(true),
138            "-batch" => options.interactive = Some(false),
139            "-no-stdin" => options.stop_after_commands = true,
140            "-no-init" | "-unsigned" | "-unredacted" | "-safe" => {}
141            other if other.starts_with('-') => {
142                match Format::from_flag(other.trim_start_matches('-')) {
143                    Some(format) => options.settings.set_format_flag(format),
144                    None => return Action::Wrong(format!("unknown option {other}")),
145                }
146            }
147            // The first one is the database and every one after it is SQL, however many there are.
148            // There is no count to get wrong: `duckdb a.db "SELECT 1" extra` does not complain
149            // about the third argument, it runs it, and says the table `extra` does not exist.
150            // Per #246.
151            other => {
152                positional += 1;
153                if positional == 1 {
154                    options.database = other.to_string();
155                } else {
156                    options.commands.push(Command::Sql(other.to_string()));
157                    options.stop_after_commands = true;
158                }
159            }
160        }
161    }
162    Action::Run(Box::new(options))
163}
164
165#[cfg(test)]
166mod tests {
167    use super::{Action, Command, parse};
168    use crate::format::Format;
169
170    fn options(arguments: &[&str]) -> super::Options {
171        let owned: Vec<String> = arguments.iter().map(|text| (*text).to_string()).collect();
172        match parse(&owned) {
173            Action::Run(options) => *options,
174            other => panic!("expected a run, got {other:?}"),
175        }
176    }
177
178    #[test]
179    fn nothing_means_an_interactive_memory_database() {
180        let parsed = options(&[]);
181        assert_eq!(parsed.database, ":memory:");
182        assert!(parsed.commands.is_empty());
183        assert!(!parsed.stop_after_commands);
184    }
185
186    #[test]
187    fn a_command_runs_and_stops() {
188        let parsed = options(&["-c", "SELECT 1"]);
189        assert_eq!(parsed.commands, vec![Command::Sql("SELECT 1".to_string())]);
190        assert!(parsed.stop_after_commands);
191    }
192
193    #[test]
194    fn commands_keep_their_order() {
195        let parsed = options(&["-c", "one", "-c", "two"]);
196        assert_eq!(
197            parsed.commands,
198            vec![Command::Sql("one".to_string()), Command::Sql("two".to_string())]
199        );
200    }
201
202    #[test]
203    fn cmd_runs_first_and_does_not_stop() {
204        let parsed = options(&["-cmd", ".mode csv"]);
205        assert!(!parsed.stop_after_commands);
206    }
207
208    #[test]
209    fn the_first_positional_is_the_database_and_the_second_is_sql() {
210        let parsed = options(&["shop.db", "SELECT 1"]);
211        assert_eq!(parsed.database, "shop.db");
212        assert_eq!(parsed.commands, vec![Command::Sql("SELECT 1".to_string())]);
213        assert!(parsed.stop_after_commands);
214    }
215
216    /// Every positional after the first is another statement, in the order they were written.
217    ///
218    /// DuckDB has no limit here and no error for the count, so neither does this. Per #246.
219    #[test]
220    fn every_positional_after_the_database_is_another_statement() {
221        let parsed = options(&["shop.db", "SELECT 1", "SELECT 2", "SELECT 3"]);
222        assert_eq!(parsed.database, "shop.db");
223        assert_eq!(
224            parsed.commands,
225            vec![
226                Command::Sql("SELECT 1".to_string()),
227                Command::Sql("SELECT 2".to_string()),
228                Command::Sql("SELECT 3".to_string()),
229            ]
230        );
231        assert!(parsed.stop_after_commands);
232    }
233
234    #[test]
235    fn a_mode_flag_sets_the_mode_and_its_separator() {
236        let parsed = options(&["-csv"]);
237        assert_eq!(parsed.settings.format, Format::Csv);
238        assert_eq!(parsed.settings.separator, ",");
239    }
240
241    /// The row separator is the one thing the csv flag does not set, which is DuckDB's behaviour.
242    ///
243    /// `duckdb -csv` writes `\n` at the end of a row and `duckdb -cmd ".mode csv"` writes `\r\n`,
244    /// on the same build in the same run, and `tests/shell.rs` holds both captures. This is the
245    /// parse side of it.
246    #[test]
247    fn a_mode_flag_leaves_the_row_separator_where_it_was_and_the_dot_command_does_not() {
248        assert_eq!(options(&["-csv"]).settings.newline, "\n");
249        assert_eq!(options(&["-csv", "-newline", ";"]).settings.newline, ";");
250    }
251
252    /// What each flag sets, against `duckdb v2.0.0-dev84237` read out of `.show`.
253    ///
254    /// The separators are given first so that a flag which leaves one alone can be told apart from
255    /// one that sets it to the value it already had. Per #239.
256    #[test]
257    fn each_mode_flag_sets_the_separators_that_flag_sets_and_no_others() {
258        let given = |flag: &str| {
259            let parsed = options(&["-separator", ";", "-newline", "@", flag]);
260            (parsed.settings.separator, parsed.settings.newline)
261        };
262        assert_eq!(given("-ascii"), ("\u{1f}".to_string(), "\u{1e}".to_string()));
263        assert_eq!(given("-csv"), (",".to_string(), "@".to_string()));
264        let neither = [
265            "-box",
266            "-column",
267            "-html",
268            "-json",
269            "-jsonlines",
270            "-line",
271            "-list",
272            "-markdown",
273            "-quote",
274            "-table",
275        ];
276        for flag in neither {
277            assert_eq!(given(flag), (";".to_string(), "@".to_string()), "{flag}");
278        }
279    }
280
281    /// The four modes that are not flags, per #238.
282    ///
283    /// Each of them is still a mode, so `.mode tabs` works and `-tabs` does not, which is what the
284    /// binary does. The aliases are not flags either.
285    #[test]
286    fn a_mode_that_duckdb_has_no_flag_for_is_an_error_here_too() {
287        for flag in ["-duckbox", "-insert", "-tabs", "-trash", "-lines", "-tsv", "-ndjson"] {
288            assert!(matches!(parse(&[flag.to_string()]), Action::Wrong(_)), "{flag}");
289        }
290    }
291
292    #[test]
293    fn a_separator_given_after_the_mode_wins() {
294        let parsed = options(&["-csv", "-separator", ";"]);
295        assert_eq!(parsed.settings.separator, ";");
296    }
297
298    #[test]
299    fn an_unknown_option_is_an_error_rather_than_a_filename() {
300        assert!(matches!(parse(&["-csvv".to_string()]), Action::Wrong(_)));
301    }
302
303    #[test]
304    fn an_option_missing_its_value_says_so() {
305        assert!(matches!(parse(&["-c".to_string()]), Action::Wrong(_)));
306    }
307
308    #[test]
309    fn version_and_help_win_wherever_they_appear() {
310        assert!(matches!(parse(&["-csv".to_string(), "-version".to_string()]), Action::Version));
311        assert!(matches!(parse(&["-help".to_string()]), Action::Help));
312    }
313}