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_name(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            other => {
148                positional += 1;
149                match positional {
150                    1 => options.database = other.to_string(),
151                    2 => {
152                        options.commands.push(Command::Sql(other.to_string()));
153                        options.stop_after_commands = true;
154                    }
155                    _ => return Action::Wrong(format!("too many arguments, starting at {other}")),
156                }
157            }
158        }
159    }
160    Action::Run(Box::new(options))
161}
162
163#[cfg(test)]
164mod tests {
165    use super::{Action, Command, parse};
166    use crate::format::Format;
167
168    fn options(arguments: &[&str]) -> super::Options {
169        let owned: Vec<String> = arguments.iter().map(|text| (*text).to_string()).collect();
170        match parse(&owned) {
171            Action::Run(options) => *options,
172            other => panic!("expected a run, got {other:?}"),
173        }
174    }
175
176    #[test]
177    fn nothing_means_an_interactive_memory_database() {
178        let parsed = options(&[]);
179        assert_eq!(parsed.database, ":memory:");
180        assert!(parsed.commands.is_empty());
181        assert!(!parsed.stop_after_commands);
182    }
183
184    #[test]
185    fn a_command_runs_and_stops() {
186        let parsed = options(&["-c", "SELECT 1"]);
187        assert_eq!(parsed.commands, vec![Command::Sql("SELECT 1".to_string())]);
188        assert!(parsed.stop_after_commands);
189    }
190
191    #[test]
192    fn commands_keep_their_order() {
193        let parsed = options(&["-c", "one", "-c", "two"]);
194        assert_eq!(
195            parsed.commands,
196            vec![Command::Sql("one".to_string()), Command::Sql("two".to_string())]
197        );
198    }
199
200    #[test]
201    fn cmd_runs_first_and_does_not_stop() {
202        let parsed = options(&["-cmd", ".mode csv"]);
203        assert!(!parsed.stop_after_commands);
204    }
205
206    #[test]
207    fn the_first_positional_is_the_database_and_the_second_is_sql() {
208        let parsed = options(&["shop.db", "SELECT 1"]);
209        assert_eq!(parsed.database, "shop.db");
210        assert_eq!(parsed.commands, vec![Command::Sql("SELECT 1".to_string())]);
211        assert!(parsed.stop_after_commands);
212    }
213
214    #[test]
215    fn a_mode_flag_sets_the_mode_and_its_separator() {
216        let parsed = options(&["-csv"]);
217        assert_eq!(parsed.settings.format, Format::Csv);
218        assert_eq!(parsed.settings.separator, ",");
219    }
220
221    /// The row separator is the one thing a mode flag does not set, which is DuckDB's behaviour.
222    ///
223    /// `duckdb -csv` writes `\n` at the end of a row and `duckdb -cmd ".mode csv"` writes `\r\n`,
224    /// on the same build in the same run, and `tests/shell.rs` holds both captures. This is the
225    /// parse side of it.
226    #[test]
227    fn a_mode_flag_leaves_the_row_separator_where_it_was_and_the_dot_command_does_not() {
228        assert_eq!(options(&["-csv"]).settings.newline, "\n");
229        assert_eq!(options(&["-ascii"]).settings.newline, "\n");
230        assert_eq!(options(&["-csv", "-newline", ";"]).settings.newline, ";");
231    }
232
233    #[test]
234    fn a_separator_given_after_the_mode_wins() {
235        let parsed = options(&["-csv", "-separator", ";"]);
236        assert_eq!(parsed.settings.separator, ";");
237    }
238
239    #[test]
240    fn an_unknown_option_is_an_error_rather_than_a_filename() {
241        assert!(matches!(parse(&["-csvv".to_string()]), Action::Wrong(_)));
242    }
243
244    #[test]
245    fn an_option_missing_its_value_says_so() {
246        assert!(matches!(parse(&["-c".to_string()]), Action::Wrong(_)));
247    }
248
249    #[test]
250    fn version_and_help_win_wherever_they_appear() {
251        assert!(matches!(parse(&["-csv".to_string(), "-version".to_string()]), Action::Version));
252        assert!(matches!(parse(&["-help".to_string()]), Action::Help));
253    }
254}