Skip to main content

basalt/
cli.rs

1//! User-facing command-line frontend for Basalt.
2//!
3//! The SQL engine stays independent of frontend concerns, so the CLI keeps its
4//! argument parsing, SQL buffering, result formatting, and meta commands in
5//! this module. Keeping the frontend testable outside the executable makes
6//! script and interactive behavior share the same connection semantics.
7
8use std::fmt;
9use std::fs::File;
10use std::io::{self, BufRead, Read, Write};
11
12use crate::database::{Connection, Database};
13use crate::db::{Column, DbError, StatementResult};
14use crate::sql::parser::parse;
15use crate::types::{ColumnType, Value};
16
17/// Maximum SQL source accepted by one CLI action or the interactive buffer.
18///
19/// The direct CLI is intentionally less restrictive than MCP, but it still
20/// needs a finite parser/input boundary for scripts and piped agent output.
21pub const MAX_SQL_INPUT_BYTES: usize = 16 * 1024 * 1024;
22
23pub const HELP: &str = "Basalt — embedded SQL database\n\n\
24Usage:\n  basalt [OPTIONS] [DATABASE_PATH | :memory:]\n\n\
25Options:\n  -c, --command SQL       Execute SQL and exit; may be repeated\n  -f, --file PATH         Execute a SQL script and exit; '-' reads stdin\n  -o, --output FORMAT     Result format: table, csv, or json\n      --table             Use table output (the default)\n      --csv               Use CSV output\n      --json              Use JSON-lines output\n      --no-header         Omit column headers in table/CSV output\n      --quiet             Suppress non-query success messages\n  -h, --help              Print this help\n  -V, --version           Print the version\n\n\
26Interactive commands:\n  .help                   Show this help\n  .tables                 List tables\n  .schema [TABLE]         Show CREATE TABLE statements\n  .mode table|csv|json    Change result format\n  .headers on|off         Toggle result headers\n  .checkpoint             Flush the snapshot and truncate the WAL\n  .show                   Show frontend state\n  .clear                  Discard the pending SQL buffer\n  .quit, .exit            Leave the shell\n\n\
27MCP server:\n  basalt mcp [OPTIONS] [DATABASE_PATH | :memory:]\n\n\
28Workspace:\n  basalt init PATH\n  basalt workspace --help\n\n\
29JSON output is one JSON object per statement (JSON Lines). CSV output emits\n\
30only query rows, so it can be piped directly into another data tool. Each CLI\n\
31SQL action and interactive input buffer is limited to 16 MiB.\n";
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum OutputMode {
35    Table,
36    Csv,
37    Json,
38}
39
40impl OutputMode {
41    fn parse(value: &str) -> Result<Self, CliError> {
42        match value.to_ascii_lowercase().as_str() {
43            "table" => Ok(OutputMode::Table),
44            "csv" => Ok(OutputMode::Csv),
45            "json" | "jsonl" | "ndjson" => Ok(OutputMode::Json),
46            _ => Err(CliError::new(format!(
47                "unknown output format {value:?}; expected table, csv, or json"
48            ))),
49        }
50    }
51
52    fn name(self) -> &'static str {
53        match self {
54            OutputMode::Table => "table",
55            OutputMode::Csv => "csv",
56            OutputMode::Json => "json",
57        }
58    }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum InputAction {
63    Command(String),
64    File(String),
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct CliOptions {
69    pub database: String,
70    pub actions: Vec<InputAction>,
71    pub output: OutputMode,
72    pub headers: bool,
73    pub quiet: bool,
74    pub help: bool,
75    pub version: bool,
76}
77
78impl Default for CliOptions {
79    fn default() -> Self {
80        Self {
81            database: ":memory:".into(),
82            actions: Vec::new(),
83            output: OutputMode::Table,
84            headers: true,
85            quiet: false,
86            help: false,
87            version: false,
88        }
89    }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct CliError {
94    pub message: String,
95}
96
97impl CliError {
98    fn new(message: impl Into<String>) -> Self {
99        Self {
100            message: message.into(),
101        }
102    }
103}
104
105impl fmt::Display for CliError {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        f.write_str(&self.message)
108    }
109}
110
111impl std::error::Error for CliError {}
112
113impl From<io::Error> for CliError {
114    fn from(error: io::Error) -> Self {
115        Self::new(error.to_string())
116    }
117}
118
119impl From<DbError> for CliError {
120    fn from(error: DbError) -> Self {
121        Self::new(error.message)
122    }
123}
124
125/// Parse command-line arguments after the executable name.
126pub fn parse_args(args: &[String]) -> Result<CliOptions, CliError> {
127    let mut options = CliOptions::default();
128    let mut positional_only = false;
129    let mut database = None;
130    let mut i = 0;
131
132    while i < args.len() {
133        let argument = args[i].as_str();
134        if !positional_only && argument == "--" {
135            positional_only = true;
136            i += 1;
137            continue;
138        }
139
140        let mut take_value = |name: &str| -> Result<String, CliError> {
141            i += 1;
142            args.get(i).cloned().ok_or_else(|| {
143                CliError::new(format!("{name} requires a value; try --help for usage"))
144            })
145        };
146
147        if !positional_only {
148            match argument {
149                "-h" | "--help" => {
150                    options.help = true;
151                    i += 1;
152                    continue;
153                }
154                "-V" | "--version" => {
155                    options.version = true;
156                    i += 1;
157                    continue;
158                }
159                "-c" | "--command" => {
160                    options
161                        .actions
162                        .push(InputAction::Command(take_value(argument)?));
163                    i += 1;
164                    continue;
165                }
166                "-f" | "--file" => {
167                    options
168                        .actions
169                        .push(InputAction::File(take_value(argument)?));
170                    i += 1;
171                    continue;
172                }
173                "-o" | "--output" => {
174                    options.output = OutputMode::parse(&take_value(argument)?)?;
175                    i += 1;
176                    continue;
177                }
178                "--table" => {
179                    options.output = OutputMode::Table;
180                    i += 1;
181                    continue;
182                }
183                "--csv" => {
184                    options.output = OutputMode::Csv;
185                    i += 1;
186                    continue;
187                }
188                "--json" => {
189                    options.output = OutputMode::Json;
190                    i += 1;
191                    continue;
192                }
193                "--no-header" | "--no-headers" => {
194                    options.headers = false;
195                    i += 1;
196                    continue;
197                }
198                "--quiet" | "-q" => {
199                    options.quiet = true;
200                    i += 1;
201                    continue;
202                }
203                _ => {}
204            }
205
206            if let Some(value) = argument.strip_prefix("--command=") {
207                if value.is_empty() {
208                    return Err(CliError::new("--command requires a non-empty value"));
209                }
210                options.actions.push(InputAction::Command(value.into()));
211                i += 1;
212                continue;
213            }
214            if let Some(value) = argument.strip_prefix("--file=") {
215                if value.is_empty() {
216                    return Err(CliError::new("--file requires a non-empty value"));
217                }
218                options.actions.push(InputAction::File(value.into()));
219                i += 1;
220                continue;
221            }
222            if let Some(value) = argument.strip_prefix("--output=") {
223                options.output = OutputMode::parse(value)?;
224                i += 1;
225                continue;
226            }
227            if let Some(value) = argument.strip_prefix("-c=") {
228                if value.is_empty() {
229                    return Err(CliError::new("-c requires a non-empty value"));
230                }
231                options.actions.push(InputAction::Command(value.into()));
232                i += 1;
233                continue;
234            }
235            if let Some(value) = argument.strip_prefix("-f=") {
236                if value.is_empty() {
237                    return Err(CliError::new("-f requires a non-empty value"));
238                }
239                options.actions.push(InputAction::File(value.into()));
240                i += 1;
241                continue;
242            }
243            if argument.starts_with('-') {
244                return Err(CliError::new(format!(
245                    "unknown option {argument:?}; try --help for usage"
246                )));
247            }
248        }
249
250        if database.replace(argument.to_string()).is_some() {
251            return Err(CliError::new(
252                "only one database path may be provided; try --help for usage",
253            ));
254        }
255        i += 1;
256    }
257
258    if let Some(database) = database {
259        options.database = database;
260    }
261    Ok(options)
262}
263
264/// Run either the requested commands/scripts or the interactive shell.
265pub fn run<R: BufRead>(
266    options: &CliOptions,
267    database: Database,
268    input: &mut R,
269    output: &mut dyn Write,
270) -> Result<(), CliError> {
271    if options.actions.is_empty() {
272        run_interactive(options, &database, input, output)
273    } else {
274        run_actions(options, &database, input, output)
275    }
276}
277
278fn run_actions<R: BufRead>(
279    options: &CliOptions,
280    database: &Database,
281    input: &mut R,
282    output: &mut dyn Write,
283) -> Result<(), CliError> {
284    let mut connection = database.connect();
285    for action in &options.actions {
286        let (source, sql) = match action {
287            InputAction::Command(sql) => {
288                let source = "command line".to_string();
289                validate_sql_input(sql, &source)?;
290                (source, sql.clone())
291            }
292            InputAction::File(path) if path == "-" => {
293                ("stdin".to_string(), read_sql(&mut *input, "stdin")?)
294            }
295            InputAction::File(path) => {
296                let file =
297                    File::open(path).map_err(|error| CliError::new(format!("{path}: {error}")))?;
298                (path.clone(), read_sql(file, path)?)
299            }
300        };
301        let results = connection
302            .execute_sql(&sql)
303            .map_err(|error| CliError::new(format!("{source}: {error}")))?;
304        render_results(
305            &results,
306            options.output,
307            options.headers,
308            options.quiet,
309            output,
310        )?;
311    }
312    output.flush()?;
313    Ok(())
314}
315
316fn run_interactive<R: BufRead>(
317    options: &CliOptions,
318    database: &Database,
319    input: &mut R,
320    output: &mut dyn Write,
321) -> Result<(), CliError> {
322    let mut connection = database.connect();
323    let mut mode = options.output;
324    let mut headers = options.headers;
325    let mut buffer = String::new();
326
327    write!(output, "basalt> ")?;
328    output.flush()?;
329    loop {
330        let mut line = String::new();
331        if read_interactive_line(
332            input,
333            &mut line,
334            MAX_SQL_INPUT_BYTES.saturating_sub(buffer.len()),
335        )? == 0
336        {
337            if !buffer.trim().is_empty() {
338                execute_interactive_sql(
339                    &mut connection,
340                    &buffer,
341                    mode,
342                    headers,
343                    options.quiet,
344                    output,
345                )?;
346            }
347            break;
348        }
349
350        let trimmed = line.trim();
351        if trimmed.starts_with('.') {
352            if trimmed.eq_ignore_ascii_case(".clear") {
353                buffer.clear();
354            } else if buffer.trim().is_empty() {
355                match handle_meta(
356                    trimmed,
357                    database,
358                    &mut mode,
359                    &mut headers,
360                    &connection,
361                    output,
362                ) {
363                    Ok(MetaAction::Quit) => break,
364                    Ok(MetaAction::Continue) => {}
365                    Err(error) => writeln!(output, "error: {error}")?,
366                }
367            } else {
368                writeln!(
369                    output,
370                    "error: finish the pending SQL statement before using {trimmed}"
371                )?;
372            }
373            write!(
374                output,
375                "{}> ",
376                if buffer.trim().is_empty() {
377                    "basalt"
378                } else {
379                    "   ..."
380                }
381            )?;
382            output.flush()?;
383            continue;
384        }
385
386        if trimmed.is_empty() && buffer.trim().is_empty() {
387            write!(output, "basalt> ")?;
388            output.flush()?;
389            continue;
390        }
391
392        buffer.push_str(&line);
393        loop {
394            if let Some(end) = top_level_semicolon(&buffer) {
395                let statement = buffer[..end].to_string();
396                buffer.drain(..end);
397                execute_interactive_sql(
398                    &mut connection,
399                    &statement,
400                    mode,
401                    headers,
402                    options.quiet,
403                    output,
404                )?;
405                continue;
406            }
407            if buffer.trim().is_empty() {
408                buffer.clear();
409                break;
410            }
411            if sql_has_open_construct(&buffer) {
412                break;
413            }
414            match parse(&buffer) {
415                Ok(statements) if !statements.is_empty() => {
416                    let statement = std::mem::take(&mut buffer);
417                    execute_interactive_sql(
418                        &mut connection,
419                        &statement,
420                        mode,
421                        headers,
422                        options.quiet,
423                        output,
424                    )?;
425                }
426                Ok(_) => buffer.clear(),
427                Err(_) => {
428                    let statement = std::mem::take(&mut buffer);
429                    execute_interactive_sql(
430                        &mut connection,
431                        &statement,
432                        mode,
433                        headers,
434                        options.quiet,
435                        output,
436                    )?;
437                }
438            }
439            break;
440        }
441
442        write!(
443            output,
444            "{}> ",
445            if buffer.trim().is_empty() {
446                "basalt"
447            } else {
448                "   ..."
449            }
450        )?;
451        output.flush()?;
452    }
453    Ok(())
454}
455
456fn execute_interactive_sql(
457    connection: &mut Connection,
458    sql: &str,
459    mode: OutputMode,
460    headers: bool,
461    quiet: bool,
462    output: &mut dyn Write,
463) -> Result<(), CliError> {
464    match connection.execute_sql(sql) {
465        Ok(results) => render_results(&results, mode, headers, quiet, output)?,
466        Err(error) => writeln!(output, "error: {error}")?,
467    }
468    Ok(())
469}
470
471fn read_sql<R: Read>(reader: R, source: &str) -> Result<String, CliError> {
472    let mut bytes = Vec::new();
473    let mut limited = reader.take((MAX_SQL_INPUT_BYTES as u64).saturating_add(1));
474    limited
475        .read_to_end(&mut bytes)
476        .map_err(|error| CliError::new(format!("{source}: {error}")))?;
477    if bytes.len() > MAX_SQL_INPUT_BYTES {
478        return Err(CliError::new(format!(
479            "{source}: SQL input exceeds the {MAX_SQL_INPUT_BYTES}-byte limit"
480        )));
481    }
482    String::from_utf8(bytes)
483        .map_err(|_| CliError::new(format!("{source}: SQL input is not valid UTF-8")))
484}
485
486fn validate_sql_input(sql: &str, source: &str) -> Result<(), CliError> {
487    if sql.len() > MAX_SQL_INPUT_BYTES {
488        return Err(CliError::new(format!(
489            "{source}: SQL input exceeds the {MAX_SQL_INPUT_BYTES}-byte limit"
490        )));
491    }
492    Ok(())
493}
494
495fn read_interactive_line<R: BufRead>(
496    input: &mut R,
497    line: &mut String,
498    remaining: usize,
499) -> io::Result<usize> {
500    let mut total = 0;
501    let mut bytes = Vec::new();
502    loop {
503        let available = input.fill_buf()?;
504        if available.is_empty() {
505            append_interactive_line(line, bytes)?;
506            return Ok(total);
507        }
508        let newline = available.iter().position(|byte| *byte == b'\n');
509        let chunk_len = newline.map_or(available.len(), |index| index + 1);
510        if chunk_len > remaining.saturating_sub(total) {
511            return Err(io::Error::new(
512                io::ErrorKind::InvalidData,
513                format!("interactive SQL input exceeds the {MAX_SQL_INPUT_BYTES}-byte limit"),
514            ));
515        }
516        bytes.extend_from_slice(&available[..chunk_len]);
517        input.consume(chunk_len);
518        total += chunk_len;
519        if newline.is_some() {
520            append_interactive_line(line, bytes)?;
521            return Ok(total);
522        }
523    }
524}
525
526fn append_interactive_line(line: &mut String, bytes: Vec<u8>) -> io::Result<()> {
527    let text = String::from_utf8(bytes).map_err(|_| {
528        io::Error::new(
529            io::ErrorKind::InvalidData,
530            "interactive input is not valid UTF-8",
531        )
532    })?;
533    line.push_str(&text);
534    Ok(())
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq)]
538enum MetaAction {
539    Continue,
540    Quit,
541}
542
543fn handle_meta(
544    command: &str,
545    database: &Database,
546    mode: &mut OutputMode,
547    headers: &mut bool,
548    connection: &Connection,
549    output: &mut dyn Write,
550) -> Result<MetaAction, CliError> {
551    let mut parts = command.split_whitespace();
552    let name = parts.next().unwrap_or_default().to_ascii_lowercase();
553    match name.as_str() {
554        ".quit" | ".exit" => Ok(MetaAction::Quit),
555        ".help" => {
556            write!(output, "{HELP}")?;
557            Ok(MetaAction::Continue)
558        }
559        ".tables" => {
560            let names = database.table_names()?;
561            if names.is_empty() {
562                writeln!(output, "No tables.")?;
563            } else {
564                writeln!(output, "{}", names.join("  "))?;
565            }
566            Ok(MetaAction::Continue)
567        }
568        ".schema" => {
569            let table = parts.next();
570            if parts.next().is_some() {
571                return Err(CliError::new("usage: .schema [TABLE]"));
572            }
573            render_schema(database, table, output)?;
574            Ok(MetaAction::Continue)
575        }
576        ".mode" => {
577            let value = parts
578                .next()
579                .ok_or_else(|| CliError::new("usage: .mode table|csv|json"))?;
580            if parts.next().is_some() {
581                return Err(CliError::new("usage: .mode table|csv|json"));
582            }
583            *mode = OutputMode::parse(value)?;
584            writeln!(output, "output mode: {}", mode.name())?;
585            Ok(MetaAction::Continue)
586        }
587        ".headers" => {
588            let value = parts
589                .next()
590                .ok_or_else(|| CliError::new("usage: .headers on|off"))?;
591            if parts.next().is_some() {
592                return Err(CliError::new("usage: .headers on|off"));
593            }
594            *headers = match value.to_ascii_lowercase().as_str() {
595                "on" | "true" | "1" => true,
596                "off" | "false" | "0" => false,
597                _ => return Err(CliError::new("usage: .headers on|off")),
598            };
599            writeln!(output, "headers: {}", if *headers { "on" } else { "off" })?;
600            Ok(MetaAction::Continue)
601        }
602        ".checkpoint" => {
603            if connection.in_transaction() {
604                return Err(CliError::new(
605                    "cannot checkpoint while a transaction is active",
606                ));
607            }
608            database.checkpoint()?;
609            writeln!(output, "CHECKPOINT")?;
610            Ok(MetaAction::Continue)
611        }
612        ".show" => {
613            writeln!(output, "mode: {}", mode.name())?;
614            writeln!(output, "headers: {}", if *headers { "on" } else { "off" })?;
615            writeln!(
616                output,
617                "transaction: {}",
618                if connection.in_transaction() {
619                    "active"
620                } else {
621                    "none"
622                }
623            )?;
624            Ok(MetaAction::Continue)
625        }
626        _ => Err(CliError::new(format!(
627            "unknown command {command:?}; try .help"
628        ))),
629    }
630}
631
632fn render_results(
633    results: &[StatementResult],
634    mode: OutputMode,
635    headers: bool,
636    quiet: bool,
637    output: &mut dyn Write,
638) -> io::Result<()> {
639    for result in results {
640        if quiet
641            && !matches!(
642                result,
643                StatementResult::Select { .. } | StatementResult::Explain(_)
644            )
645        {
646            continue;
647        }
648        match mode {
649            OutputMode::Table => render_table_result(result, headers, output)?,
650            OutputMode::Csv => {
651                if let StatementResult::Select { columns, rows } = result {
652                    render_csv(columns, rows, headers, output)?;
653                }
654            }
655            OutputMode::Json => render_json_result(result, output)?,
656        }
657    }
658    Ok(())
659}
660
661fn render_table_result(
662    result: &StatementResult,
663    headers: bool,
664    output: &mut dyn Write,
665) -> io::Result<()> {
666    match result {
667        StatementResult::Select { columns, rows } => {
668            let values: Vec<Vec<String>> = rows
669                .iter()
670                .map(|row| row.iter().map(value_text).collect())
671                .collect();
672            if headers {
673                let widths = table_widths(columns, &values);
674                writeln!(output, "{}", padded_row(columns, &widths))?;
675                writeln!(output, "{}", separator_row(&widths))?;
676                for row in &values {
677                    writeln!(output, "{}", padded_row(row, &widths))?;
678                }
679            } else {
680                for row in &values {
681                    writeln!(output, "{}", row.join(" | "))?;
682                }
683            }
684            writeln!(output, "{} row(s)", rows.len())?;
685        }
686        StatementResult::Insert { rows_affected }
687        | StatementResult::Update { rows_affected }
688        | StatementResult::Delete { rows_affected } => {
689            writeln!(output, "{rows_affected} row(s) affected")?;
690        }
691        StatementResult::CreateTable { name } => writeln!(output, "table '{name}' created")?,
692        StatementResult::DropTable { name } => writeln!(output, "table '{name}' dropped")?,
693        StatementResult::CreateIndex { name, .. } => writeln!(output, "index '{name}' created")?,
694        StatementResult::DropIndex { name } => writeln!(output, "index '{name}' dropped")?,
695        StatementResult::Explain(value) => writeln!(output, "{value}")?,
696        StatementResult::Begin => writeln!(output, "BEGIN")?,
697        StatementResult::Commit => writeln!(output, "COMMIT")?,
698        StatementResult::Rollback => writeln!(output, "ROLLBACK")?,
699        StatementResult::Checkpoint => writeln!(output, "CHECKPOINT")?,
700        StatementResult::Echo(value) => writeln!(output, "{value}")?,
701    }
702    Ok(())
703}
704
705fn render_csv(
706    columns: &[String],
707    rows: &[Vec<Value>],
708    headers: bool,
709    output: &mut dyn Write,
710) -> io::Result<()> {
711    if headers {
712        write_csv_row(columns.iter().map(String::as_str), output)?;
713    }
714    for row in rows {
715        write_csv_row(row.iter().map(value_text), output)?;
716    }
717    Ok(())
718}
719
720fn write_csv_row<I, S>(values: I, output: &mut dyn Write) -> io::Result<()>
721where
722    I: IntoIterator<Item = S>,
723    S: AsRef<str>,
724{
725    let mut first = true;
726    for value in values {
727        if !first {
728            output.write_all(b",")?;
729        }
730        first = false;
731        write_csv_field(value.as_ref(), output)?;
732    }
733    output.write_all(b"\n")?;
734    Ok(())
735}
736
737fn write_csv_field(value: &str, output: &mut dyn Write) -> io::Result<()> {
738    if value
739        .bytes()
740        .any(|byte| matches!(byte, b',' | b'"' | b'\n' | b'\r'))
741    {
742        write!(output, "\"{}\"", value.replace('"', "\"\""))?;
743    } else {
744        output.write_all(value.as_bytes())?;
745    }
746    Ok(())
747}
748
749fn render_json_result(result: &StatementResult, output: &mut dyn Write) -> io::Result<()> {
750    let json = match result {
751        StatementResult::Select { columns, rows } => {
752            let columns = columns
753                .iter()
754                .map(|column| json_string(column))
755                .collect::<Vec<_>>()
756                .join(",");
757            let rows = rows
758                .iter()
759                .map(|row| {
760                    format!(
761                        "[{}]",
762                        row.iter().map(json_value).collect::<Vec<_>>().join(",")
763                    )
764                })
765                .collect::<Vec<_>>()
766                .join(",");
767            format!("{{\"type\":\"select\",\"columns\":[{columns}],\"rows\":[{rows}]}}")
768        }
769        StatementResult::Insert { rows_affected } => {
770            format!("{{\"type\":\"insert\",\"rows_affected\":{rows_affected}}}")
771        }
772        StatementResult::Update { rows_affected } => {
773            format!("{{\"type\":\"update\",\"rows_affected\":{rows_affected}}}")
774        }
775        StatementResult::Delete { rows_affected } => {
776            format!("{{\"type\":\"delete\",\"rows_affected\":{rows_affected}}}")
777        }
778        StatementResult::CreateTable { name } => {
779            format!(
780                "{{\"type\":\"create_table\",\"name\":{}}}",
781                json_string(name)
782            )
783        }
784        StatementResult::DropTable { name } => {
785            format!("{{\"type\":\"drop_table\",\"name\":{}}}", json_string(name))
786        }
787        StatementResult::CreateIndex {
788            name,
789            table,
790            column,
791        } => format!(
792            "{{\"type\":\"create_index\",\"name\":{},\"table\":{},\"column\":{}}}",
793            json_string(name),
794            json_string(table),
795            json_string(column)
796        ),
797        StatementResult::DropIndex { name } => {
798            format!("{{\"type\":\"drop_index\",\"name\":{}}}", json_string(name))
799        }
800        StatementResult::Explain(value) => {
801            format!("{{\"type\":\"explain\",\"value\":{}}}", json_string(value))
802        }
803        StatementResult::Begin => "{\"type\":\"begin\"}".into(),
804        StatementResult::Commit => "{\"type\":\"commit\"}".into(),
805        StatementResult::Rollback => "{\"type\":\"rollback\"}".into(),
806        StatementResult::Checkpoint => "{\"type\":\"checkpoint\"}".into(),
807        StatementResult::Echo(value) => {
808            format!("{{\"type\":\"echo\",\"value\":{}}}", json_string(value))
809        }
810    };
811    writeln!(output, "{json}")
812}
813
814fn render_schema(
815    database: &Database,
816    requested_table: Option<&str>,
817    output: &mut dyn Write,
818) -> Result<(), CliError> {
819    let tables = if let Some(table) = requested_table {
820        let actual = database
821            .table_names()?
822            .into_iter()
823            .find(|name| name.eq_ignore_ascii_case(table))
824            .ok_or_else(|| CliError::new(format!("no such table: {table}")))?;
825        vec![actual]
826    } else {
827        database.table_names()?
828    };
829    if tables.is_empty() {
830        writeln!(output, "No tables.")?;
831        return Ok(());
832    }
833    for table in tables {
834        let columns = database.columns(&table)?;
835        let definitions = columns
836            .iter()
837            .map(column_definition)
838            .collect::<Vec<_>>()
839            .join(", ");
840        writeln!(
841            output,
842            "CREATE TABLE {} ({});",
843            quote_identifier(&table),
844            definitions
845        )?;
846    }
847    Ok(())
848}
849
850fn column_definition(column: &Column) -> String {
851    let mut definition = format!(
852        "{} {}",
853        quote_identifier(&column.name),
854        column_type_name(&column.ty)
855    );
856    if column.primary_key {
857        definition.push_str(" PRIMARY KEY");
858    } else if column.unique {
859        definition.push_str(" UNIQUE");
860    }
861    if column.not_null && !column.primary_key {
862        definition.push_str(" NOT NULL");
863    }
864    definition
865}
866
867fn column_type_name(ty: &ColumnType) -> &'static str {
868    match ty {
869        ColumnType::Integer => "INTEGER",
870        ColumnType::Real => "REAL",
871        ColumnType::Text => "TEXT",
872        ColumnType::Boolean => "BOOLEAN",
873        ColumnType::Any => "ANY",
874        ColumnType::Null => "NULL",
875    }
876}
877
878fn quote_identifier(value: &str) -> String {
879    format!("\"{}\"", value.replace('"', "\"\""))
880}
881
882fn value_text(value: &Value) -> String {
883    match value {
884        Value::Null => "NULL".into(),
885        Value::Integer(value) => value.to_string(),
886        Value::Real(value) => value.to_string(),
887        Value::Text(value) => value.clone(),
888        Value::Boolean(value) => value.to_string(),
889    }
890}
891
892fn table_widths(columns: &[String], rows: &[Vec<String>]) -> Vec<usize> {
893    let mut widths: Vec<usize> = columns.iter().map(|value| value.chars().count()).collect();
894    for row in rows {
895        if widths.len() < row.len() {
896            widths.resize(row.len(), 0);
897        }
898        for (width, value) in widths.iter_mut().zip(row) {
899            *width = (*width).max(value.chars().count());
900        }
901    }
902    widths
903}
904
905fn padded_row(values: &[String], widths: &[usize]) -> String {
906    values
907        .iter()
908        .enumerate()
909        .map(|(index, value)| {
910            let width = widths.get(index).copied().unwrap_or(0);
911            let padding = if index + 1 == values.len() {
912                0
913            } else {
914                width.saturating_sub(value.chars().count())
915            };
916            format!("{value}{}", " ".repeat(padding))
917        })
918        .collect::<Vec<_>>()
919        .join(" | ")
920}
921
922fn separator_row(widths: &[usize]) -> String {
923    widths
924        .iter()
925        .map(|width| "-".repeat(*width))
926        .collect::<Vec<_>>()
927        .join("-+-")
928}
929
930fn json_string(value: &str) -> String {
931    let mut escaped = String::with_capacity(value.len() + 2);
932    escaped.push('"');
933    for character in value.chars() {
934        match character {
935            '"' => escaped.push_str("\\\""),
936            '\\' => escaped.push_str("\\\\"),
937            '\n' => escaped.push_str("\\n"),
938            '\r' => escaped.push_str("\\r"),
939            '\t' => escaped.push_str("\\t"),
940            character if character.is_control() => {
941                use std::fmt::Write as _;
942                let _ = write!(escaped, "\\u{:04x}", character as u32);
943            }
944            character => escaped.push(character),
945        }
946    }
947    escaped.push('"');
948    escaped
949}
950
951fn json_value(value: &Value) -> String {
952    match value {
953        Value::Null => "null".into(),
954        Value::Integer(value) => value.to_string(),
955        Value::Real(value) if value.is_finite() => value.to_string(),
956        Value::Real(_) => "null".into(),
957        Value::Text(value) => json_string(value),
958        Value::Boolean(value) => value.to_string(),
959    }
960}
961
962/// Return the byte offset just after the first top-level semicolon.
963fn top_level_semicolon(input: &str) -> Option<usize> {
964    let bytes = input.as_bytes();
965    let mut i = 0;
966    let mut parentheses = 0usize;
967    let mut quote = None;
968    let mut line_comment = false;
969    let mut block_comment = false;
970
971    while i < bytes.len() {
972        let byte = bytes[i];
973        if line_comment {
974            if byte == b'\n' {
975                line_comment = false;
976            }
977            i += 1;
978            continue;
979        }
980        if block_comment {
981            if byte == b'*' && bytes.get(i + 1) == Some(&b'/') {
982                block_comment = false;
983                i += 2;
984            } else {
985                i += 1;
986            }
987            continue;
988        }
989        if let Some(delimiter) = quote {
990            if byte == delimiter {
991                if bytes.get(i + 1) == Some(&delimiter) {
992                    i += 2;
993                } else {
994                    quote = None;
995                    i += 1;
996                }
997            } else {
998                i += 1;
999            }
1000            continue;
1001        }
1002        match byte {
1003            b'-' if bytes.get(i + 1) == Some(&b'-') => {
1004                line_comment = true;
1005                i += 2;
1006            }
1007            b'/' if bytes.get(i + 1) == Some(&b'*') => {
1008                block_comment = true;
1009                i += 2;
1010            }
1011            b'\'' | b'"' | b'[' => {
1012                quote = Some(if byte == b'[' { b']' } else { byte });
1013                i += 1;
1014            }
1015            b'(' => {
1016                parentheses += 1;
1017                i += 1;
1018            }
1019            b')' => {
1020                parentheses = parentheses.saturating_sub(1);
1021                i += 1;
1022            }
1023            b';' if parentheses == 0 => return Some(i + 1),
1024            _ => i += 1,
1025        }
1026    }
1027    None
1028}
1029
1030fn sql_has_open_construct(input: &str) -> bool {
1031    let bytes = input.as_bytes();
1032    let mut i = 0;
1033    let mut parentheses = 0usize;
1034    let mut quote = None;
1035    let mut line_comment = false;
1036    let mut block_comment = false;
1037
1038    while i < bytes.len() {
1039        let byte = bytes[i];
1040        if line_comment {
1041            if byte == b'\n' {
1042                line_comment = false;
1043            }
1044            i += 1;
1045            continue;
1046        }
1047        if block_comment {
1048            if byte == b'*' && bytes.get(i + 1) == Some(&b'/') {
1049                block_comment = false;
1050                i += 2;
1051            } else {
1052                i += 1;
1053            }
1054            continue;
1055        }
1056        if let Some(delimiter) = quote {
1057            if byte == delimiter {
1058                if bytes.get(i + 1) == Some(&delimiter) {
1059                    i += 2;
1060                } else {
1061                    quote = None;
1062                    i += 1;
1063                }
1064            } else {
1065                i += 1;
1066            }
1067            continue;
1068        }
1069        match byte {
1070            b'-' if bytes.get(i + 1) == Some(&b'-') => {
1071                line_comment = true;
1072                i += 2;
1073            }
1074            b'/' if bytes.get(i + 1) == Some(&b'*') => {
1075                block_comment = true;
1076                i += 2;
1077            }
1078            b'\'' | b'"' | b'[' => {
1079                quote = Some(if byte == b'[' { b']' } else { byte });
1080                i += 1;
1081            }
1082            b'(' => {
1083                parentheses += 1;
1084                i += 1;
1085            }
1086            b')' => {
1087                parentheses = parentheses.saturating_sub(1);
1088                i += 1;
1089            }
1090            _ => i += 1,
1091        }
1092    }
1093    parentheses > 0 || quote.is_some() || block_comment
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098    use super::*;
1099    use std::io::{BufReader, Cursor};
1100
1101    fn args(values: &[&str]) -> Vec<String> {
1102        values.iter().map(|value| (*value).into()).collect()
1103    }
1104
1105    #[test]
1106    fn parses_options_and_preserves_action_order() {
1107        let options = parse_args(&args(&[
1108            "--json",
1109            "-c",
1110            "SELECT 1",
1111            "-f=seed.sql",
1112            "--no-header",
1113            "demo.db",
1114        ]))
1115        .unwrap();
1116        assert_eq!(options.database, "demo.db");
1117        assert_eq!(options.output, OutputMode::Json);
1118        assert!(!options.headers);
1119        assert_eq!(
1120            options.actions,
1121            vec![
1122                InputAction::Command("SELECT 1".into()),
1123                InputAction::File("seed.sql".into())
1124            ]
1125        );
1126    }
1127
1128    #[test]
1129    fn sql_scanner_ignores_nested_literals_and_comments() {
1130        let sql = "SELECT '(' AS x /* ; */; SELECT \";\" AS y;";
1131        assert_eq!(top_level_semicolon(sql), Some(24));
1132        assert!(!sql_has_open_construct("SELECT (1 + 2)"));
1133        assert!(sql_has_open_construct("SELECT (1 + 2"));
1134        assert!(sql_has_open_construct("SELECT 'unfinished"));
1135    }
1136
1137    #[test]
1138    fn renders_json_and_csv_without_external_serializers() {
1139        let results = vec![StatementResult::Select {
1140            columns: vec!["name".into(), "note".into()],
1141            rows: vec![vec![
1142                Value::Text("Ada".into()),
1143                Value::Text("say, \"hi\"".into()),
1144            ]],
1145        }];
1146        let mut json = Vec::new();
1147        render_results(&results, OutputMode::Json, true, false, &mut json).unwrap();
1148        assert_eq!(
1149            String::from_utf8(json).unwrap(),
1150            "{\"type\":\"select\",\"columns\":[\"name\",\"note\"],\"rows\":[[\"Ada\",\"say, \\\"hi\\\"\"]]}\n"
1151        );
1152        let mut csv = Vec::new();
1153        render_results(&results, OutputMode::Csv, true, false, &mut csv).unwrap();
1154        assert_eq!(
1155            String::from_utf8(csv).unwrap(),
1156            "name,note\nAda,\"say, \"\"hi\"\"\"\n"
1157        );
1158    }
1159
1160    #[test]
1161    fn command_mode_uses_one_connection_for_transactions() {
1162        let options = parse_args(&args(&[
1163            "-c",
1164            "CREATE TABLE t (id INTEGER);",
1165            "-c",
1166            "BEGIN;",
1167            "-c",
1168            "INSERT INTO t VALUES (1);",
1169            "-c",
1170            "ROLLBACK;",
1171            "-c",
1172            "SELECT * FROM t;",
1173            "--json",
1174        ]))
1175        .unwrap();
1176        let mut input = Cursor::new(Vec::<u8>::new());
1177        let mut output = Vec::new();
1178        run(&options, Database::in_memory(), &mut input, &mut output).unwrap();
1179        let output = String::from_utf8(output).unwrap();
1180        assert!(output.contains("\"rows\":[]"));
1181    }
1182
1183    #[test]
1184    fn bounded_sql_reader_rejects_input_larger_than_the_cli_contract() {
1185        let input = vec![b'x'; MAX_SQL_INPUT_BYTES + 1];
1186        let error = read_sql(Cursor::new(input), "stdin").unwrap_err();
1187        assert!(error.to_string().contains("SQL input exceeds"));
1188
1189        let sql = "x".repeat(MAX_SQL_INPUT_BYTES + 1);
1190        let error = validate_sql_input(&sql, "command line").unwrap_err();
1191        assert!(error.to_string().contains("SQL input exceeds"));
1192    }
1193
1194    #[test]
1195    fn interactive_line_reader_enforces_remaining_buffer_capacity() {
1196        let mut input = Cursor::new(b"SELECT 1\n".to_vec());
1197        let mut line = String::new();
1198        let error = read_interactive_line(&mut input, &mut line, 0).unwrap_err();
1199        assert_eq!(error.kind(), io::ErrorKind::InvalidData);
1200        assert!(error.to_string().contains("interactive SQL input exceeds"));
1201    }
1202
1203    #[test]
1204    fn interactive_line_reader_accepts_utf8_split_across_input_chunks() {
1205        let mut input = BufReader::with_capacity(1, Cursor::new("SELECT 'é';\n"));
1206        let mut line = String::new();
1207        read_interactive_line(&mut input, &mut line, MAX_SQL_INPUT_BYTES).unwrap();
1208        assert_eq!(line, "SELECT 'é';\n");
1209    }
1210}