1use std::path::PathBuf;
9
10use crate::format::Format;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Command {
15 Sql(String),
17 File(PathBuf),
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum Action {
24 Run(Box<Options>),
26 Version,
28 Help,
30 Config,
32 Wrong(String),
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Options {
39 pub database: String,
41 pub commands: Vec<Command>,
43 pub stop_after_commands: bool,
45 pub interactive: Option<bool>,
48 pub echo: bool,
50 pub bail: bool,
52 pub readonly: bool,
54 pub sets: Vec<String>,
61 pub metrics: Option<PathBuf>,
68 pub fallbacks: bool,
76 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
98pub 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 "--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 "--metrics" => match next(argument) {
159 Ok(path) => options.metrics = Some(PathBuf::from(path)),
160 Err(why) => return Action::Wrong(why),
161 },
162 "--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 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 #[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 #[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 #[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 #[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}