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