1use 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
118pub 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
257pub 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
885fn 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}