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 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
89pub 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 "--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 "--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 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 #[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 #[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 #[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 #[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}