Skip to main content

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}