use std::{fs, path::Path};
use crate::StorageError;
pub const SQL_DUMP_END_MARKER: &str = "-- End of dump";
pub fn read_sql_dump<P: AsRef<Path>>(path: P) -> Result<String, StorageError> {
let path_ref = path.as_ref();
if !path_ref.exists() {
return Err(StorageError::NotImplemented(format!("File does not exist: {:?}", path_ref)));
}
match fs::read_to_string(path_ref) {
Ok(content) => {
if content.contains(SQL_DUMP_END_MARKER) {
Ok(content)
} else if content.trim().is_empty() {
Ok(content)
} else if content.starts_with("-- VibeSQL Database Dump") {
Err(StorageError::NotImplemented(format!(
"Database file {:?} appears to be truncated (missing end marker). \
This may be caused by an interrupted write. \
If you have a backup, please restore it.",
path_ref
)))
} else {
Ok(content)
}
}
Err(e) => {
if let Ok(bytes) = fs::read(path_ref).map(|b| b.get(0..16).unwrap_or(&[]).to_vec()) {
if bytes.starts_with(b"SQLite format") {
return Err(StorageError::NotImplemented(format!(
"File appears to be a binary SQLite database. vibesql uses SQL dump format (text). \
To import, export from SQLite as SQL: sqlite3 {} .dump > {}.sql",
path_ref.display(),
path_ref.file_stem().and_then(|s| s.to_str()).unwrap_or("database")
)));
}
if let Ok(sample) =
fs::read(path_ref).map(|b| b.get(0..512).unwrap_or(&[]).to_vec())
{
if sample.contains(&0) {
return Err(StorageError::NotImplemented(
"File appears to be a binary database format. vibesql uses SQL dump format (text files). \
Please export your database as SQL text format.".to_string()
));
}
}
}
Err(StorageError::NotImplemented(format!("Failed to read file: {}", e)))
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Quote {
Single,
Double,
Bracket,
}
pub fn parse_sql_statements(content: &str) -> Result<Vec<String>, StorageError> {
let mut statements = Vec::new();
let mut current_statement = String::new();
let mut quote: Option<Quote> = None;
let mut escape_next = false;
let mut begin_depth: u32 = 0;
let mut case_depth: u32 = 0;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("--") || trimmed.is_empty() {
continue;
}
let chars: Vec<char> = line.chars().collect();
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
if escape_next {
current_statement.push(ch);
escape_next = false;
i += 1;
continue;
}
if quote.is_none() && ch == '-' && i + 1 < chars.len() && chars[i + 1] == '-' {
break;
}
if quote.is_none() && ch.is_ascii_alphabetic() {
let prev_is_ident =
i > 0 && (chars[i - 1].is_ascii_alphanumeric() || chars[i - 1] == '_');
if !prev_is_ident {
if let Some(consumed) = match_keyword(&chars, i, "BEGIN") {
begin_depth += 1;
current_statement.push_str("BEGIN");
i += consumed;
continue;
}
if begin_depth > 0 {
if let Some(consumed) = match_keyword(&chars, i, "CASE") {
case_depth += 1;
current_statement.push_str("CASE");
i += consumed;
continue;
}
}
if let Some(consumed) = match_keyword(&chars, i, "END") {
if case_depth > 0 {
case_depth -= 1;
} else if begin_depth > 0 {
begin_depth -= 1;
}
current_statement.push_str("END");
i += consumed;
continue;
}
}
}
match ch {
'\\' if quote == Some(Quote::Single) => {
current_statement.push(ch);
escape_next = true;
}
'\'' if quote.is_none() => {
quote = Some(Quote::Single);
current_statement.push(ch);
}
'"' if quote.is_none() => {
quote = Some(Quote::Double);
current_statement.push(ch);
}
'[' if quote.is_none() => {
quote = Some(Quote::Bracket);
current_statement.push(ch);
}
'\'' if quote == Some(Quote::Single) => {
if chars.get(i + 1) == Some(&'\'') {
current_statement.push(ch);
current_statement.push('\'');
i += 2;
continue;
}
quote = None;
current_statement.push(ch);
}
'"' if quote == Some(Quote::Double) => {
if chars.get(i + 1) == Some(&'"') {
current_statement.push(ch);
current_statement.push('"');
i += 2;
continue;
}
quote = None;
current_statement.push(ch);
}
']' if quote == Some(Quote::Bracket) => {
quote = None;
current_statement.push(ch);
}
';' if quote.is_none() && begin_depth == 0 => {
current_statement.push(ch);
if !current_statement.trim().is_empty() {
statements.push(current_statement.trim_end_matches(';').to_string());
}
current_statement.clear();
}
_ => {
current_statement.push(ch);
}
}
i += 1;
}
if quote.is_none() {
current_statement.push('\n');
}
}
if !current_statement.trim().is_empty() {
statements.push(current_statement.trim().to_string());
}
Ok(statements)
}
fn match_keyword(chars: &[char], i: usize, keyword: &str) -> Option<usize> {
let kw_chars: Vec<char> = keyword.chars().collect();
if i + kw_chars.len() > chars.len() {
return None;
}
for (k, kc) in kw_chars.iter().enumerate() {
if chars[i + k].to_ascii_uppercase() != *kc {
return None;
}
}
let next_idx = i + kw_chars.len();
if next_idx < chars.len() {
let nc = chars[next_idx];
if nc.is_ascii_alphanumeric() || nc == '_' {
return None;
}
}
Some(kw_chars.len())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_statements() {
let content = r#"
-- Comment
CREATE TABLE users (id INTEGER);
INSERT INTO users VALUES (1);
"#;
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 2);
assert!(statements[0].contains("CREATE TABLE"));
assert!(statements[1].contains("INSERT INTO"));
}
#[test]
fn test_parse_with_string_literals() {
let content = r#"INSERT INTO users VALUES (1, 'John; Doe');"#;
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 1);
assert!(statements[0].contains("John; Doe"));
}
#[test]
fn test_quote_inside_double_quoted_identifier_splits_correctly() {
let content = "CREATE TABLE \"weird'name\" (x);\nINSERT INTO \"weird'name\" VALUES (1);";
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 2, "got: {statements:?}");
assert!(statements[0].contains("CREATE TABLE"));
assert!(statements[0].contains("weird'name"));
assert!(statements[1].contains("INSERT INTO"));
}
#[test]
fn test_semicolon_inside_double_quoted_identifier_not_a_terminator() {
let content = "CREATE TABLE \"a;b\" (x);\nCREATE TABLE c (y);";
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 2, "got: {statements:?}");
assert!(statements[0].contains("\"a;b\""));
assert!(statements[1].contains("CREATE TABLE c"));
}
#[test]
fn test_quote_inside_bracket_identifier_splits_correctly() {
let content = "CREATE TABLE [weird';name] (x);\nINSERT INTO [weird';name] VALUES (1);";
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 2, "got: {statements:?}");
assert!(statements[0].contains("[weird';name]"));
assert!(statements[1].contains("INSERT INTO"));
}
#[test]
fn test_double_quote_inside_string_literal_is_literal() {
let content = "INSERT INTO t VALUES ('say \"hi\"');\nINSERT INTO t VALUES (2);";
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 2, "got: {statements:?}");
assert!(statements[0].contains("say \"hi\""));
assert!(statements[1].contains("VALUES (2)"));
}
#[test]
fn test_doubled_quote_escapes_within_quoted_identifier() {
let content = "CREATE TABLE \"a\"\"b\" (x);\nCREATE TABLE c (y);";
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 2, "got: {statements:?}");
assert!(statements[0].contains("\"a\"\"b\""));
assert!(statements[1].contains("CREATE TABLE c"));
}
#[test]
fn test_doubled_quote_escapes_within_string_literal() {
let content = "INSERT INTO t VALUES ('it''s; here');\nINSERT INTO t VALUES (2);";
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 2, "got: {statements:?}");
assert!(statements[0].contains("it''s; here"));
assert!(statements[1].contains("VALUES (2)"));
}
#[test]
fn test_skip_comments() {
let content = r#"
-- This is a comment
CREATE TABLE users (id INTEGER);
-- Another comment
"#;
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 1);
}
#[test]
fn test_multiline_statements() {
let content = r#"
CREATE TABLE users (
id INTEGER,
name VARCHAR(100)
);
"#;
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 1);
assert!(statements[0].contains("id INTEGER"));
assert!(statements[0].contains("name VARCHAR"));
}
#[test]
fn test_inline_comments() {
let content = r#"
CREATE TABLE test_files (
file_path VARCHAR(500) PRIMARY KEY,
category VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL, -- 'PASS', 'FAIL', 'TIMEOUT', 'UNTESTED'
last_tested TIMESTAMP
);
"#;
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 1);
assert!(statements[0].contains("file_path VARCHAR(500) PRIMARY KEY"));
assert!(statements[0].contains("status VARCHAR(20) NOT NULL"));
assert!(!statements[0].contains("PASS"));
assert!(!statements[0].contains("FAIL"));
}
#[test]
fn test_inline_comments_preserve_strings_with_dashes() {
let content = r#"INSERT INTO users VALUES (1, 'test--value');"#;
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 1);
assert!(statements[0].contains("test--value"));
}
#[test]
fn test_inline_comment_after_insert() {
let content = r#"INSERT INTO users VALUES (1, 'Alice'); -- Add first user"#;
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 1);
assert!(statements[0].contains("'Alice'"));
assert!(!statements[0].contains("Add first user"));
}
#[test]
fn test_parse_create_trigger_body_with_semicolons() {
let content = r#"
CREATE TABLE t(a, b);
CREATE TRIGGER tr AFTER INSERT ON t BEGIN
INSERT INTO t VALUES(99, 99);
UPDATE t SET b = 1 WHERE a = 0;
END;
INSERT INTO t VALUES(1, 1);
"#;
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 3, "got: {:#?}", statements);
assert!(statements[0].contains("CREATE TABLE"));
assert!(statements[1].contains("CREATE TRIGGER"));
assert!(statements[1].contains("BEGIN"));
assert!(statements[1].contains("END"));
assert!(statements[1].contains("INSERT INTO t VALUES(99, 99)"));
assert!(statements[1].contains("UPDATE t SET b = 1"));
assert!(statements[2].contains("INSERT INTO t VALUES(1, 1)"));
}
#[test]
fn test_parse_nested_begin_end() {
let content = r#"
CREATE TRIGGER tr AFTER INSERT ON t BEGIN
BEGIN
INSERT INTO t VALUES(1);
END;
INSERT INTO t VALUES(2);
END;
SELECT 1;
"#;
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 2);
assert!(statements[0].contains("CREATE TRIGGER"));
assert!(statements[1].trim().starts_with("SELECT 1"));
}
#[test]
fn test_parse_trigger_body_with_case_end() {
let content = r#"
CREATE TABLE tbl(a, b, c);
CREATE TRIGGER before_tbl_insert BEFORE INSERT ON tbl BEGIN SELECT CASE WHEN (new.a = 4) THEN RAISE(IGNORE) END; END;
INSERT INTO tbl VALUES(1, 2, 3);
"#;
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 3, "got: {:#?}", statements);
assert!(statements[0].contains("CREATE TABLE"));
assert!(statements[1].contains("CREATE TRIGGER"));
assert!(statements[1].contains("CASE WHEN"));
assert!(statements[1].contains("RAISE(IGNORE)"));
assert!(
statements[1].contains("RAISE(IGNORE) END; END"),
"trigger body truncated at CASE END: {:?}",
statements[1]
);
assert!(statements[2].contains("INSERT INTO tbl VALUES(1, 2, 3)"));
}
#[test]
fn test_parse_trigger_body_with_nested_case() {
let content = r#"
CREATE TRIGGER tr BEFORE UPDATE ON t BEGIN
SELECT CASE WHEN a = 1 THEN CASE WHEN b = 2 THEN RAISE(ABORT, 'x') END END;
SELECT CASE WHEN c = 3 THEN RAISE(IGNORE) END;
END;
SELECT 1;
"#;
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 2, "got: {:#?}", statements);
assert!(statements[0].contains("CREATE TRIGGER"));
assert!(statements[1].trim().starts_with("SELECT 1"));
}
#[test]
fn test_parse_begin_keyword_does_not_match_beginning() {
let content = r#"INSERT INTO t VALUES('BEGINNING');
SELECT 1;"#;
let statements = parse_sql_statements(content).unwrap();
assert_eq!(statements.len(), 2);
}
#[test]
fn test_truncation_detection() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut complete_file = NamedTempFile::new().unwrap();
writeln!(complete_file, "-- VibeSQL Database Dump\nCREATE TABLE t(x INT);\n-- End of dump")
.unwrap();
assert!(read_sql_dump(complete_file.path()).is_ok());
let mut truncated_file = NamedTempFile::new().unwrap();
writeln!(
truncated_file,
"-- VibeSQL Database Dump\nCREATE TABLE t(x INT);\nINSERT INTO t VALUES ("
)
.unwrap();
let result = read_sql_dump(truncated_file.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("truncated"));
let empty_file = NamedTempFile::new().unwrap();
assert!(read_sql_dump(empty_file.path()).is_ok());
let mut plain_sql = NamedTempFile::new().unwrap();
writeln!(plain_sql, "CREATE TABLE t(x INT);").unwrap();
assert!(read_sql_dump(plain_sql.path()).is_ok());
}
}