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