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