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