Skip to main content

cqlite_cli/
script_executor.rs

1//! Script execution module for handling CQL script files
2//!
3//! This module provides functionality to execute CQL scripts from files,
4//! processing multiple statements sequentially with proper error handling
5//! and output formatting.
6
7use anyhow::{Context, Result};
8use cqlite_core::Database;
9use std::path::Path;
10
11use crate::cli::OutputFormat;
12use crate::config::OutputConfig;
13use crate::error::{print_error, CliExitCode};
14
15/// Execute a CQL script file containing multiple statements
16///
17/// This function parses a CQL script file and executes each statement
18/// sequentially against the provided database. If any statement fails,
19/// execution stops immediately and an error is returned.
20///
21/// # Arguments
22///
23/// * `file_path` - Path to the CQL script file
24/// * `database` - Database instance to execute statements against
25/// * `output_config` - Output configuration (color, pagination, etc.)
26/// * `format` - Output format (table, json, csv, parquet)
27///
28/// # Returns
29///
30/// * `Ok(())` - Script executed successfully
31/// * `Err(_)` - Error occurred during script parsing or execution
32///
33/// # Exit Code
34///
35/// On query execution errors, this function prints the error and exits
36/// with code 5 (as per M2_CLI_SPEC.md line 340).
37///
38/// # Examples
39///
40/// ```no_run
41/// use std::path::Path;
42/// use cqlite_core::Database;
43/// use cqlite_cli::config::OutputConfig;
44/// use cqlite_cli::cli::OutputFormat;
45/// use cqlite_cli::script_executor::execute_script_file;
46///
47/// # async fn example() -> anyhow::Result<()> {
48/// let db = Database::open(Path::new("test.db"), Default::default()).await?;
49/// let config = OutputConfig::default();
50///
51/// execute_script_file(
52///     Path::new("script.cql"),
53///     &db,
54///     &config,
55///     OutputFormat::Table,
56/// ).await?;
57/// # Ok(())
58/// # }
59/// ```
60#[cfg(feature = "state_machine")]
61pub async fn execute_script_file(
62    file_path: &Path,
63    database: &Database,
64    output_config: &OutputConfig,
65    format: OutputFormat,
66) -> Result<()> {
67    // Parse the script file
68    let statements = load_script(file_path)
69        .with_context(|| format!("Failed to parse script file: {}", file_path.display()))?;
70
71    if statements.is_empty() {
72        eprintln!("Warning: Script file contains no statements");
73        return Ok(());
74    }
75
76    println!(
77        "Executing {} statement(s) from {}",
78        statements.len(),
79        file_path.display()
80    );
81
82    // Execute each statement sequentially
83    for (index, statement) in statements.iter().enumerate() {
84        let statement_num = index + 1;
85
86        // Execute the statement using the existing execute_query function
87        if let Err(e) = crate::commands::execute_query(
88            database,
89            statement,
90            false, // explain
91            false, // timing
92            format.clone(),
93            output_config,
94        )
95        .await
96        {
97            // Print error with statement context and helpful hint
98            eprintln!(
99                "Error executing statement {} in {}",
100                statement_num,
101                file_path.display()
102            );
103            eprintln!("Statement: {}", statement);
104            print_error(&e, CliExitCode::QueryExecutionError);
105
106            // Exit with code 5 as per M2_CLI_SPEC.md line 340
107            std::process::exit(CliExitCode::QueryExecutionError.as_i32());
108        }
109    }
110
111    println!("\nSuccessfully executed {} statement(s)", statements.len());
112    Ok(())
113}
114
115/// Stub for execute_script_file when state_machine feature is disabled
116#[cfg(not(feature = "state_machine"))]
117pub async fn execute_script_file(
118    _file_path: &Path,
119    _database: &Database,
120    _output_config: &OutputConfig,
121    _format: OutputFormat,
122) -> Result<()> {
123    anyhow::bail!(
124        "Script execution is not available in M1.\n\
125         Build with --features state_machine to enable this feature.\n\
126         See CLAUDE.md for M1 API examples."
127    )
128}
129
130/// Parse a CQL script into individual statements
131///
132/// Handles:
133/// - Semicolon-terminated statements
134/// - Line comments (-- comment)
135/// - Block comments (/* comment */)
136/// - String literals with both single and double quotes
137/// - Escaped quotes (doubled quotes like '' or "")
138/// - Strings with embedded semicolons
139/// - Multi-line statements
140/// - Blank lines and whitespace
141///
142/// Returns a vector of trimmed statement strings (without trailing semicolons)
143///
144/// # Errors
145///
146/// Returns an error if:
147/// - An unterminated statement is found (missing semicolon)
148/// - An unterminated string literal is found
149/// - An unterminated block comment is found
150pub fn parse_script(script_content: &str) -> Result<Vec<String>> {
151    let mut statements = Vec::new();
152    let mut current_statement = String::new();
153    let mut chars = script_content.chars().peekable();
154
155    let mut in_string = false;
156    let mut in_line_comment = false;
157    let mut in_block_comment = false;
158    let mut string_delimiter = '\0';
159
160    while let Some(ch) = chars.next() {
161        // Handle line comments
162        if !in_string && !in_block_comment && ch == '-' {
163            if chars.peek() == Some(&'-') {
164                chars.next(); // consume second '-'
165                in_line_comment = true;
166                continue;
167            }
168        }
169
170        // End line comment at newline
171        if in_line_comment {
172            if ch == '\n' {
173                in_line_comment = false;
174                current_statement.push(ch);
175            }
176            continue;
177        }
178
179        // Handle block comments
180        if !in_string && !in_line_comment && ch == '/' {
181            if chars.peek() == Some(&'*') {
182                chars.next(); // consume '*'
183                in_block_comment = true;
184                continue;
185            }
186        }
187
188        // End block comment
189        if in_block_comment {
190            if ch == '*' && chars.peek() == Some(&'/') {
191                chars.next(); // consume '/'
192                in_block_comment = false;
193            }
194            continue;
195        }
196
197        // Handle string literals
198        if !in_block_comment && !in_line_comment {
199            if ch == '\'' || ch == '"' {
200                if !in_string {
201                    in_string = true;
202                    string_delimiter = ch;
203                    current_statement.push(ch);
204                } else if ch == string_delimiter {
205                    // Check for escaped quote (doubled quote)
206                    if chars.peek() == Some(&ch) {
207                        current_statement.push(ch);
208                        current_statement.push(chars.next().unwrap());
209                    } else {
210                        in_string = false;
211                        current_statement.push(ch);
212                    }
213                } else {
214                    current_statement.push(ch);
215                }
216                continue;
217            }
218        }
219
220        // Handle statement terminator (semicolon)
221        if !in_string && !in_line_comment && !in_block_comment && ch == ';' {
222            let trimmed = current_statement.trim();
223            if !trimmed.is_empty() {
224                statements.push(trimmed.to_string());
225            }
226            current_statement.clear();
227            continue;
228        }
229
230        // Regular character - add to current statement
231        if !in_line_comment && !in_block_comment {
232            current_statement.push(ch);
233        }
234    }
235
236    // Check for unterminated constructs
237    if in_string {
238        anyhow::bail!("Unterminated string literal in script");
239    }
240
241    if in_block_comment {
242        anyhow::bail!("Unterminated block comment in script");
243    }
244
245    // Check for unterminated statement
246    let remaining = current_statement.trim();
247    if !remaining.is_empty() {
248        anyhow::bail!(
249            "Unterminated statement found (missing semicolon): {}",
250            if remaining.len() > 50 {
251                format!("{}...", &remaining[..50])
252            } else {
253                remaining.to_string()
254            }
255        );
256    }
257
258    Ok(statements)
259}
260
261/// Load and parse a CQL script file
262pub fn load_script(script_path: &Path) -> Result<Vec<String>> {
263    let content = std::fs::read_to_string(script_path)
264        .with_context(|| format!("Failed to read script file: {}", script_path.display()))?;
265
266    parse_script(&content)
267        .with_context(|| format!("Failed to parse script file: {}", script_path.display()))
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_parse_empty_file() {
276        let result = parse_script("");
277        assert!(result.is_ok());
278        assert_eq!(result.unwrap().len(), 0);
279    }
280
281    #[test]
282    fn test_parse_single_statement() {
283        let content = "SELECT * FROM users;";
284        let result = parse_script(content);
285        assert!(result.is_ok());
286        let statements = result.unwrap();
287        assert_eq!(statements.len(), 1);
288        assert_eq!(statements[0], "SELECT * FROM users");
289    }
290
291    #[test]
292    fn test_parse_multiple_statements() {
293        let content = "SELECT * FROM users;\nINSERT INTO users VALUES (1, 'test');";
294        let result = parse_script(content);
295        assert!(result.is_ok());
296        let statements = result.unwrap();
297        assert_eq!(statements.len(), 2);
298        assert_eq!(statements[0], "SELECT * FROM users");
299        assert_eq!(statements[1], "INSERT INTO users VALUES (1, 'test')");
300    }
301
302    #[test]
303    fn test_parse_line_comments() {
304        let content = "-- This is a comment\nSELECT * FROM users; -- inline comment";
305        let result = parse_script(content);
306        assert!(result.is_ok());
307        let statements = result.unwrap();
308        assert_eq!(statements.len(), 1);
309        assert_eq!(statements[0], "SELECT * FROM users");
310    }
311
312    #[test]
313    fn test_parse_block_comments() {
314        let content = "/* block comment */ SELECT * FROM users; /* another comment */";
315        let result = parse_script(content);
316        assert!(result.is_ok());
317        let statements = result.unwrap();
318        assert_eq!(statements.len(), 1);
319        assert_eq!(statements[0], "SELECT * FROM users");
320    }
321
322    #[test]
323    fn test_parse_multiline_block_comment() {
324        let content = "/*\n * Multi-line\n * block comment\n */\nSELECT * FROM users;";
325        let result = parse_script(content);
326        assert!(result.is_ok());
327        let statements = result.unwrap();
328        assert_eq!(statements.len(), 1);
329        assert_eq!(statements[0], "SELECT * FROM users");
330    }
331
332    #[test]
333    fn test_parse_string_with_semicolon() {
334        let content = "INSERT INTO users VALUES (1, 'test;value');";
335        let result = parse_script(content);
336        assert!(result.is_ok());
337        let statements = result.unwrap();
338        assert_eq!(statements.len(), 1);
339        assert_eq!(statements[0], "INSERT INTO users VALUES (1, 'test;value')");
340    }
341
342    #[test]
343    fn test_parse_double_quoted_string() {
344        let content = r#"INSERT INTO users VALUES (1, "test;value");"#;
345        let result = parse_script(content);
346        assert!(result.is_ok());
347        let statements = result.unwrap();
348        assert_eq!(statements.len(), 1);
349        assert_eq!(
350            statements[0],
351            r#"INSERT INTO users VALUES (1, "test;value")"#
352        );
353    }
354
355    #[test]
356    fn test_parse_escaped_single_quotes() {
357        let content = "INSERT INTO users VALUES (1, 'test''s value');";
358        let result = parse_script(content);
359        assert!(result.is_ok());
360        let statements = result.unwrap();
361        assert_eq!(statements.len(), 1);
362        assert_eq!(
363            statements[0],
364            "INSERT INTO users VALUES (1, 'test''s value')"
365        );
366    }
367
368    #[test]
369    fn test_parse_escaped_double_quotes() {
370        let content = r#"INSERT INTO users VALUES (1, "test""s value");"#;
371        let result = parse_script(content);
372        assert!(result.is_ok());
373        let statements = result.unwrap();
374        assert_eq!(statements.len(), 1);
375        assert_eq!(
376            statements[0],
377            r#"INSERT INTO users VALUES (1, "test""s value")"#
378        );
379    }
380
381    #[test]
382    fn test_parse_multiline_statement() {
383        let content = "SELECT *\nFROM users\nWHERE id = 1;";
384        let result = parse_script(content);
385        assert!(result.is_ok());
386        let statements = result.unwrap();
387        assert_eq!(statements.len(), 1);
388        assert_eq!(statements[0], "SELECT *\nFROM users\nWHERE id = 1");
389    }
390
391    #[test]
392    fn test_parse_blank_lines() {
393        let content = "SELECT * FROM users;\n\n\nINSERT INTO users VALUES (1, 'test');";
394        let result = parse_script(content);
395        assert!(result.is_ok());
396        let statements = result.unwrap();
397        assert_eq!(statements.len(), 2);
398    }
399
400    #[test]
401    fn test_parse_comments_only() {
402        let content = "-- comment 1\n/* comment 2 */\n-- comment 3";
403        let result = parse_script(content);
404        assert!(result.is_ok());
405        assert_eq!(result.unwrap().len(), 0);
406    }
407
408    #[test]
409    fn test_parse_unterminated_statement() {
410        let content = "SELECT * FROM users";
411        let result = parse_script(content);
412        assert!(result.is_err());
413        assert!(result
414            .unwrap_err()
415            .to_string()
416            .contains("Unterminated statement"));
417    }
418
419    #[test]
420    fn test_parse_unterminated_string() {
421        let content = "INSERT INTO users VALUES (1, 'unterminated;";
422        let result = parse_script(content);
423        assert!(result.is_err());
424        assert!(result
425            .unwrap_err()
426            .to_string()
427            .contains("Unterminated string"));
428    }
429
430    #[test]
431    fn test_parse_unterminated_block_comment() {
432        let content = "/* unterminated comment\nSELECT * FROM users;";
433        let result = parse_script(content);
434        assert!(result.is_err());
435        assert!(result
436            .unwrap_err()
437            .to_string()
438            .contains("Unterminated block comment"));
439    }
440
441    #[test]
442    fn test_parse_complex_script() {
443        let content = r#"
444-- Create table
445CREATE TABLE users (
446    id INT PRIMARY KEY,
447    name TEXT,
448    email TEXT
449);
450
451/* Insert test data */
452INSERT INTO users VALUES (1, 'Alice', 'alice@example.com');
453INSERT INTO users VALUES (2, 'Bob', 'bob@example.com');
454
455-- Query with string containing semicolon
456SELECT * FROM users WHERE email = 'test;email@example.com';
457"#;
458        let result = parse_script(content);
459        assert!(result.is_ok());
460        let statements = result.unwrap();
461        assert_eq!(statements.len(), 4);
462    }
463
464    #[test]
465    fn test_parse_empty_statements() {
466        let content = ";;; SELECT * FROM users; ;;;";
467        let result = parse_script(content);
468        assert!(result.is_ok());
469        let statements = result.unwrap();
470        assert_eq!(statements.len(), 1);
471        assert_eq!(statements[0], "SELECT * FROM users");
472    }
473
474    #[test]
475    fn test_parse_mixed_quotes() {
476        let content = r#"INSERT INTO users VALUES (1, 'single', "double");"#;
477        let result = parse_script(content);
478        assert!(result.is_ok());
479        let statements = result.unwrap();
480        assert_eq!(statements.len(), 1);
481    }
482
483    #[test]
484    fn test_parse_comment_in_string() {
485        let content = "INSERT INTO users VALUES (1, 'value with -- comment inside');";
486        let result = parse_script(content);
487        assert!(result.is_ok());
488        let statements = result.unwrap();
489        assert_eq!(statements.len(), 1);
490        assert_eq!(
491            statements[0],
492            "INSERT INTO users VALUES (1, 'value with -- comment inside')"
493        );
494    }
495
496    #[test]
497    fn test_parse_block_comment_in_string() {
498        let content = "INSERT INTO users VALUES (1, 'value with /* comment */ inside');";
499        let result = parse_script(content);
500        assert!(result.is_ok());
501        let statements = result.unwrap();
502        assert_eq!(statements.len(), 1);
503        assert_eq!(
504            statements[0],
505            "INSERT INTO users VALUES (1, 'value with /* comment */ inside')"
506        );
507    }
508}