inillucent_cli/commands.rs
1//! The dot commands that are about the shell rather than about the database.
2//!
3//! Invariant: each one does what the reference's does, including when it
4//! refuses. A command that printed a different usage line for a missing
5//! argument would be a different command, because the usage line is the whole
6//! output in the case a script is most likely to hit.
7//!
8//! What is here is the group that needs nothing from the engine: the working
9//! directory, a subprocess, the prompt strings, how output is punctuated, and
10//! the two that make the reference's own test scripts run (`.testcase` and
11//! `.check`). The ones that ask the database something are in `diagnose`.
12
13use crate::shell::Shell;
14
15/// `.cd DIRECTORY`: change the working directory.
16///
17/// @param shell - the shell
18/// @param arguments - the words after the command
19pub fn cd(shell: &mut Shell, arguments: &[&str]) {
20 let Some(path) = arguments.first() else {
21 shell.complain("Usage: .cd DIRECTORY");
22 return;
23 };
24 if std::env::set_current_dir(path).is_err() {
25 shell.complain(&format!("Cannot change to directory \"{path}\""));
26 }
27}
28
29/// `.shell CMD ARGS...` and `.system CMD ARGS...`: run a command.
30///
31/// Both spellings are the same command and the reference's usage line names
32/// `.system` for either, which is why this one does too.
33///
34/// @param shell - the shell
35/// @param arguments - the words after the command
36pub fn system(shell: &mut Shell, arguments: &[&str]) {
37 if arguments.is_empty() {
38 shell.complain("Usage: .system COMMAND");
39 return;
40 }
41 let line = arguments.join(" ");
42 let status = if cfg!(windows) {
43 std::process::Command::new("cmd")
44 .arg("/C")
45 .arg(&line)
46 .status()
47 } else {
48 std::process::Command::new("/bin/sh")
49 .arg("-c")
50 .arg(&line)
51 .status()
52 };
53 if let Ok(status) = status {
54 if !status.success() {
55 // The reference reports the child's own exit code and nothing else,
56 // because the child has already said whatever it had to say.
57 shell.failed = true;
58 }
59 } else {
60 shell.complain("Error: cannot run the system shell");
61 }
62}
63
64/// `.crlf ?on|off?`: whether output lines end `\r\n`.
65///
66/// @param shell - the shell
67/// @param arguments - the words after the command
68pub fn crlf(shell: &mut Shell, arguments: &[&str]) {
69 // **On only where the reference honours it (task-1946, M5).** SQLite's shell
70 // implements `.crlf` by putting `stdout` into text mode, which exists on
71 // Windows and nowhere else, so `.crlf on` followed by `.crlf` answers
72 // `crlf is ON` there and `crlf is OFF` everywhere else. This shell turned it
73 // on wherever it was asked. Nothing could see the difference until the first
74 // Linux run that had an oracle to compare against: `shell.crlf` was one of
75 // three cases in `semantics.rs` that agreed on Windows and did not on Linux.
76 //
77 // The state is still printed on every platform, because the reference prints
78 // it on every platform.
79 if let Some(word) = arguments.first() {
80 shell.crlf = cfg!(windows) && crate::dot::truthy(Some(word));
81 }
82 let state = if shell.crlf { "ON" } else { "OFF" };
83 shell.say(&format!("crlf is {state}"));
84}
85
86/// `.prompt MAIN CONTINUE`: the two strings an interactive session prints.
87///
88/// Recorded and printed by `.show`, which is the only thing that reads them in
89/// a script - the prompts themselves are only written when input is a terminal.
90///
91/// @param shell - the shell
92/// @param arguments - the words after the command
93pub fn prompt(shell: &mut Shell, arguments: &[&str]) {
94 if let Some(main) = arguments.first() {
95 shell.prompt_main = (*main).to_string();
96 }
97 if let Some(more) = arguments.get(1) {
98 shell.prompt_continue = (*more).to_string();
99 }
100}
101
102/// `.explain ?on|off|auto?`: how an `EXPLAIN` listing is laid out.
103///
104/// `auto` is the default and is what makes an `EXPLAIN` print as a table while
105/// everything else keeps the current `.mode`.
106///
107/// @param shell - the shell
108/// @param arguments - the words after the command
109pub fn explain(shell: &mut Shell, arguments: &[&str]) {
110 shell.explain_mode = match arguments.first().map(|word| word.to_ascii_lowercase()) {
111 None => ExplainMode::On,
112 Some(word) if word == "auto" => ExplainMode::Auto,
113 Some(word) if crate::dot::truthy(Some(&word)) => ExplainMode::On,
114 Some(_) => ExplainMode::Off,
115 };
116}
117
118/// When an `EXPLAIN` listing is laid out as a table.
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub enum ExplainMode {
121 /// Only for a statement that is an `EXPLAIN`, which is the default.
122 Auto,
123 /// For every statement.
124 On,
125 /// For none, so an `EXPLAIN` prints under the current `.mode`.
126 Off,
127}
128
129impl ExplainMode {
130 /// What `.show` calls this mode, which is what `.explain` accepts back.
131 pub fn name(self) -> &'static str {
132 match self {
133 ExplainMode::Auto => "auto",
134 ExplainMode::On => "on",
135 ExplainMode::Off => "off",
136 }
137 }
138}
139
140/// `.nonce STRING`: the token that suspends safe mode for one command.
141///
142/// @param shell - the shell
143/// @param arguments - the words after the command
144pub fn nonce(shell: &mut Shell, arguments: &[&str]) {
145 let Some(value) = arguments.first() else {
146 shell.complain("Usage: .nonce NONCE");
147 return;
148 };
149 shell.nonce = Some((*value).to_string());
150}
151
152/// `.testcase NAME`: start capturing output for the next `.check`.
153///
154/// @param shell - the shell
155/// @param arguments - the words after the command
156pub fn testcase(shell: &mut Shell, arguments: &[&str]) {
157 shell.testcase = Some(
158 arguments
159 .first()
160 .map(|name| (*name).to_string())
161 .unwrap_or_default(),
162 );
163 shell.captured.clear();
164}
165
166/// `.check TEXT`: compare what has been printed since `.testcase`.
167///
168/// Silence on a match and a three-line report on a mismatch, which is the
169/// reference's own harness protocol - its test scripts are read by both.
170///
171/// @param shell - the shell
172/// @param arguments - the words after the command
173pub fn check(shell: &mut Shell, arguments: &[&str]) {
174 let line = shell.line;
175 let typed = if arguments.is_empty() {
176 ".check".to_string()
177 } else {
178 format!(".check {}", arguments.join(" "))
179 };
180 let Some(name) = shell.testcase.take() else {
181 shell.complain(&format!("line {line}: {typed}"));
182 shell.complain(&format!("line {line}: ^--- no .testcase is active"));
183 return;
184 };
185 shell.tests_run = shell.tests_run.saturating_add(1);
186 let wanted = arguments.join(" ");
187 // **Compared with the trailing newline the output actually had**, because
188 // that is what the reference compares and what its own `[...]` brackets
189 // show: a `Got:` that ends inside the bracket and a `]` on the next line is
190 // how a missing newline is told from a present one.
191 let got = shell.captured.clone();
192 if got.trim_end_matches('\n') == wanted {
193 shell.captured.clear();
194 return;
195 }
196 shell.tests_failed = shell.tests_failed.saturating_add(1);
197 shell.complain(&format!(
198 "<stdin>:{line}: .check failed for testcase {name}"
199 ));
200 shell.complain(&format!("Expected: [{wanted}]"));
201 shell.complain(&format!("Got: [{got}]"));
202 shell.captured.clear();
203}
204
205/// Reports how many `.testcase`/`.check` pairs ran, at the end of the script.
206///
207/// Nothing is printed when none ran, which is what makes the line invisible to
208/// a script that is not a test script.
209///
210/// @param shell - the shell
211pub fn report_tests(shell: &mut Shell) {
212 if shell.tests_run == 0 {
213 return;
214 }
215 let run = shell.tests_run;
216 let failed = shell.tests_failed;
217 let plural = if failed == 1 { "error" } else { "errors" };
218 // The tally goes to standard output, where the reference puts it; the
219 // failures themselves go to standard error. A harness that reads the two
220 // streams apart sees the count as a result and the failures as diagnostics.
221 shell.say(&format!("{run} tests run with {failed} {plural}"));
222}
223
224/// `.excel` and `.www`: send the next command's output somewhere to look at.
225///
226/// The reference writes the rows to a temporary file and hands it to the
227/// system's own handler - a spreadsheet for `.excel`, a browser for `.www`.
228/// Both are `.once` with a mode and a destination chosen for you.
229///
230/// @param shell - the shell
231/// @param html - whether the file is HTML rather than CSV
232pub fn viewer(shell: &mut Shell, html: bool) {
233 let suffix = if html { "html" } else { "csv" };
234 let path = std::env::temp_dir().join(format!(
235 "inillucent-{}.{suffix}",
236 std::time::SystemTime::now()
237 .duration_since(std::time::UNIX_EPOCH)
238 .map(|held| held.as_nanos())
239 .unwrap_or(0)
240 ));
241 shell.layout.mode = if html {
242 crate::render::Mode::Html
243 } else {
244 crate::render::Mode::Csv
245 };
246 shell.layout.headers = true;
247 if let Err(reason) = shell.redirect(Some(&path.to_string_lossy()), true) {
248 shell.complain(&format!("Error: {reason}"));
249 return;
250 }
251 shell.viewer = Some(path);
252}
253
254/// Hands a `.excel` or `.www` file to the system's own handler.
255///
256/// Called when the redirected command finishes, which is where the reference
257/// opens it too: the file has to be complete before anything is asked to read
258/// it.
259///
260/// @param path - the file that was written
261pub fn open_viewer(path: &std::path::Path) {
262 let opened = if cfg!(windows) {
263 std::process::Command::new("cmd")
264 .arg("/C")
265 .arg("start")
266 .arg("")
267 .arg(path)
268 .status()
269 } else if cfg!(target_os = "macos") {
270 std::process::Command::new("open").arg(path).status()
271 } else {
272 std::process::Command::new("xdg-open").arg(path).status()
273 };
274 let _ = opened;
275}
276
277/// `.scanstats on|off|est`: whether per-statement scan metrics are collected.
278///
279/// **Accepted and recorded rather than acted on, and the reference's own build
280/// is the same.** The metrics come from `sqlite3_stmt_scanstatus`, which is
281/// compiled out of the pinned `sqlite3` - `.scanstats on` there prints nothing
282/// extra either. What this engine has instead is the `sqlite_stmt` table, whose
283/// `nscan`, `nsort` and `naidx` columns are the same three numbers asked for as
284/// SQL.
285///
286/// @param shell - the shell
287/// @param arguments - the words after the command
288pub fn scanstats(shell: &mut Shell, arguments: &[&str]) {
289 let Some(word) = arguments.first() else {
290 shell.complain("Usage: .scanstats on|off|est");
291 return;
292 };
293 let folded = word.to_ascii_lowercase();
294 if !matches!(folded.as_str(), "on" | "off" | "est") {
295 shell.complain("Usage: .scanstats on|off|est");
296 return;
297 }
298 shell.scanstats = folded;
299}
300
301/// `.trace ?FILE|off?`: echo each statement before it runs.
302///
303/// `stdout` and `stderr` name the two streams; anything else is a file; `off`
304/// stops. With no argument it stops, which is what the reference does.
305///
306/// @param shell - the shell
307/// @param arguments - the words after the command
308pub fn trace(shell: &mut Shell, arguments: &[&str]) {
309 let Some(word) = arguments.first() else {
310 shell.trace = None;
311 return;
312 };
313 if word.eq_ignore_ascii_case("off") {
314 shell.trace = None;
315 return;
316 }
317 shell.trace = Some((*word).to_string());
318}
319
320/// `.auth ON|OFF`: print every decision the authorizer is asked to make.
321///
322/// The reference installs an authorizer that allows everything and writes each
323/// callback to standard output, which is what makes the command a window on
324/// what a statement actually touches. The lines are its own format:
325/// `authorizer:` then the action and four arguments, each either `NULL` or a
326/// quoted string.
327///
328/// @param shell - the shell
329/// @param arguments - the words after the command
330pub fn auth(shell: &mut Shell, arguments: &[&str]) {
331 let Some(word) = arguments.first() else {
332 shell.complain("Usage: .auth ON|OFF");
333 return;
334 };
335 let on = crate::dot::truthy(Some(word));
336 shell.auth = on;
337 shell.set_authorizer(on);
338}
339
340/// The authorizer `.auth on` installs: it allows everything and says so.
341pub struct Watching {
342 /// Where the lines go until the shell prints them.
343 pub seen: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
344}
345
346impl inillucent_driver::Authorizer for Watching {
347 /// Records one decision and allows it.
348 ///
349 /// @param action - what the binder is about to bind
350 fn authorize(
351 &self,
352 action: inillucent_driver::AuthAction<'_>,
353 ) -> inillucent_driver::Authorization {
354 let line = match action {
355 inillucent_driver::AuthAction::Select => "SELECT NULL NULL NULL NULL".to_string(),
356 inillucent_driver::AuthAction::Read {
357 database,
358 table,
359 column,
360 } => format!(
361 "READ {} {} {} NULL",
362 quoted(table),
363 quoted(column),
364 quoted(database)
365 ),
366 inillucent_driver::AuthAction::Function { name } => {
367 format!("FUNCTION NULL {} NULL NULL", quoted(name))
368 }
369 };
370 self.seen.borrow_mut().push(format!("authorizer: {line}"));
371 inillucent_driver::Authorization::Allow
372 }
373}
374
375/// Renders one authorizer argument the way the reference renders it.
376///
377/// @param bytes - the name
378fn quoted(bytes: &[u8]) -> String {
379 format!("\"{}\"", String::from_utf8_lossy(bytes))
380}
381
382/// `.connection [close] [#]`: open, list or close an auxiliary database.
383///
384/// Five slots, which is the reference's own limit. With no argument the open
385/// ones are listed and the one statements run on is marked `ACTIVE`; with a
386/// number the shell switches to that slot, opening an in-memory database there
387/// if it was closed; with `close` and a number that slot is closed.
388///
389/// A number out of range is ignored rather than refused, which is what the
390/// reference does with it.
391///
392/// @param shell - the shell
393/// @param arguments - the words after the command
394pub fn connection(shell: &mut Shell, arguments: &[&str]) {
395 /// How wide the slot number is printed, so `ACTIVE 0:` and ` 0:`
396 /// put their colons in the same column.
397 const MARK: usize = 6;
398
399 match arguments.first().map(|word| word.to_ascii_lowercase()) {
400 None => {
401 let active = shell.active();
402 for (slot, held) in shell.slots().into_iter().enumerate() {
403 let Some(path) = held else {
404 continue;
405 };
406 let mark = if slot == active { "ACTIVE" } else { "" };
407 let name = if path == ":memory:" {
408 "(memory)".to_string()
409 } else {
410 path
411 };
412 let line = format!("{mark:<MARK$} {slot}: {name}");
413 shell.say(&line);
414 }
415 }
416 Some(word) if word == "close" => {
417 if let Some(slot) = arguments.get(1).and_then(|text| text.parse::<usize>().ok()) {
418 shell.close_slot(slot);
419 }
420 }
421 Some(word) => {
422 if let Ok(slot) = word.parse::<usize>() {
423 if let Err(reason) = shell.use_slot(slot) {
424 shell.complain(&format!("Error: {reason}"));
425 }
426 }
427 }
428 }
429}
430
431/// `.imposter INDEX IMPOSTER` and `.imposter off`: read an index directly.
432///
433/// An index's entries are the indexed columns followed by the row's identity,
434/// which is a `WITHOUT ROWID` table - so declaring one over the index's own
435/// b-tree is a way to read what the index actually holds when a query over it
436/// is answering wrongly. The declaration is this connection's and is not
437/// written to the file.
438///
439/// @param shell - the shell
440/// @param arguments - the words after the command
441pub fn imposter(shell: &mut Shell, arguments: &[&str]) {
442 match (arguments.first().copied(), arguments.get(1).copied()) {
443 (Some(word), None) if word.eq_ignore_ascii_case("off") => {
444 if let Err(reason) = shell.connection().imposter(None, b"") {
445 shell.complain(&format!("Error: {}", reason.message()));
446 }
447 }
448 (Some(index), Some(name)) => {
449 match shell
450 .connection()
451 .imposter(Some(index.as_bytes()), name.as_bytes())
452 {
453 Ok(Some(sql)) => shell.say(&sql),
454 Ok(None) => {}
455 Err(reason) => shell.complain(reason.message()),
456 }
457 }
458 _ => {
459 shell.complain("Usage: .imposter INDEX IMPOSTER");
460 shell.complain(" .imposter off");
461 }
462 }
463}