datafusion-ducklake-cli 0.2.0

Interactive and scriptable SQL shell for datafusion-ducklake-provider.
Documentation
use std::env;
use std::path::PathBuf;

use datafusion_ducklake_provider::DuckLakeSessionContext;
use rustyline::DefaultEditor;
use rustyline::error::ReadlineError;

use crate::args::Cli;
use crate::error::{CliError, err};
use crate::runner::{RunOptions, run_statement};
use crate::split::split_complete_statements;

pub(crate) async fn run_repl(
    ctx: &DuckLakeSessionContext,
    cli: &Cli,
    mut options: RunOptions,
) -> Result<(), CliError> {
    let mut editor = DefaultEditor::new()?;
    let history_file = history_file(cli);
    if let Some(path) = &history_file {
        let _ = editor.load_history(path);
    }

    let mut buffer = String::new();
    loop {
        let prompt = if buffer.trim().is_empty() {
            "ducklake> "
        } else {
            "...> "
        };
        let line = match editor.readline(prompt) {
            Ok(line) => line,
            Err(ReadlineError::Interrupted) => {
                buffer.clear();
                eprintln!("^C");
                continue;
            }
            Err(ReadlineError::Eof) => break,
            Err(err) => return Err(err.into()),
        };

        let trimmed = line.trim();
        if buffer.trim().is_empty() && trimmed.starts_with('.') {
            match handle_dot_command(trimmed, &mut options) {
                Ok(true) => break,
                Ok(false) => {}
                Err(err) => eprintln!("{err}"),
            }
            continue;
        }
        if trimmed.is_empty() && buffer.trim().is_empty() {
            continue;
        }

        let _ = editor.add_history_entry(line.as_str());
        buffer.push_str(&line);
        buffer.push('\n');
        let split = split_complete_statements(&buffer);
        for statement in split.statements {
            if let Err(err) = run_statement(ctx, &statement, options).await {
                eprintln!("{err}");
            }
        }
        buffer = split.trailing;
    }

    if let Some(path) = &history_file {
        if let Some(parent) = path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        let _ = editor.save_history(path);
    }
    Ok(())
}

fn handle_dot_command(command: &str, options: &mut RunOptions) -> Result<bool, CliError> {
    match command {
        ".exit" | ".quit" => Ok(true),
        ".help" => {
            print_help();
            Ok(false)
        }
        ".timing on" => {
            options.timing = true;
            eprintln!("Timing is on.");
            Ok(false)
        }
        ".timing off" => {
            options.timing = false;
            eprintln!("Timing is off.");
            Ok(false)
        }
        ".clear" => Ok(false),
        other if other.starts_with(".timing") => Err(err("usage: .timing on|off")),
        other => Err(err(format!(
            "unknown command {other:?}; use .help for available commands"
        ))),
    }
}

fn print_help() {
    println!(
        "\
DuckLake CLI commands:
  .help          Show this help.
  .timing on     Print elapsed time after each statement.
  .timing off    Disable elapsed time output.
  .quit, .exit   Exit the shell.

SQL statements are executed through DuckLakeSessionContext::sql.
Use ATTACH to connect a DuckLake catalog, for example:
  ATTACH 'ducklake:sqlite:metadata.sqlite' AS lake (DATA_PATH 'data/');
"
    );
}

fn history_file(cli: &Cli) -> Option<PathBuf> {
    if cli.no_history {
        return None;
    }
    cli.history_file
        .clone()
        .or_else(|| env::var_os("DUCKLAKE_CLI_HISTORY").map(PathBuf::from))
        .or_else(|| {
            env::var_os("HOME").map(|home| PathBuf::from(home).join(".ducklake-cli-history"))
        })
}

#[cfg(test)]
mod tests {
    use crate::runner::RunOptions;

    use super::handle_dot_command;

    #[test]
    fn timing_command_toggles_option() {
        let mut options = RunOptions {
            quiet: false,
            timing: false,
            continue_on_error: false,
        };
        assert!(!handle_dot_command(".timing on", &mut options).unwrap());
        assert!(options.timing);
        assert!(!handle_dot_command(".timing off", &mut options).unwrap());
        assert!(!options.timing);
    }

    #[test]
    fn quit_commands_exit_repl() {
        let mut options = RunOptions {
            quiet: false,
            timing: false,
            continue_on_error: false,
        };
        assert!(handle_dot_command(".quit", &mut options).unwrap());
        assert!(handle_dot_command(".exit", &mut options).unwrap());
    }
}