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    /// What `--set name=value` asked for, in the order it was given.
55    ///
56    /// Kept apart from [`Options::commands`] rather than pushed in as SQL, because these run
57    /// before everything else whatever position they were written in. A flag that configures the
58    /// engine and a flag that runs a query are two different things, and a benchmark script that
59    /// puts its `--set` at the end of the line means the same thing as one that puts it first.
60    pub sets: Vec<String>,
61    /// Where `--metrics` writes what each statement reported about itself, one document per line.
62    ///
63    /// One per line rather than one array, because a statement's document is written the moment
64    /// that statement finishes and a run that dies half way through still leaves the ones that did
65    /// finish. A harness reading this wants the line for the query it timed, and a file it has to
66    /// read to the end before it can parse any of it is a file it cannot get that from.
67    pub metrics: Option<PathBuf>,
68    /// How results are printed, and everything that goes with it.
69    pub settings: crate::format::Settings,
70}
71
72impl Default for Options {
73    fn default() -> Self {
74        Self {
75            database: ":memory:".to_string(),
76            commands: Vec::new(),
77            stop_after_commands: false,
78            interactive: None,
79            echo: false,
80            bail: false,
81            readonly: false,
82            sets: Vec::new(),
83            metrics: None,
84            settings: crate::format::Settings::default(),
85        }
86    }
87}
88
89/// Reads the command line.
90///
91/// Unknown options are an error rather than a positional argument. SQLite treats an unrecognized
92/// dash argument as a filename and DuckDB inherits that, which turns a typo into a database called
93/// `-csvv`, so this is one of the few places the shell deliberately does not copy the behaviour.
94pub fn parse(arguments: &[String]) -> Action {
95    let mut options = Options::default();
96    let mut positional = 0;
97    let mut at = 0;
98    while at < arguments.len() {
99        let argument = arguments[at].as_str();
100        at += 1;
101        let mut next = |name: &str| -> Result<String, String> {
102            if at < arguments.len() {
103                let value = arguments[at].clone();
104                at += 1;
105                Ok(value)
106            } else {
107                Err(format!("{name} wants a value"))
108            }
109        };
110        match argument {
111            "-version" | "--version" | "-V" => return Action::Version,
112            "-h" | "-help" | "--help" => return Action::Help,
113            "--print-config" => return Action::Config,
114            "-c" | "-s" | "--command" => match next(argument) {
115                Ok(sql) => {
116                    options.commands.push(Command::Sql(sql));
117                    options.stop_after_commands = true;
118                }
119                Err(why) => return Action::Wrong(why),
120            },
121            "-cmd" => match next(argument) {
122                Ok(sql) => options.commands.push(Command::Sql(sql)),
123                Err(why) => return Action::Wrong(why),
124            },
125            "-f" | "-file" => match next(argument) {
126                Ok(path) => {
127                    options.commands.push(Command::File(PathBuf::from(path)));
128                    options.stop_after_commands = true;
129                }
130                Err(why) => return Action::Wrong(why),
131            },
132            "-init" => match next(argument) {
133                Ok(path) => options.commands.push(Command::File(PathBuf::from(path))),
134                Err(why) => return Action::Wrong(why),
135            },
136            // Two dashes, like `--print-config`, because DuckDB has no flag of this name and the
137            // single dash forms in this list are the ones a script written against `duckdb`
138            // already uses. A name that is ours should look like it.
139            "--set" => match next(argument) {
140                Ok(pair) => match pair.split_once('=') {
141                    Some(_) => options.sets.push(pair),
142                    None => {
143                        return Action::Wrong(format!("--set is written name=value, not {pair}"));
144                    }
145                },
146                Err(why) => return Action::Wrong(why),
147            },
148            // Two dashes for the same reason `--set` has two: DuckDB has no flag of this name.
149            "--metrics" => match next(argument) {
150                Ok(path) => options.metrics = Some(PathBuf::from(path)),
151                Err(why) => return Action::Wrong(why),
152            },
153            "-separator" => match next(argument) {
154                Ok(value) => options.settings.separator = value,
155                Err(why) => return Action::Wrong(why),
156            },
157            "-newline" => match next(argument) {
158                Ok(value) => options.settings.newline = value,
159                Err(why) => return Action::Wrong(why),
160            },
161            "-nullvalue" => match next(argument) {
162                Ok(value) => options.settings.nullvalue = value,
163                Err(why) => return Action::Wrong(why),
164            },
165            "-header" => options.settings.header = true,
166            "-noheader" => options.settings.header = false,
167            "-echo" => options.echo = true,
168            "-bail" => options.bail = true,
169            "-readonly" => options.readonly = true,
170            "-interactive" => options.interactive = Some(true),
171            "-batch" => options.interactive = Some(false),
172            "-no-stdin" => options.stop_after_commands = true,
173            "-no-init" | "-unsigned" | "-unredacted" | "-safe" => {}
174            other if other.starts_with('-') => {
175                match Format::from_flag(other.trim_start_matches('-')) {
176                    Some(format) => options.settings.set_format_flag(format),
177                    None => return Action::Wrong(format!("unknown option {other}")),
178                }
179            }
180            // The first one is the database and every one after it is SQL, however many there are.
181            // There is no count to get wrong: `duckdb a.db "SELECT 1" extra` does not complain
182            // about the third argument, it runs it, and says the table `extra` does not exist.
183            // Per #246.
184            other => {
185                positional += 1;
186                if positional == 1 {
187                    options.database = other.to_string();
188                } else {
189                    options.commands.push(Command::Sql(other.to_string()));
190                    options.stop_after_commands = true;
191                }
192            }
193        }
194    }
195    Action::Run(Box::new(options))
196}
197
198#[cfg(test)]
199mod tests {
200    use super::{Action, Command, parse};
201    use crate::format::Format;
202
203    fn options(arguments: &[&str]) -> super::Options {
204        let owned: Vec<String> = arguments.iter().map(|text| (*text).to_string()).collect();
205        match parse(&owned) {
206            Action::Run(options) => *options,
207            other => panic!("expected a run, got {other:?}"),
208        }
209    }
210
211    #[test]
212    fn nothing_means_an_interactive_memory_database() {
213        let parsed = options(&[]);
214        assert_eq!(parsed.database, ":memory:");
215        assert!(parsed.commands.is_empty());
216        assert!(!parsed.stop_after_commands);
217    }
218
219    #[test]
220    fn a_command_runs_and_stops() {
221        let parsed = options(&["-c", "SELECT 1"]);
222        assert_eq!(parsed.commands, vec![Command::Sql("SELECT 1".to_string())]);
223        assert!(parsed.stop_after_commands);
224    }
225
226    #[test]
227    fn commands_keep_their_order() {
228        let parsed = options(&["-c", "one", "-c", "two"]);
229        assert_eq!(
230            parsed.commands,
231            vec![Command::Sql("one".to_string()), Command::Sql("two".to_string())]
232        );
233    }
234
235    #[test]
236    fn cmd_runs_first_and_does_not_stop() {
237        let parsed = options(&["-cmd", ".mode csv"]);
238        assert!(!parsed.stop_after_commands);
239    }
240
241    #[test]
242    fn the_first_positional_is_the_database_and_the_second_is_sql() {
243        let parsed = options(&["shop.db", "SELECT 1"]);
244        assert_eq!(parsed.database, "shop.db");
245        assert_eq!(parsed.commands, vec![Command::Sql("SELECT 1".to_string())]);
246        assert!(parsed.stop_after_commands);
247    }
248
249    /// Every positional after the first is another statement, in the order they were written.
250    ///
251    /// DuckDB has no limit here and no error for the count, so neither does this. Per #246.
252    #[test]
253    fn every_positional_after_the_database_is_another_statement() {
254        let parsed = options(&["shop.db", "SELECT 1", "SELECT 2", "SELECT 3"]);
255        assert_eq!(parsed.database, "shop.db");
256        assert_eq!(
257            parsed.commands,
258            vec![
259                Command::Sql("SELECT 1".to_string()),
260                Command::Sql("SELECT 2".to_string()),
261                Command::Sql("SELECT 3".to_string()),
262            ]
263        );
264        assert!(parsed.stop_after_commands);
265    }
266
267    #[test]
268    fn a_mode_flag_sets_the_mode_and_its_separator() {
269        let parsed = options(&["-csv"]);
270        assert_eq!(parsed.settings.format, Format::Csv);
271        assert_eq!(parsed.settings.separator, ",");
272    }
273
274    /// The row separator is the one thing the csv flag does not set, which is DuckDB's behaviour.
275    ///
276    /// `duckdb -csv` writes `\n` at the end of a row and `duckdb -cmd ".mode csv"` writes `\r\n`,
277    /// on the same build in the same run, and `tests/shell.rs` holds both captures. This is the
278    /// parse side of it.
279    #[test]
280    fn a_mode_flag_leaves_the_row_separator_where_it_was_and_the_dot_command_does_not() {
281        assert_eq!(options(&["-csv"]).settings.newline, "\n");
282        assert_eq!(options(&["-csv", "-newline", ";"]).settings.newline, ";");
283    }
284
285    /// What each flag sets, against `duckdb v2.0.0-dev84237` read out of `.show`.
286    ///
287    /// The separators are given first so that a flag which leaves one alone can be told apart from
288    /// one that sets it to the value it already had. Per #239.
289    #[test]
290    fn each_mode_flag_sets_the_separators_that_flag_sets_and_no_others() {
291        let given = |flag: &str| {
292            let parsed = options(&["-separator", ";", "-newline", "@", flag]);
293            (parsed.settings.separator, parsed.settings.newline)
294        };
295        assert_eq!(given("-ascii"), ("\u{1f}".to_string(), "\u{1e}".to_string()));
296        assert_eq!(given("-csv"), (",".to_string(), "@".to_string()));
297        let neither = [
298            "-box",
299            "-column",
300            "-html",
301            "-json",
302            "-jsonlines",
303            "-line",
304            "-list",
305            "-markdown",
306            "-quote",
307            "-table",
308        ];
309        for flag in neither {
310            assert_eq!(given(flag), (";".to_string(), "@".to_string()), "{flag}");
311        }
312    }
313
314    /// The four modes that are not flags, per #238.
315    ///
316    /// Each of them is still a mode, so `.mode tabs` works and `-tabs` does not, which is what the
317    /// binary does. The aliases are not flags either.
318    #[test]
319    fn a_mode_that_duckdb_has_no_flag_for_is_an_error_here_too() {
320        for flag in ["-duckbox", "-insert", "-tabs", "-trash", "-lines", "-tsv", "-ndjson"] {
321            assert!(matches!(parse(&[flag.to_string()]), Action::Wrong(_)), "{flag}");
322        }
323    }
324
325    #[test]
326    fn a_separator_given_after_the_mode_wins() {
327        let parsed = options(&["-csv", "-separator", ";"]);
328        assert_eq!(parsed.settings.separator, ";");
329    }
330
331    #[test]
332    fn every_set_flag_is_kept_in_order_and_apart_from_the_sql() {
333        let parsed = options(&["--set", "hash.table=unchained", "-c", "SELECT 1", "--set", "x=y"]);
334        assert_eq!(parsed.sets, ["hash.table=unchained", "x=y"]);
335        assert_eq!(parsed.commands, [Command::Sql("SELECT 1".to_string())]);
336    }
337
338    #[test]
339    fn a_set_flag_without_a_value_says_how_it_is_written() {
340        assert!(matches!(
341            parse(&["--set".to_string(), "hash.table".to_string()]),
342            Action::Wrong(why) if why.contains("name=value")
343        ));
344        assert!(matches!(parse(&["--set".to_string()]), Action::Wrong(_)));
345    }
346
347    #[test]
348    fn the_metrics_flag_names_the_file_the_documents_go_to() {
349        let parsed = options(&["--metrics", "run.json", "-c", "SELECT 1"]);
350        assert_eq!(parsed.metrics, Some(std::path::PathBuf::from("run.json")));
351        assert_eq!(parsed.commands, [Command::Sql("SELECT 1".to_string())]);
352        assert!(matches!(parse(&["--metrics".to_string()]), Action::Wrong(_)));
353    }
354
355    #[test]
356    fn nothing_is_written_unless_the_metrics_flag_asks_for_it() {
357        assert_eq!(options(&["-c", "SELECT 1"]).metrics, None);
358    }
359
360    #[test]
361    fn an_unknown_option_is_an_error_rather_than_a_filename() {
362        assert!(matches!(parse(&["-csvv".to_string()]), Action::Wrong(_)));
363    }
364
365    #[test]
366    fn an_option_missing_its_value_says_so() {
367        assert!(matches!(parse(&["-c".to_string()]), Action::Wrong(_)));
368    }
369
370    #[test]
371    fn version_and_help_win_wherever_they_appear() {
372        assert!(matches!(parse(&["-csv".to_string(), "-version".to_string()]), Action::Version));
373        assert!(matches!(parse(&["-help".to_string()]), Action::Help));
374    }
375}