diff --git a/src/app/handlers/mod.rs b/src/app/handlers/mod.rs index fa31a08f..668dd923 100644 --- a/src/app/handlers/mod.rs +++ b/src/app/handlers/mod.rs @@ -33,12 +33,9 @@ use std::sync::Arc; #[cfg(feature = "flightsql")] use tonic::transport::Channel; -use crate::{ - app::{state::tabs::history::HistoryQuery, AppEvent}, - ui::SelectedTab, -}; - use super::App; +use crate::app::ui::SelectedTab; +use crate::app::{state::tabs::history::HistoryQuery, AppEvent}; pub fn crossterm_event_handler(event: event::Event) -> Option { match event { diff --git a/src/app/mod.rs b/src/app/mod.rs index accb8fa2..f7607034 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -16,19 +16,13 @@ // under the License. pub mod app_execution; -pub mod config; pub mod handlers; pub mod state; +pub mod ui; -use crate::cli::DftCli; -use crate::{cli, ui}; use color_eyre::eyre::eyre; use color_eyre::Result; use crossterm::event as ct; -use datafusion::arrow::util::pretty::pretty_format_batches; -use datafusion::execution::SendableRecordBatchStream; -use datafusion::sql::parser::DFParser; -use datafusion::sql::sqlparser::dialect::GenericDialect; use futures::FutureExt; use log::{debug, error, info, trace}; use ratatui::backend::CrosstermBackend; @@ -37,9 +31,6 @@ use ratatui::crossterm::{ terminal::{EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{prelude::*, style::palette::tailwind, widgets::*}; -use std::fs::File; -use std::io::{BufRead, BufReader}; -use std::path::{Path, PathBuf}; use std::sync::Arc; use strum::IntoEnumIterator; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; @@ -77,7 +68,7 @@ pub enum AppEvent { } pub struct App<'app> { - pub cli: DftCli, + //cli: DftArgs, pub state: state::AppState<'app>, pub execution: Arc, pub app_event_tx: UnboundedSender, @@ -88,7 +79,7 @@ pub struct App<'app> { } impl<'app> App<'app> { - pub fn new(state: state::AppState<'app>, cli: DftCli) -> Self { + pub fn new(state: state::AppState<'app>) -> Self { let (app_event_tx, app_event_rx) = mpsc::unbounded_channel(); let app_cancellation_token = CancellationToken::new(); let task = tokio::spawn(async {}); @@ -96,7 +87,6 @@ impl<'app> App<'app> { let execution = Arc::new(ExecutionContext::new(state.config.execution.clone())); Self { - cli, state, task, streams_task, @@ -302,9 +292,9 @@ impl Widget for &App<'_> { } } -pub async fn run_app(cli: cli::DftCli, state: state::AppState<'_>) -> Result<()> { +pub async fn run_app(state: state::AppState<'_>) -> Result<()> { info!("Running app with state: {:?}", state); - let mut app = App::new(state, cli.clone()); + let mut app = App::new(state); app.execute_ddl(); @@ -329,118 +319,3 @@ pub async fn run_app(cli: cli::DftCli, state: state::AppState<'_>) -> Result<()> } app.exit() } - -/// Encapsulates the command line interface -pub struct CliApp { - /// Execution context for running queries - execution: ExecutionContext, -} - -impl CliApp { - pub fn new(state: state::AppState<'static>) -> Self { - let execution = ExecutionContext::new(state.config.execution.clone()); - - Self { execution } - } - - pub async fn execute_files_or_commands( - &self, - files: Vec, - commands: Vec, - ) -> Result<()> { - match (files.is_empty(), commands.is_empty()) { - (true, true) => Err(eyre!("No files or commands provided to execute")), - (false, true) => self.execute_files(files).await, - (true, false) => self.execute_commands(commands).await, - (false, false) => Err(eyre!( - "Cannot execute both files and commands at the same time" - )), - } - } - - async fn execute_files(&self, files: Vec) -> Result<()> { - info!("Executing files: {:?}", files); - for file in files { - self.exec_from_file(&file).await? - } - - Ok(()) - } - async fn execute_commands(&self, commands: Vec) -> Result<()> { - info!("Executing commands: {:?}", commands); - for command in commands { - self.exec_from_string(&command).await? - } - - Ok(()) - } - - async fn exec_from_string(&self, sql: &str) -> Result<()> { - let dialect = GenericDialect {}; - let statements = DFParser::parse_sql_with_dialect(sql, &dialect)?; - for statement in statements { - let stream = self.execution.execute_statement(statement).await?; - self.print_stream(stream).await; - } - Ok(()) - } - - /// run and execute SQL statements and commands from a file, against a context - /// with the given print options - pub async fn exec_from_file(&self, file: &Path) -> Result<()> { - let file = File::open(file)?; - let reader = BufReader::new(file); - - let mut query = String::new(); - - for line in reader.lines() { - let line = line?; - if line.starts_with("#!") { - continue; - } - if line.starts_with("--") { - continue; - } - - let line = line.trim_end(); - query.push_str(line); - // if we found the end of a query, run it - if line.ends_with(';') { - // TODO: if the query errors, should we keep trying to execute - // the other queries in the file? That is what datafusion-cli does... - self.execute_and_print_sql(&query).await?; - query.clear(); - } else { - query.push('\n'); - } - } - - // run the last line(s) in file if the last statement doesn't contain ‘;’ - // ignore if it only consists of '\n' - if query.contains(|c| c != '\n') { - self.execute_and_print_sql(&query).await?; - } - - Ok(()) - } - - /// executes a sql statement and prints the result to stdout - pub async fn execute_and_print_sql(&self, sql: &str) -> Result<()> { - let stream = self.execution.execute_sql(sql).await?; - self.print_stream(stream).await; - Ok(()) - } - - /// Prints the stream to stdout - async fn print_stream(&self, mut stream: SendableRecordBatchStream) { - while let Some(maybe_batch) = stream.next().await { - match maybe_batch { - Ok(batch) => match pretty_format_batches(&[batch]) { - Ok(d) => println!("{}", d), - Err(e) => println!("Error formatting batch: {e}"), - }, - Err(e) => println!("Error executing SQL: {e}"), - } - } - } -} diff --git a/src/app/state/mod.rs b/src/app/state/mod.rs index 94698f7b..a118c356 100644 --- a/src/app/state/mod.rs +++ b/src/app/state/mod.rs @@ -17,18 +17,17 @@ pub mod tabs; -use crate::app::cli; -use crate::app::config::get_data_dir; use crate::app::state::tabs::sql::SQLTabState; use crate::app::ui::SelectedTab; +use crate::config::get_data_dir; use log::{debug, error, info}; use std::path::PathBuf; use self::tabs::{history::HistoryTabState, logs::LogsTabState}; -use super::config::AppConfig; #[cfg(feature = "flightsql")] use crate::app::state::tabs::flightsql::FlightSQLTabState; +use crate::config::AppConfig; #[derive(Debug)] pub struct Tabs { @@ -56,10 +55,9 @@ pub struct AppState<'app> { pub tabs: Tabs, } -pub fn initialize<'app>(args: cli::DftCli) -> AppState<'app> { +pub fn initialize<'app>(config_path: PathBuf) -> AppState<'app> { debug!("Initializing state"); let data_dir = get_data_dir(); - let config_path = args.get_config(); debug!("Config path: {:?}", config_path); let config = if config_path.exists() { debug!("Config exists"); diff --git a/src/ui/convert.rs b/src/app/ui/convert.rs similarity index 100% rename from src/ui/convert.rs rename to src/app/ui/convert.rs diff --git a/src/ui/mod.rs b/src/app/ui/mod.rs similarity index 100% rename from src/ui/mod.rs rename to src/app/ui/mod.rs diff --git a/src/ui/tabs/context.rs b/src/app/ui/tabs/context.rs similarity index 100% rename from src/ui/tabs/context.rs rename to src/app/ui/tabs/context.rs diff --git a/src/ui/tabs/flightsql.rs b/src/app/ui/tabs/flightsql.rs similarity index 98% rename from src/ui/tabs/flightsql.rs rename to src/app/ui/tabs/flightsql.rs index 2e47ce42..daf9ba58 100644 --- a/src/ui/tabs/flightsql.rs +++ b/src/app/ui/tabs/flightsql.rs @@ -23,7 +23,8 @@ use ratatui::{ widgets::{Block, Borders, Paragraph, Row, StatefulWidget, Table, Widget}, }; -use crate::{app::App, ui::convert::record_batches_to_table}; +use crate::app::ui::convert::record_batches_to_table; +use crate::app::App; pub fn render_sql_editor(area: Rect, buf: &mut Buffer, app: &App) { let border_color = if app.state.flightsql_tab.editor_editable() { diff --git a/src/ui/tabs/history.rs b/src/app/ui/tabs/history.rs similarity index 100% rename from src/ui/tabs/history.rs rename to src/app/ui/tabs/history.rs diff --git a/src/ui/tabs/logs.rs b/src/app/ui/tabs/logs.rs similarity index 100% rename from src/ui/tabs/logs.rs rename to src/app/ui/tabs/logs.rs diff --git a/src/ui/tabs/mod.rs b/src/app/ui/tabs/mod.rs similarity index 100% rename from src/ui/tabs/mod.rs rename to src/app/ui/tabs/mod.rs diff --git a/src/ui/tabs/sql.rs b/src/app/ui/tabs/sql.rs similarity index 98% rename from src/ui/tabs/sql.rs rename to src/app/ui/tabs/sql.rs index f848e9bb..16ee0cb8 100644 --- a/src/ui/tabs/sql.rs +++ b/src/app/ui/tabs/sql.rs @@ -23,7 +23,8 @@ use ratatui::{ widgets::{Block, Borders, Paragraph, Row, StatefulWidget, Table, Widget}, }; -use crate::{app::App, ui::convert::record_batches_to_table}; +use crate::app::ui::convert::record_batches_to_table; +use crate::app::App; pub fn render_sql_editor(area: Rect, buf: &mut Buffer, app: &App) { let border_color = if app.state.sql_tab.editor_editable() { diff --git a/src/args.rs b/src/args.rs new file mode 100644 index 00000000..4e6cbc17 --- /dev/null +++ b/src/args.rs @@ -0,0 +1,91 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Command line argument parsing: [`DftArgs`] + +use crate::config::get_data_dir; +use clap::Parser; +use std::path::{Path, PathBuf}; + +const LONG_ABOUT: &str = " +dft - DataFusion TUI + +CLI and terminal UI data analysis tool using Apache DataFusion as query +execution engine. + +dft provides a rich terminal UI as well as a broad array of pre-integrated +data sources and formats for querying and analyzing data. + +Environment Variables +RUST_LOG { trace | debug | info | error }: Standard rust logging level. Default is info. +"; + +#[derive(Clone, Debug, Parser, Default)] +#[command(author, version, about, long_about = LONG_ABOUT)] +pub struct DftArgs { + #[clap( + short, + long, + num_args = 0.., + help = "Execute commands from file(s), then exit", + value_parser(parse_valid_file) + )] + pub files: Vec, + + #[clap( + short = 'c', + long, + num_args = 0.., + help = "Execute the given SQL string(s), then exit.", + value_parser(parse_command) + )] + pub commands: Vec, + + #[clap(long, help = "Path to the configuration file")] + pub config: Option, +} + +impl DftArgs { + pub fn config_path(&self) -> PathBuf { + if let Some(config) = self.config.as_ref() { + Path::new(config).to_path_buf() + } else { + let mut config = get_data_dir(); + config.push("config.toml"); + config + } + } +} + +fn parse_valid_file(file: &str) -> std::result::Result { + let path = PathBuf::from(file); + if !path.exists() { + Err(format!("File does not exist: '{file}'")) + } else if !path.is_file() { + Err(format!("Exists but is not a file: '{file}'")) + } else { + Ok(path) + } +} + +fn parse_command(command: &str) -> std::result::Result { + if !command.is_empty() { + Ok(command.to_string()) + } else { + Err("-c flag expects only non empty commands".to_string()) + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e5391968..c0b98f95 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -14,82 +14,131 @@ // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. +//! [`CliApp`]: Command Line User Interface +use crate::app::state; +use crate::execution::ExecutionContext; +use color_eyre::eyre::eyre; +use datafusion::arrow::util::pretty::pretty_format_batches; +use datafusion::execution::SendableRecordBatchStream; +use datafusion::sql::parser::DFParser; +use futures::StreamExt; +use log::info; +use std::fs::File; +use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; -use clap::Parser; - -use crate::app::config::get_data_dir; - -const LONG_ABOUT: &str = " -dft - DataFusion TUI - -CLI and terminal UI data analysis tool using Apache DataFusion as query -execution engine. - -dft provides a rich terminal UI as well as a broad array of pre-integrated -data sources and formats for querying and analyzing data. - -Environment Variables -RUST_LOG { trace | debug | info | error }: Standard rust logging level. Default is info. -"; - -#[derive(Clone, Debug, Parser, Default)] -#[command(author, version, about, long_about = LONG_ABOUT)] -pub struct DftCli { - #[clap( - short, - long, - num_args = 0.., - help = "Execute commands from file(s), then exit", - value_parser(parse_valid_file) - )] - pub files: Vec, - - #[clap( - short = 'c', - long, - num_args = 0.., - help = "Execute the given SQL string(s), then exit.", - value_parser(parse_command) - )] - pub commands: Vec, - - #[clap(long, help = "Path to the configuration file")] - pub config: Option, +/// Encapsulates the command line interface +pub struct CliApp { + /// Execution context for running queries + execution: ExecutionContext, } -fn get_config_path(cli_config_arg: Option<&String>) -> PathBuf { - if let Some(config) = cli_config_arg { - Path::new(config).to_path_buf() - } else { - let mut config = get_data_dir(); - config.push("config.toml"); - config +impl CliApp { + pub fn new(state: state::AppState<'static>) -> Self { + let execution = ExecutionContext::new(state.config.execution.clone()); + + Self { execution } } -} -impl DftCli { - pub fn get_config(&self) -> PathBuf { - get_config_path(self.config.as_ref()) + pub async fn execute_files_or_commands( + &self, + files: Vec, + commands: Vec, + ) -> color_eyre::Result<()> { + match (files.is_empty(), commands.is_empty()) { + (true, true) => Err(eyre!("No files or commands provided to execute")), + (false, true) => self.execute_files(files).await, + (true, false) => self.execute_commands(commands).await, + (false, false) => Err(eyre!( + "Cannot execute both files and commands at the same time" + )), + } } -} -fn parse_valid_file(file: &str) -> Result { - let path = PathBuf::from(file); - if !path.exists() { - Err(format!("File does not exist: '{file}'")) - } else if !path.is_file() { - Err(format!("Exists but is not a file: '{file}'")) - } else { - Ok(path) + async fn execute_files(&self, files: Vec) -> color_eyre::Result<()> { + info!("Executing files: {:?}", files); + for file in files { + self.exec_from_file(&file).await? + } + + Ok(()) + } + async fn execute_commands(&self, commands: Vec) -> color_eyre::Result<()> { + info!("Executing commands: {:?}", commands); + for command in commands { + self.exec_from_string(&command).await? + } + + Ok(()) + } + + async fn exec_from_string(&self, sql: &str) -> color_eyre::Result<()> { + let dialect = datafusion::sql::sqlparser::dialect::GenericDialect {}; + let statements = DFParser::parse_sql_with_dialect(sql, &dialect)?; + for statement in statements { + let stream = self.execution.execute_statement(statement).await?; + self.print_stream(stream).await; + } + Ok(()) + } + + /// run and execute SQL statements and commands from a file, against a context + /// with the given print options + pub async fn exec_from_file(&self, file: &Path) -> color_eyre::Result<()> { + let file = File::open(file)?; + let reader = BufReader::new(file); + + let mut query = String::new(); + + for line in reader.lines() { + let line = line?; + if line.starts_with("#!") { + continue; + } + if line.starts_with("--") { + continue; + } + + let line = line.trim_end(); + query.push_str(line); + // if we found the end of a query, run it + if line.ends_with(';') { + // TODO: if the query errors, should we keep trying to execute + // the other queries in the file? That is what datafusion-cli does... + self.execute_and_print_sql(&query).await?; + query.clear(); + } else { + query.push('\n'); + } + } + + // run the last line(s) in file if the last statement doesn't contain ‘;’ + // ignore if it only consists of '\n' + if query.contains(|c| c != '\n') { + self.execute_and_print_sql(&query).await?; + } + + Ok(()) + } + + /// executes a sql statement and prints the result to stdout + pub async fn execute_and_print_sql(&self, sql: &str) -> color_eyre::Result<()> { + let stream = self.execution.execute_sql(sql).await?; + self.print_stream(stream).await; + Ok(()) } -} -fn parse_command(command: &str) -> Result { - if !command.is_empty() { - Ok(command.to_string()) - } else { - Err("-c flag expects only non empty commands".to_string()) + /// Prints the stream to stdout + async fn print_stream(&self, mut stream: SendableRecordBatchStream) { + while let Some(maybe_batch) = stream.next().await { + match maybe_batch { + Ok(batch) => match pretty_format_batches(&[batch]) { + Ok(d) => println!("{}", d), + Err(e) => println!("Error formatting batch: {e}"), + }, + Err(e) => println!("Error executing SQL: {e}"), + } + } } } diff --git a/src/app/config.rs b/src/config.rs similarity index 99% rename from src/app/config.rs rename to src/config.rs index 40d35976..70fc6aa9 100644 --- a/src/app/config.rs +++ b/src/config.rs @@ -15,6 +15,8 @@ // specific language governing permissions and limitations // under the License. +//! Configuration management handling + use std::path::PathBuf; use directories::{ProjectDirs, UserDirs}; diff --git a/src/execution/mod.rs b/src/execution/mod.rs index 6b59a3e7..8de42bf3 100644 --- a/src/execution/mod.rs +++ b/src/execution/mod.rs @@ -38,7 +38,7 @@ use { tonic::transport::Channel, }; -use crate::app::config::ExecutionConfig; +use crate::config::ExecutionConfig; /// Structure for executing queries either locally or remotely (via FlightSQL) /// diff --git a/src/lib.rs b/src/lib.rs index ed191eae..a6e31bce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod app; +pub mod args; pub mod cli; +pub mod config; pub mod execution; pub mod telemetry; -pub mod ui; diff --git a/src/main.rs b/src/main.rs index a8fde044..6c7178e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,19 +17,20 @@ use clap::Parser; use color_eyre::Result; -use dft::app::{run_app, state, CliApp}; -use dft::cli; +use dft::app::{run_app, state}; +use dft::args::DftArgs; +use dft::cli::CliApp; use dft::telemetry; #[tokio::main] async fn main() -> Result<()> { - let cli = cli::DftCli::parse(); + let cli = DftArgs::parse(); // CLI mode: executing commands from files or CLI arguments if !cli.files.is_empty() || !cli.commands.is_empty() { // use env_logger to setup logging for CLI env_logger::init(); - let state = state::initialize(cli.clone()); + let state = state::initialize(cli.config_path()); let app = CliApp::new(state); app.execute_files_or_commands(cli.files.clone(), cli.commands.clone()) .await?; @@ -38,8 +39,8 @@ async fn main() -> Result<()> { else { // use alternate logging for TUI telemetry::initialize_logs()?; - let state = state::initialize(cli.clone()); - run_app(cli.clone(), state).await?; + let state = state::initialize(cli.config_path()); + run_app(state).await?; } Ok(()) diff --git a/tests/tui.rs b/tests/tui.rs index 86a15704..031e4fba 100644 --- a/tests/tui.rs +++ b/tests/tui.rs @@ -19,16 +19,11 @@ use dft::app::state::initialize; use dft::app::App; -use dft::cli::DftCli; - -fn setup_app() -> App<'static> { - let args = DftCli::default(); - let state = initialize(args.clone()); - let app = App::new(state, args); - app -} +use tempfile::tempdir; #[tokio::test] async fn run_app_with_no_args() { - let _app = setup_app(); + let config_path = tempdir().unwrap(); + let state = initialize(config_path.path().to_path_buf()); + let _app = App::new(state); }