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 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
73pub 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_flag(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 => {
152 positional += 1;
153 if positional == 1 {
154 options.database = other.to_string();
155 } else {
156 options.commands.push(Command::Sql(other.to_string()));
157 options.stop_after_commands = true;
158 }
159 }
160 }
161 }
162 Action::Run(Box::new(options))
163}
164
165#[cfg(test)]
166mod tests {
167 use super::{Action, Command, parse};
168 use crate::format::Format;
169
170 fn options(arguments: &[&str]) -> super::Options {
171 let owned: Vec<String> = arguments.iter().map(|text| (*text).to_string()).collect();
172 match parse(&owned) {
173 Action::Run(options) => *options,
174 other => panic!("expected a run, got {other:?}"),
175 }
176 }
177
178 #[test]
179 fn nothing_means_an_interactive_memory_database() {
180 let parsed = options(&[]);
181 assert_eq!(parsed.database, ":memory:");
182 assert!(parsed.commands.is_empty());
183 assert!(!parsed.stop_after_commands);
184 }
185
186 #[test]
187 fn a_command_runs_and_stops() {
188 let parsed = options(&["-c", "SELECT 1"]);
189 assert_eq!(parsed.commands, vec![Command::Sql("SELECT 1".to_string())]);
190 assert!(parsed.stop_after_commands);
191 }
192
193 #[test]
194 fn commands_keep_their_order() {
195 let parsed = options(&["-c", "one", "-c", "two"]);
196 assert_eq!(
197 parsed.commands,
198 vec![Command::Sql("one".to_string()), Command::Sql("two".to_string())]
199 );
200 }
201
202 #[test]
203 fn cmd_runs_first_and_does_not_stop() {
204 let parsed = options(&["-cmd", ".mode csv"]);
205 assert!(!parsed.stop_after_commands);
206 }
207
208 #[test]
209 fn the_first_positional_is_the_database_and_the_second_is_sql() {
210 let parsed = options(&["shop.db", "SELECT 1"]);
211 assert_eq!(parsed.database, "shop.db");
212 assert_eq!(parsed.commands, vec![Command::Sql("SELECT 1".to_string())]);
213 assert!(parsed.stop_after_commands);
214 }
215
216 #[test]
220 fn every_positional_after_the_database_is_another_statement() {
221 let parsed = options(&["shop.db", "SELECT 1", "SELECT 2", "SELECT 3"]);
222 assert_eq!(parsed.database, "shop.db");
223 assert_eq!(
224 parsed.commands,
225 vec![
226 Command::Sql("SELECT 1".to_string()),
227 Command::Sql("SELECT 2".to_string()),
228 Command::Sql("SELECT 3".to_string()),
229 ]
230 );
231 assert!(parsed.stop_after_commands);
232 }
233
234 #[test]
235 fn a_mode_flag_sets_the_mode_and_its_separator() {
236 let parsed = options(&["-csv"]);
237 assert_eq!(parsed.settings.format, Format::Csv);
238 assert_eq!(parsed.settings.separator, ",");
239 }
240
241 #[test]
247 fn a_mode_flag_leaves_the_row_separator_where_it_was_and_the_dot_command_does_not() {
248 assert_eq!(options(&["-csv"]).settings.newline, "\n");
249 assert_eq!(options(&["-csv", "-newline", ";"]).settings.newline, ";");
250 }
251
252 #[test]
257 fn each_mode_flag_sets_the_separators_that_flag_sets_and_no_others() {
258 let given = |flag: &str| {
259 let parsed = options(&["-separator", ";", "-newline", "@", flag]);
260 (parsed.settings.separator, parsed.settings.newline)
261 };
262 assert_eq!(given("-ascii"), ("\u{1f}".to_string(), "\u{1e}".to_string()));
263 assert_eq!(given("-csv"), (",".to_string(), "@".to_string()));
264 let neither = [
265 "-box",
266 "-column",
267 "-html",
268 "-json",
269 "-jsonlines",
270 "-line",
271 "-list",
272 "-markdown",
273 "-quote",
274 "-table",
275 ];
276 for flag in neither {
277 assert_eq!(given(flag), (";".to_string(), "@".to_string()), "{flag}");
278 }
279 }
280
281 #[test]
286 fn a_mode_that_duckdb_has_no_flag_for_is_an_error_here_too() {
287 for flag in ["-duckbox", "-insert", "-tabs", "-trash", "-lines", "-tsv", "-ndjson"] {
288 assert!(matches!(parse(&[flag.to_string()]), Action::Wrong(_)), "{flag}");
289 }
290 }
291
292 #[test]
293 fn a_separator_given_after_the_mode_wins() {
294 let parsed = options(&["-csv", "-separator", ";"]);
295 assert_eq!(parsed.settings.separator, ";");
296 }
297
298 #[test]
299 fn an_unknown_option_is_an_error_rather_than_a_filename() {
300 assert!(matches!(parse(&["-csvv".to_string()]), Action::Wrong(_)));
301 }
302
303 #[test]
304 fn an_option_missing_its_value_says_so() {
305 assert!(matches!(parse(&["-c".to_string()]), Action::Wrong(_)));
306 }
307
308 #[test]
309 fn version_and_help_win_wherever_they_appear() {
310 assert!(matches!(parse(&["-csv".to_string(), "-version".to_string()]), Action::Version));
311 assert!(matches!(parse(&["-help".to_string()]), Action::Help));
312 }
313}