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