//! User-facing command-line frontend for Basalt.
//!
//! The SQL engine stays independent of frontend concerns, so the CLI keeps its
//! argument parsing, SQL buffering, result formatting, and meta commands in
//! this module. Keeping the frontend testable outside the executable makes
//! script and interactive behavior share the same connection semantics.
use std::fmt;
use std::fs::File;
use std::io::{self, BufRead, Read, Write};
use crate::database::{Connection, Database};
use crate::db::{Column, DbError, StatementResult};
use crate::sql::parser::parse;
use crate::types::{ColumnType, Value};
/// Maximum SQL source accepted by one CLI action or the interactive buffer.
///
/// The direct CLI is intentionally less restrictive than MCP, but it still
/// needs a finite parser/input boundary for scripts and piped agent output.
pub const MAX_SQL_INPUT_BYTES: usize = 16 * 1024 * 1024;
pub const HELP: &str = "Basalt — embedded SQL database\n\n\
Usage:\n basalt [OPTIONS] [DATABASE_PATH | :memory:]\n\n\
Options:\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\
Interactive 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\
MCP server:\n basalt mcp [OPTIONS] [DATABASE_PATH | :memory:]\n\n\
Workspace:\n basalt init PATH\n basalt workspace --help\n\n\
JSON output is one JSON object per statement (JSON Lines). CSV output emits\n\
only query rows, so it can be piped directly into another data tool. Each CLI\n\
SQL action and interactive input buffer is limited to 16 MiB.\n";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputMode {
Table,
Csv,
Json,
}
impl OutputMode {
fn parse(value: &str) -> Result<Self, CliError> {
match value.to_ascii_lowercase().as_str() {
"table" => Ok(OutputMode::Table),
"csv" => Ok(OutputMode::Csv),
"json" | "jsonl" | "ndjson" => Ok(OutputMode::Json),
_ => Err(CliError::new(format!(
"unknown output format {value:?}; expected table, csv, or json"
))),
}
}
fn name(self) -> &'static str {
match self {
OutputMode::Table => "table",
OutputMode::Csv => "csv",
OutputMode::Json => "json",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputAction {
Command(String),
File(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliOptions {
pub database: String,
pub actions: Vec<InputAction>,
pub output: OutputMode,
pub headers: bool,
pub quiet: bool,
pub help: bool,
pub version: bool,
}
impl Default for CliOptions {
fn default() -> Self {
Self {
database: ":memory:".into(),
actions: Vec::new(),
output: OutputMode::Table,
headers: true,
quiet: false,
help: false,
version: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliError {
pub message: String,
}
impl CliError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for CliError {}
impl From<io::Error> for CliError {
fn from(error: io::Error) -> Self {
Self::new(error.to_string())
}
}
impl From<DbError> for CliError {
fn from(error: DbError) -> Self {
Self::new(error.message)
}
}
/// Parse command-line arguments after the executable name.
pub fn parse_args(args: &[String]) -> Result<CliOptions, CliError> {
let mut options = CliOptions::default();
let mut positional_only = false;
let mut database = None;
let mut i = 0;
while i < args.len() {
let argument = args[i].as_str();
if !positional_only && argument == "--" {
positional_only = true;
i += 1;
continue;
}
let mut take_value = |name: &str| -> Result<String, CliError> {
i += 1;
args.get(i).cloned().ok_or_else(|| {
CliError::new(format!("{name} requires a value; try --help for usage"))
})
};
if !positional_only {
match argument {
"-h" | "--help" => {
options.help = true;
i += 1;
continue;
}
"-V" | "--version" => {
options.version = true;
i += 1;
continue;
}
"-c" | "--command" => {
options
.actions
.push(InputAction::Command(take_value(argument)?));
i += 1;
continue;
}
"-f" | "--file" => {
options
.actions
.push(InputAction::File(take_value(argument)?));
i += 1;
continue;
}
"-o" | "--output" => {
options.output = OutputMode::parse(&take_value(argument)?)?;
i += 1;
continue;
}
"--table" => {
options.output = OutputMode::Table;
i += 1;
continue;
}
"--csv" => {
options.output = OutputMode::Csv;
i += 1;
continue;
}
"--json" => {
options.output = OutputMode::Json;
i += 1;
continue;
}
"--no-header" | "--no-headers" => {
options.headers = false;
i += 1;
continue;
}
"--quiet" | "-q" => {
options.quiet = true;
i += 1;
continue;
}
_ => {}
}
if let Some(value) = argument.strip_prefix("--command=") {
if value.is_empty() {
return Err(CliError::new("--command requires a non-empty value"));
}
options.actions.push(InputAction::Command(value.into()));
i += 1;
continue;
}
if let Some(value) = argument.strip_prefix("--file=") {
if value.is_empty() {
return Err(CliError::new("--file requires a non-empty value"));
}
options.actions.push(InputAction::File(value.into()));
i += 1;
continue;
}
if let Some(value) = argument.strip_prefix("--output=") {
options.output = OutputMode::parse(value)?;
i += 1;
continue;
}
if let Some(value) = argument.strip_prefix("-c=") {
if value.is_empty() {
return Err(CliError::new("-c requires a non-empty value"));
}
options.actions.push(InputAction::Command(value.into()));
i += 1;
continue;
}
if let Some(value) = argument.strip_prefix("-f=") {
if value.is_empty() {
return Err(CliError::new("-f requires a non-empty value"));
}
options.actions.push(InputAction::File(value.into()));
i += 1;
continue;
}
if argument.starts_with('-') {
return Err(CliError::new(format!(
"unknown option {argument:?}; try --help for usage"
)));
}
}
if database.replace(argument.to_string()).is_some() {
return Err(CliError::new(
"only one database path may be provided; try --help for usage",
));
}
i += 1;
}
if let Some(database) = database {
options.database = database;
}
Ok(options)
}
/// Run either the requested commands/scripts or the interactive shell.
pub fn run<R: BufRead>(
options: &CliOptions,
database: Database,
input: &mut R,
output: &mut dyn Write,
) -> Result<(), CliError> {
if options.actions.is_empty() {
run_interactive(options, &database, input, output)
} else {
run_actions(options, &database, input, output)
}
}
fn run_actions<R: BufRead>(
options: &CliOptions,
database: &Database,
input: &mut R,
output: &mut dyn Write,
) -> Result<(), CliError> {
let mut connection = database.connect();
for action in &options.actions {
let (source, sql) = match action {
InputAction::Command(sql) => {
let source = "command line".to_string();
validate_sql_input(sql, &source)?;
(source, sql.clone())
}
InputAction::File(path) if path == "-" => {
("stdin".to_string(), read_sql(&mut *input, "stdin")?)
}
InputAction::File(path) => {
let file =
File::open(path).map_err(|error| CliError::new(format!("{path}: {error}")))?;
(path.clone(), read_sql(file, path)?)
}
};
let results = connection
.execute_sql(&sql)
.map_err(|error| CliError::new(format!("{source}: {error}")))?;
render_results(
&results,
options.output,
options.headers,
options.quiet,
output,
)?;
}
output.flush()?;
Ok(())
}
fn run_interactive<R: BufRead>(
options: &CliOptions,
database: &Database,
input: &mut R,
output: &mut dyn Write,
) -> Result<(), CliError> {
let mut connection = database.connect();
let mut mode = options.output;
let mut headers = options.headers;
let mut buffer = String::new();
write!(output, "basalt> ")?;
output.flush()?;
loop {
let mut line = String::new();
if read_interactive_line(
input,
&mut line,
MAX_SQL_INPUT_BYTES.saturating_sub(buffer.len()),
)? == 0
{
if !buffer.trim().is_empty() {
execute_interactive_sql(
&mut connection,
&buffer,
mode,
headers,
options.quiet,
output,
)?;
}
break;
}
let trimmed = line.trim();
if trimmed.starts_with('.') {
if trimmed.eq_ignore_ascii_case(".clear") {
buffer.clear();
} else if buffer.trim().is_empty() {
match handle_meta(
trimmed,
database,
&mut mode,
&mut headers,
&connection,
output,
) {
Ok(MetaAction::Quit) => break,
Ok(MetaAction::Continue) => {}
Err(error) => writeln!(output, "error: {error}")?,
}
} else {
writeln!(
output,
"error: finish the pending SQL statement before using {trimmed}"
)?;
}
write!(
output,
"{}> ",
if buffer.trim().is_empty() {
"basalt"
} else {
" ..."
}
)?;
output.flush()?;
continue;
}
if trimmed.is_empty() && buffer.trim().is_empty() {
write!(output, "basalt> ")?;
output.flush()?;
continue;
}
buffer.push_str(&line);
loop {
if let Some(end) = top_level_semicolon(&buffer) {
let statement = buffer[..end].to_string();
buffer.drain(..end);
execute_interactive_sql(
&mut connection,
&statement,
mode,
headers,
options.quiet,
output,
)?;
continue;
}
if buffer.trim().is_empty() {
buffer.clear();
break;
}
if sql_has_open_construct(&buffer) {
break;
}
match parse(&buffer) {
Ok(statements) if !statements.is_empty() => {
let statement = std::mem::take(&mut buffer);
execute_interactive_sql(
&mut connection,
&statement,
mode,
headers,
options.quiet,
output,
)?;
}
Ok(_) => buffer.clear(),
Err(_) => {
let statement = std::mem::take(&mut buffer);
execute_interactive_sql(
&mut connection,
&statement,
mode,
headers,
options.quiet,
output,
)?;
}
}
break;
}
write!(
output,
"{}> ",
if buffer.trim().is_empty() {
"basalt"
} else {
" ..."
}
)?;
output.flush()?;
}
Ok(())
}
fn execute_interactive_sql(
connection: &mut Connection,
sql: &str,
mode: OutputMode,
headers: bool,
quiet: bool,
output: &mut dyn Write,
) -> Result<(), CliError> {
match connection.execute_sql(sql) {
Ok(results) => render_results(&results, mode, headers, quiet, output)?,
Err(error) => writeln!(output, "error: {error}")?,
}
Ok(())
}
fn read_sql<R: Read>(reader: R, source: &str) -> Result<String, CliError> {
let mut bytes = Vec::new();
let mut limited = reader.take((MAX_SQL_INPUT_BYTES as u64).saturating_add(1));
limited
.read_to_end(&mut bytes)
.map_err(|error| CliError::new(format!("{source}: {error}")))?;
if bytes.len() > MAX_SQL_INPUT_BYTES {
return Err(CliError::new(format!(
"{source}: SQL input exceeds the {MAX_SQL_INPUT_BYTES}-byte limit"
)));
}
String::from_utf8(bytes)
.map_err(|_| CliError::new(format!("{source}: SQL input is not valid UTF-8")))
}
fn validate_sql_input(sql: &str, source: &str) -> Result<(), CliError> {
if sql.len() > MAX_SQL_INPUT_BYTES {
return Err(CliError::new(format!(
"{source}: SQL input exceeds the {MAX_SQL_INPUT_BYTES}-byte limit"
)));
}
Ok(())
}
fn read_interactive_line<R: BufRead>(
input: &mut R,
line: &mut String,
remaining: usize,
) -> io::Result<usize> {
let mut total = 0;
let mut bytes = Vec::new();
loop {
let available = input.fill_buf()?;
if available.is_empty() {
append_interactive_line(line, bytes)?;
return Ok(total);
}
let newline = available.iter().position(|byte| *byte == b'\n');
let chunk_len = newline.map_or(available.len(), |index| index + 1);
if chunk_len > remaining.saturating_sub(total) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("interactive SQL input exceeds the {MAX_SQL_INPUT_BYTES}-byte limit"),
));
}
bytes.extend_from_slice(&available[..chunk_len]);
input.consume(chunk_len);
total += chunk_len;
if newline.is_some() {
append_interactive_line(line, bytes)?;
return Ok(total);
}
}
}
fn append_interactive_line(line: &mut String, bytes: Vec<u8>) -> io::Result<()> {
let text = String::from_utf8(bytes).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
"interactive input is not valid UTF-8",
)
})?;
line.push_str(&text);
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MetaAction {
Continue,
Quit,
}
fn handle_meta(
command: &str,
database: &Database,
mode: &mut OutputMode,
headers: &mut bool,
connection: &Connection,
output: &mut dyn Write,
) -> Result<MetaAction, CliError> {
let mut parts = command.split_whitespace();
let name = parts.next().unwrap_or_default().to_ascii_lowercase();
match name.as_str() {
".quit" | ".exit" => Ok(MetaAction::Quit),
".help" => {
write!(output, "{HELP}")?;
Ok(MetaAction::Continue)
}
".tables" => {
let names = database.table_names()?;
if names.is_empty() {
writeln!(output, "No tables.")?;
} else {
writeln!(output, "{}", names.join(" "))?;
}
Ok(MetaAction::Continue)
}
".schema" => {
let table = parts.next();
if parts.next().is_some() {
return Err(CliError::new("usage: .schema [TABLE]"));
}
render_schema(database, table, output)?;
Ok(MetaAction::Continue)
}
".mode" => {
let value = parts
.next()
.ok_or_else(|| CliError::new("usage: .mode table|csv|json"))?;
if parts.next().is_some() {
return Err(CliError::new("usage: .mode table|csv|json"));
}
*mode = OutputMode::parse(value)?;
writeln!(output, "output mode: {}", mode.name())?;
Ok(MetaAction::Continue)
}
".headers" => {
let value = parts
.next()
.ok_or_else(|| CliError::new("usage: .headers on|off"))?;
if parts.next().is_some() {
return Err(CliError::new("usage: .headers on|off"));
}
*headers = match value.to_ascii_lowercase().as_str() {
"on" | "true" | "1" => true,
"off" | "false" | "0" => false,
_ => return Err(CliError::new("usage: .headers on|off")),
};
writeln!(output, "headers: {}", if *headers { "on" } else { "off" })?;
Ok(MetaAction::Continue)
}
".checkpoint" => {
if connection.in_transaction() {
return Err(CliError::new(
"cannot checkpoint while a transaction is active",
));
}
database.checkpoint()?;
writeln!(output, "CHECKPOINT")?;
Ok(MetaAction::Continue)
}
".show" => {
writeln!(output, "mode: {}", mode.name())?;
writeln!(output, "headers: {}", if *headers { "on" } else { "off" })?;
writeln!(
output,
"transaction: {}",
if connection.in_transaction() {
"active"
} else {
"none"
}
)?;
Ok(MetaAction::Continue)
}
_ => Err(CliError::new(format!(
"unknown command {command:?}; try .help"
))),
}
}
fn render_results(
results: &[StatementResult],
mode: OutputMode,
headers: bool,
quiet: bool,
output: &mut dyn Write,
) -> io::Result<()> {
for result in results {
if quiet
&& !matches!(
result,
StatementResult::Select { .. } | StatementResult::Explain(_)
)
{
continue;
}
match mode {
OutputMode::Table => render_table_result(result, headers, output)?,
OutputMode::Csv => {
if let StatementResult::Select { columns, rows } = result {
render_csv(columns, rows, headers, output)?;
}
}
OutputMode::Json => render_json_result(result, output)?,
}
}
Ok(())
}
fn render_table_result(
result: &StatementResult,
headers: bool,
output: &mut dyn Write,
) -> io::Result<()> {
match result {
StatementResult::Select { columns, rows } => {
let values: Vec<Vec<String>> = rows
.iter()
.map(|row| row.iter().map(value_text).collect())
.collect();
if headers {
let widths = table_widths(columns, &values);
writeln!(output, "{}", padded_row(columns, &widths))?;
writeln!(output, "{}", separator_row(&widths))?;
for row in &values {
writeln!(output, "{}", padded_row(row, &widths))?;
}
} else {
for row in &values {
writeln!(output, "{}", row.join(" | "))?;
}
}
writeln!(output, "{} row(s)", rows.len())?;
}
StatementResult::Insert { rows_affected }
| StatementResult::Update { rows_affected }
| StatementResult::Delete { rows_affected } => {
writeln!(output, "{rows_affected} row(s) affected")?;
}
StatementResult::CreateTable { name } => writeln!(output, "table '{name}' created")?,
StatementResult::DropTable { name } => writeln!(output, "table '{name}' dropped")?,
StatementResult::CreateIndex { name, .. } => writeln!(output, "index '{name}' created")?,
StatementResult::DropIndex { name } => writeln!(output, "index '{name}' dropped")?,
StatementResult::Explain(value) => writeln!(output, "{value}")?,
StatementResult::Begin => writeln!(output, "BEGIN")?,
StatementResult::Commit => writeln!(output, "COMMIT")?,
StatementResult::Rollback => writeln!(output, "ROLLBACK")?,
StatementResult::Checkpoint => writeln!(output, "CHECKPOINT")?,
StatementResult::Echo(value) => writeln!(output, "{value}")?,
}
Ok(())
}
fn render_csv(
columns: &[String],
rows: &[Vec<Value>],
headers: bool,
output: &mut dyn Write,
) -> io::Result<()> {
if headers {
write_csv_row(columns.iter().map(String::as_str), output)?;
}
for row in rows {
write_csv_row(row.iter().map(value_text), output)?;
}
Ok(())
}
fn write_csv_row<I, S>(values: I, output: &mut dyn Write) -> io::Result<()>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut first = true;
for value in values {
if !first {
output.write_all(b",")?;
}
first = false;
write_csv_field(value.as_ref(), output)?;
}
output.write_all(b"\n")?;
Ok(())
}
fn write_csv_field(value: &str, output: &mut dyn Write) -> io::Result<()> {
if value
.bytes()
.any(|byte| matches!(byte, b',' | b'"' | b'\n' | b'\r'))
{
write!(output, "\"{}\"", value.replace('"', "\"\""))?;
} else {
output.write_all(value.as_bytes())?;
}
Ok(())
}
fn render_json_result(result: &StatementResult, output: &mut dyn Write) -> io::Result<()> {
let json = match result {
StatementResult::Select { columns, rows } => {
let columns = columns
.iter()
.map(|column| json_string(column))
.collect::<Vec<_>>()
.join(",");
let rows = rows
.iter()
.map(|row| {
format!(
"[{}]",
row.iter().map(json_value).collect::<Vec<_>>().join(",")
)
})
.collect::<Vec<_>>()
.join(",");
format!("{{\"type\":\"select\",\"columns\":[{columns}],\"rows\":[{rows}]}}")
}
StatementResult::Insert { rows_affected } => {
format!("{{\"type\":\"insert\",\"rows_affected\":{rows_affected}}}")
}
StatementResult::Update { rows_affected } => {
format!("{{\"type\":\"update\",\"rows_affected\":{rows_affected}}}")
}
StatementResult::Delete { rows_affected } => {
format!("{{\"type\":\"delete\",\"rows_affected\":{rows_affected}}}")
}
StatementResult::CreateTable { name } => {
format!(
"{{\"type\":\"create_table\",\"name\":{}}}",
json_string(name)
)
}
StatementResult::DropTable { name } => {
format!("{{\"type\":\"drop_table\",\"name\":{}}}", json_string(name))
}
StatementResult::CreateIndex {
name,
table,
column,
} => format!(
"{{\"type\":\"create_index\",\"name\":{},\"table\":{},\"column\":{}}}",
json_string(name),
json_string(table),
json_string(column)
),
StatementResult::DropIndex { name } => {
format!("{{\"type\":\"drop_index\",\"name\":{}}}", json_string(name))
}
StatementResult::Explain(value) => {
format!("{{\"type\":\"explain\",\"value\":{}}}", json_string(value))
}
StatementResult::Begin => "{\"type\":\"begin\"}".into(),
StatementResult::Commit => "{\"type\":\"commit\"}".into(),
StatementResult::Rollback => "{\"type\":\"rollback\"}".into(),
StatementResult::Checkpoint => "{\"type\":\"checkpoint\"}".into(),
StatementResult::Echo(value) => {
format!("{{\"type\":\"echo\",\"value\":{}}}", json_string(value))
}
};
writeln!(output, "{json}")
}
fn render_schema(
database: &Database,
requested_table: Option<&str>,
output: &mut dyn Write,
) -> Result<(), CliError> {
let tables = if let Some(table) = requested_table {
let actual = database
.table_names()?
.into_iter()
.find(|name| name.eq_ignore_ascii_case(table))
.ok_or_else(|| CliError::new(format!("no such table: {table}")))?;
vec![actual]
} else {
database.table_names()?
};
if tables.is_empty() {
writeln!(output, "No tables.")?;
return Ok(());
}
for table in tables {
let columns = database.columns(&table)?;
let definitions = columns
.iter()
.map(column_definition)
.collect::<Vec<_>>()
.join(", ");
writeln!(
output,
"CREATE TABLE {} ({});",
quote_identifier(&table),
definitions
)?;
}
Ok(())
}
fn column_definition(column: &Column) -> String {
let mut definition = format!(
"{} {}",
quote_identifier(&column.name),
column_type_name(&column.ty)
);
if column.primary_key {
definition.push_str(" PRIMARY KEY");
} else if column.unique {
definition.push_str(" UNIQUE");
}
if column.not_null && !column.primary_key {
definition.push_str(" NOT NULL");
}
definition
}
fn column_type_name(ty: &ColumnType) -> &'static str {
match ty {
ColumnType::Integer => "INTEGER",
ColumnType::Real => "REAL",
ColumnType::Text => "TEXT",
ColumnType::Boolean => "BOOLEAN",
ColumnType::Any => "ANY",
ColumnType::Null => "NULL",
}
}
fn quote_identifier(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\"\""))
}
fn value_text(value: &Value) -> String {
match value {
Value::Null => "NULL".into(),
Value::Integer(value) => value.to_string(),
Value::Real(value) => value.to_string(),
Value::Text(value) => value.clone(),
Value::Boolean(value) => value.to_string(),
}
}
fn table_widths(columns: &[String], rows: &[Vec<String>]) -> Vec<usize> {
let mut widths: Vec<usize> = columns.iter().map(|value| value.chars().count()).collect();
for row in rows {
if widths.len() < row.len() {
widths.resize(row.len(), 0);
}
for (width, value) in widths.iter_mut().zip(row) {
*width = (*width).max(value.chars().count());
}
}
widths
}
fn padded_row(values: &[String], widths: &[usize]) -> String {
values
.iter()
.enumerate()
.map(|(index, value)| {
let width = widths.get(index).copied().unwrap_or(0);
let padding = if index + 1 == values.len() {
0
} else {
width.saturating_sub(value.chars().count())
};
format!("{value}{}", " ".repeat(padding))
})
.collect::<Vec<_>>()
.join(" | ")
}
fn separator_row(widths: &[usize]) -> String {
widths
.iter()
.map(|width| "-".repeat(*width))
.collect::<Vec<_>>()
.join("-+-")
}
fn json_string(value: &str) -> String {
let mut escaped = String::with_capacity(value.len() + 2);
escaped.push('"');
for character in value.chars() {
match character {
'"' => escaped.push_str("\\\""),
'\\' => escaped.push_str("\\\\"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
character if character.is_control() => {
use std::fmt::Write as _;
let _ = write!(escaped, "\\u{:04x}", character as u32);
}
character => escaped.push(character),
}
}
escaped.push('"');
escaped
}
fn json_value(value: &Value) -> String {
match value {
Value::Null => "null".into(),
Value::Integer(value) => value.to_string(),
Value::Real(value) if value.is_finite() => value.to_string(),
Value::Real(_) => "null".into(),
Value::Text(value) => json_string(value),
Value::Boolean(value) => value.to_string(),
}
}
/// Return the byte offset just after the first top-level semicolon.
fn top_level_semicolon(input: &str) -> Option<usize> {
let bytes = input.as_bytes();
let mut i = 0;
let mut parentheses = 0usize;
let mut quote = None;
let mut line_comment = false;
let mut block_comment = false;
while i < bytes.len() {
let byte = bytes[i];
if line_comment {
if byte == b'\n' {
line_comment = false;
}
i += 1;
continue;
}
if block_comment {
if byte == b'*' && bytes.get(i + 1) == Some(&b'/') {
block_comment = false;
i += 2;
} else {
i += 1;
}
continue;
}
if let Some(delimiter) = quote {
if byte == delimiter {
if bytes.get(i + 1) == Some(&delimiter) {
i += 2;
} else {
quote = None;
i += 1;
}
} else {
i += 1;
}
continue;
}
match byte {
b'-' if bytes.get(i + 1) == Some(&b'-') => {
line_comment = true;
i += 2;
}
b'/' if bytes.get(i + 1) == Some(&b'*') => {
block_comment = true;
i += 2;
}
b'\'' | b'"' | b'[' => {
quote = Some(if byte == b'[' { b']' } else { byte });
i += 1;
}
b'(' => {
parentheses += 1;
i += 1;
}
b')' => {
parentheses = parentheses.saturating_sub(1);
i += 1;
}
b';' if parentheses == 0 => return Some(i + 1),
_ => i += 1,
}
}
None
}
fn sql_has_open_construct(input: &str) -> bool {
let bytes = input.as_bytes();
let mut i = 0;
let mut parentheses = 0usize;
let mut quote = None;
let mut line_comment = false;
let mut block_comment = false;
while i < bytes.len() {
let byte = bytes[i];
if line_comment {
if byte == b'\n' {
line_comment = false;
}
i += 1;
continue;
}
if block_comment {
if byte == b'*' && bytes.get(i + 1) == Some(&b'/') {
block_comment = false;
i += 2;
} else {
i += 1;
}
continue;
}
if let Some(delimiter) = quote {
if byte == delimiter {
if bytes.get(i + 1) == Some(&delimiter) {
i += 2;
} else {
quote = None;
i += 1;
}
} else {
i += 1;
}
continue;
}
match byte {
b'-' if bytes.get(i + 1) == Some(&b'-') => {
line_comment = true;
i += 2;
}
b'/' if bytes.get(i + 1) == Some(&b'*') => {
block_comment = true;
i += 2;
}
b'\'' | b'"' | b'[' => {
quote = Some(if byte == b'[' { b']' } else { byte });
i += 1;
}
b'(' => {
parentheses += 1;
i += 1;
}
b')' => {
parentheses = parentheses.saturating_sub(1);
i += 1;
}
_ => i += 1,
}
}
parentheses > 0 || quote.is_some() || block_comment
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufReader, Cursor};
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).into()).collect()
}
#[test]
fn parses_options_and_preserves_action_order() {
let options = parse_args(&args(&[
"--json",
"-c",
"SELECT 1",
"-f=seed.sql",
"--no-header",
"demo.db",
]))
.unwrap();
assert_eq!(options.database, "demo.db");
assert_eq!(options.output, OutputMode::Json);
assert!(!options.headers);
assert_eq!(
options.actions,
vec![
InputAction::Command("SELECT 1".into()),
InputAction::File("seed.sql".into())
]
);
}
#[test]
fn sql_scanner_ignores_nested_literals_and_comments() {
let sql = "SELECT '(' AS x /* ; */; SELECT \";\" AS y;";
assert_eq!(top_level_semicolon(sql), Some(24));
assert!(!sql_has_open_construct("SELECT (1 + 2)"));
assert!(sql_has_open_construct("SELECT (1 + 2"));
assert!(sql_has_open_construct("SELECT 'unfinished"));
}
#[test]
fn renders_json_and_csv_without_external_serializers() {
let results = vec![StatementResult::Select {
columns: vec!["name".into(), "note".into()],
rows: vec![vec![
Value::Text("Ada".into()),
Value::Text("say, \"hi\"".into()),
]],
}];
let mut json = Vec::new();
render_results(&results, OutputMode::Json, true, false, &mut json).unwrap();
assert_eq!(
String::from_utf8(json).unwrap(),
"{\"type\":\"select\",\"columns\":[\"name\",\"note\"],\"rows\":[[\"Ada\",\"say, \\\"hi\\\"\"]]}\n"
);
let mut csv = Vec::new();
render_results(&results, OutputMode::Csv, true, false, &mut csv).unwrap();
assert_eq!(
String::from_utf8(csv).unwrap(),
"name,note\nAda,\"say, \"\"hi\"\"\"\n"
);
}
#[test]
fn command_mode_uses_one_connection_for_transactions() {
let options = parse_args(&args(&[
"-c",
"CREATE TABLE t (id INTEGER);",
"-c",
"BEGIN;",
"-c",
"INSERT INTO t VALUES (1);",
"-c",
"ROLLBACK;",
"-c",
"SELECT * FROM t;",
"--json",
]))
.unwrap();
let mut input = Cursor::new(Vec::<u8>::new());
let mut output = Vec::new();
run(&options, Database::in_memory(), &mut input, &mut output).unwrap();
let output = String::from_utf8(output).unwrap();
assert!(output.contains("\"rows\":[]"));
}
#[test]
fn bounded_sql_reader_rejects_input_larger_than_the_cli_contract() {
let input = vec![b'x'; MAX_SQL_INPUT_BYTES + 1];
let error = read_sql(Cursor::new(input), "stdin").unwrap_err();
assert!(error.to_string().contains("SQL input exceeds"));
let sql = "x".repeat(MAX_SQL_INPUT_BYTES + 1);
let error = validate_sql_input(&sql, "command line").unwrap_err();
assert!(error.to_string().contains("SQL input exceeds"));
}
#[test]
fn interactive_line_reader_enforces_remaining_buffer_capacity() {
let mut input = Cursor::new(b"SELECT 1\n".to_vec());
let mut line = String::new();
let error = read_interactive_line(&mut input, &mut line, 0).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert!(error.to_string().contains("interactive SQL input exceeds"));
}
#[test]
fn interactive_line_reader_accepts_utf8_split_across_input_chunks() {
let mut input = BufReader::with_capacity(1, Cursor::new("SELECT 'é';\n"));
let mut line = String::new();
read_interactive_line(&mut input, &mut line, MAX_SQL_INPUT_BYTES).unwrap();
assert_eq!(line, "SELECT 'é';\n");
}
}