diff --git a/ahnlich/Cargo.lock b/ahnlich/Cargo.lock index 256b7097..44e12407 100644 --- a/ahnlich/Cargo.lock +++ b/ahnlich/Cargo.lock @@ -502,6 +502,7 @@ dependencies = [ "clap 4.5.4", "crossterm", "deadpool", + "dirs", "dsl", "serde", "serde_json", @@ -761,6 +762,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dsl" version = "0.1.0" @@ -1255,6 +1277,16 @@ version = "0.2.154" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346" +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.5.0", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -1525,6 +1557,12 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "4.2.0" @@ -1882,6 +1920,17 @@ dependencies = [ "bitflags 2.5.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.10.4" diff --git a/ahnlich/Cargo.toml b/ahnlich/Cargo.toml index 74fffe36..3cda2cc3 100644 --- a/ahnlich/Cargo.toml +++ b/ahnlich/Cargo.toml @@ -45,3 +45,4 @@ opentelemetry = { version = "0.23.0", features = ["trace"] } tracing-opentelemetry = "0.24.0" log = "0.4" fallible_collections = "0.4.9" +dirs = "5.0.1" diff --git a/ahnlich/cli/Cargo.toml b/ahnlich/cli/Cargo.toml index 9bed2c8c..2c5c1e38 100644 --- a/ahnlich/cli/Cargo.toml +++ b/ahnlich/cli/Cargo.toml @@ -13,8 +13,9 @@ path = "src/lib.rs" [dependencies] -crossterm = "0.28.1" +crossterm = { version = "0.28.1", feature = ["bracketed-paste"]} clap.workspace = true +dirs.workspace = true dsl = { path = "../dsl", version = "*" } thiserror.workspace = true tokio.workspace = true diff --git a/ahnlich/cli/src/connect.rs b/ahnlich/cli/src/connect.rs index 5dd2e78d..82d1679a 100644 --- a/ahnlich/cli/src/connect.rs +++ b/ahnlich/cli/src/connect.rs @@ -36,6 +36,14 @@ impl AgentPool { } } + /// Returns the commands for the agent pool in question + pub fn commands(&self) -> &[&str] { + match self { + AgentPool::AI(_) => dsl::ai::COMMANDS, + AgentPool::DB(_) => dsl::db::COMMANDS, + } + } + /// Checks if the connection to to a host and post is alive, also checks the cli is connected /// to the right server( ahnlich ai or db) pub async fn is_valid_connection(&self) -> Result { diff --git a/ahnlich/cli/src/history.rs b/ahnlich/cli/src/history.rs new file mode 100644 index 00000000..2a61290a --- /dev/null +++ b/ahnlich/cli/src/history.rs @@ -0,0 +1,103 @@ +use std::{ + fs::OpenOptions, + io::{self, BufRead, Write}, + path::PathBuf, +}; + +fn get_history_file_path() -> PathBuf { + let mut path = dirs::home_dir().expect("Could not find home directory"); + path.push(".ahnlich_cli_history"); + path +} + +fn load_command_history() -> Vec { + let path = get_history_file_path(); + if path.exists() { + let file = OpenOptions::new() + .read(true) + .open(path) + .expect("Unable to open history file"); + let reader = io::BufReader::new(file); + reader.lines().map_while(Result::ok).collect() + } else { + Vec::new() + } +} + +fn save_command_history(commands: &[String]) { + let path = get_history_file_path(); + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path) + .expect("Unable to open history file"); + for command in commands { + writeln!(file, "{}", command).expect("Unable to write to history file"); + } +} + +pub(crate) struct HistoryManager { + command_history: Vec, + current_command_index: usize, +} + +impl HistoryManager { + pub(crate) fn new() -> Self { + let command_history = load_command_history(); + let current_command_index = command_history.len(); + Self { + command_history, + current_command_index, + } + } + + pub(crate) fn down(&mut self) -> String { + if self.current_command_index < self.command_history.len() { + self.current_command_index += 1; + } + if self.is_index_end() { + String::new() + } else { + self.get_at_index() + } + } + + pub(crate) fn up(&mut self) -> String { + if self.current_command_index > 0 { + self.current_command_index -= 1; + } + if self.command_history.is_empty() && self.current_command_index == 0 { + String::new() + } else { + self.get_at_index() + } + } + + fn get_at_index(&self) -> String { + self.command_history[self.current_command_index].clone() + } + + pub(crate) fn reset_index(&mut self) { + self.current_command_index = self.command_history.len(); + } + + pub(crate) fn is_index_end(&self) -> bool { + self.current_command_index == self.command_history.len() + } + + pub(crate) fn save_to_disk(&self) { + save_command_history(&self.command_history); + } + + pub(crate) fn add_command(&mut self, command: &str) { + if let Some(last_command) = self.command_history.last() { + if last_command != command { + self.command_history.push(command.to_string()); + } + } else { + self.command_history.push(command.to_string()); + } + self.reset_index(); + } +} diff --git a/ahnlich/cli/src/lib.rs b/ahnlich/cli/src/lib.rs index dad12301..1bf67e8c 100644 --- a/ahnlich/cli/src/lib.rs +++ b/ahnlich/cli/src/lib.rs @@ -1,3 +1,4 @@ pub mod config; pub mod connect; +mod history; pub mod term; diff --git a/ahnlich/cli/src/term.rs b/ahnlich/cli/src/term.rs index b428b6bc..b8a50c08 100644 --- a/ahnlich/cli/src/term.rs +++ b/ahnlich/cli/src/term.rs @@ -1,13 +1,48 @@ use crossterm::{ + cursor, event::{self, Event, KeyCode, KeyEvent}, + queue, style::{Color, Print, SetForegroundColor, Stylize}, + terminal::{self, disable_raw_mode, enable_raw_mode}, ExecutableCommand, }; -use std::io::{self, stdout, Write}; +use std::io::{self, stdout, Stdout, Write}; -use crate::connect::AgentPool; +use crate::{connect::AgentPool, history::HistoryManager}; -const RESERVED_WORDS: [&str; 3] = ["ping", "infoserver", "createpredindex"]; +#[derive(Debug)] +enum SpecialEntry { + Enter, + Up, + Down, + Left, + Right, + Del, + Exit, + ClrScr, +} + +#[derive(Debug)] +enum Entry { + Char(char), + Special(SpecialEntry), + None, +} + +impl Entry { + fn is_history_key(&self) -> bool { + matches!( + self, + Entry::Special(SpecialEntry::Up) | Entry::Special(SpecialEntry::Down) + ) + } +} + +#[derive(Debug)] +enum LineResult { + Command(String), + Exit, +} pub struct Term { client_pool: AgentPool, @@ -18,80 +53,259 @@ impl Term { Self { client_pool } } - pub(crate) fn read_line(&self) -> io::Result { - let mut line = String::new(); - while let Event::Key(KeyEvent { code, .. }) = event::read()? { - match code { - KeyCode::Enter => { - break; + fn read_char(&self) -> io::Result { + match event::read()? { + Event::Key(KeyEvent { + code, modifiers, .. + }) => { + if code == KeyCode::Char('c') && modifiers == event::KeyModifiers::CONTROL { + return Ok(Entry::Special(SpecialEntry::Exit)); } - KeyCode::Char(c) => { - line.push(c); - } - KeyCode::Esc => { - break; + if code == KeyCode::Char('l') && modifiers == event::KeyModifiers::CONTROL { + return Ok(Entry::Special(SpecialEntry::ClrScr)); } - _ => {} + Ok(match code { + KeyCode::Enter => Entry::Special(SpecialEntry::Enter), + KeyCode::Char(c) => Entry::Char(c), + KeyCode::Left => Entry::Special(SpecialEntry::Left), + KeyCode::Up => Entry::Special(SpecialEntry::Up), + KeyCode::Down => Entry::Special(SpecialEntry::Down), + KeyCode::Right => Entry::Special(SpecialEntry::Right), + KeyCode::Backspace => Entry::Special(SpecialEntry::Del), + _ => Entry::None, + }) } + _ => Ok(Entry::None), } - Ok(line) } pub fn welcome_message(&self) -> io::Result<()> { let mut stdout = stdout(); - stdout.execute(SetForegroundColor(Color::White))?; - stdout.execute(Print(format!( - "Welcome To Ahnlich {}\n\n", - self.client_pool - )))?; - stdout.execute(SetForegroundColor(Color::White))?; + queue!( + stdout, + terminal::Clear(terminal::ClearType::All), + cursor::MoveTo(0, 0), + SetForegroundColor(Color::White), + Print(format!("Welcome To Ahnlich {}\n\n", self.client_pool)), + SetForegroundColor(Color::White), + )?; stdout.flush()?; Ok(()) } - pub(crate) fn ahnlich_prompt(&self) -> io::Result<()> { - let mut stdout = stdout(); + pub(crate) fn ahnlich_prompt(&self, stdout: &mut Stdout) -> io::Result<()> { stdout.execute(SetForegroundColor(Color::White))?; - stdout.execute(Print(">>> "))?; + stdout.execute(Print(">>>"))?; stdout.execute(SetForegroundColor(Color::White))?; + stdout.flush()?; - //stdout.flush()?; Ok(()) } - pub(crate) fn print_query(&self, query: &str) -> io::Result<()> { - self.ahnlich_prompt()?; - let output = String::from_iter(query.split(' ').map(|ex| { - if RESERVED_WORDS.contains(&(ex.to_lowercase().as_str())) { - format!("{} ", ex.magenta()) - } else { - format!("{} ", ex.white()) - } - })); + pub(crate) fn format_output(&self, query: &str) -> String { + let matches = |c| c == ';' || c == ' '; + let output = query + .split_inclusive(matches) + .map(|ex| { + // Trim the trailing space or semicolon from the command part + let trimmed_ex = ex.trim_end_matches(matches); + + if self + .client_pool + .commands() + .contains(&(trimmed_ex.to_lowercase().as_str())) + { + // Add back the space or semicolon at the end (if present) + format!("{}{}", trimmed_ex.magenta(), &ex[trimmed_ex.len()..]) + } else { + format!("{}{}", trimmed_ex.white(), &ex[trimmed_ex.len()..]) + } + }) + .collect::(); + + output + } + + fn remove_at_pos(&self, input: &mut String, char_index: u16) { + let byte_index = input + .char_indices() + .nth(char_index as usize) + .map(|(entry, _)| entry) + .unwrap_or_else(|| panic!("Index out of bounds {} --> {}", input.len(), char_index)); - println!("{output}"); + input.remove(byte_index); + } + fn move_to_pos_and_print( + &self, + stdout: &mut Stdout, + output: &str, + col_pos: u16, + ) -> io::Result<()> { + let formatted_output = self.format_output(output); + queue!( + stdout, + cursor::MoveToColumn(col_pos), + terminal::Clear(terminal::ClearType::FromCursorDown), + Print(formatted_output) + )?; + stdout.flush()?; Ok(()) } - pub async fn run(&self) -> io::Result<()> { + fn delete_and_print_less( + &self, + stdout: &mut Stdout, + output: &str, + col_pos: u16, + ) -> io::Result<()> { + let formatted_output = self.format_output(output); + let clean = vec![" "; output.len() + 1]; + queue!( + stdout, + cursor::MoveToColumn(col_pos), + Print(clean.join("").to_string()), + cursor::MoveToColumn(col_pos), + Print(formatted_output) + )?; + stdout.flush()?; + Ok(()) + } + + fn read_line( + &self, + stdout: &mut Stdout, + history: &mut HistoryManager, + ) -> io::Result { + let (start_pos_col, _) = cursor::position()?; + let mut output = String::new(); + loop { - self.ahnlich_prompt()?; - let input = self.read_line()?; - match input.as_str() { - "quit" | "exit()" => break, - command => { - self.print_query(command)?; - let response = self.client_pool.parse_queries(command).await; - - match response { - Ok(success) => { - println!("{}", success.join("\n\n")) + let char = self.read_char()?; + let (current_pos_col, _) = cursor::position()?; + if !char.is_history_key() { + history.reset_index(); + } + match char { + Entry::Char(c) => { + let insertion_position = current_pos_col - start_pos_col; + output.insert(insertion_position as usize, c); + self.move_to_pos_and_print(stdout, &output, start_pos_col)?; + stdout.execute(cursor::MoveToColumn(current_pos_col + 1))?; + } + Entry::Special(special) => match special { + SpecialEntry::Up => { + output = history.up(); + queue!( + stdout, + cursor::MoveToColumn(start_pos_col), + terminal::Clear(terminal::ClearType::FromCursorDown), + )?; + self.move_to_pos_and_print(stdout, &output, start_pos_col)?; + } + SpecialEntry::Down => { + output = history.down(); + queue!( + stdout, + cursor::MoveToColumn(start_pos_col), + terminal::Clear(terminal::ClearType::FromCursorDown), + )?; + self.move_to_pos_and_print(stdout, &output, start_pos_col)?; + } + SpecialEntry::Enter => { + queue!(stdout, Print("\n"), cursor::MoveToColumn(0))?; + stdout.flush()?; + break; + } + SpecialEntry::Left => { + if start_pos_col < current_pos_col { + stdout.execute(cursor::MoveLeft(1))?; + } + } + SpecialEntry::Right => { + if start_pos_col + output.len() as u16 > current_pos_col { + stdout.execute(cursor::MoveRight(1))?; } - Err(err) => println!("{}", err.red()), } + SpecialEntry::Del => { + if current_pos_col == start_pos_col { + continue; + } + let output_current_pos = current_pos_col - start_pos_col; + + if !output.is_empty() { + self.remove_at_pos(&mut output, output_current_pos - 1); + self.delete_and_print_less(stdout, &output, start_pos_col)?; + stdout.execute(cursor::MoveToColumn(current_pos_col - 1))?; + } + } + SpecialEntry::ClrScr => { + queue!( + stdout, + cursor::MoveTo(0, 0), + terminal::Clear(terminal::ClearType::All), + )?; + self.ahnlich_prompt(stdout)?; + self.move_to_pos_and_print(stdout, &output, start_pos_col)?; + } + SpecialEntry::Exit => return Ok(LineResult::Exit), + }, + Entry::None => { + continue; } + } + } + history.add_command(&output); + history.save_to_disk(); + Ok(LineResult::Command(output)) + } + + pub async fn run(&self) -> io::Result<()> { + enable_raw_mode()?; + let mut stdout = stdout(); + stdout.execute(cursor::EnableBlinking)?; + stdout.execute(cursor::SetCursorStyle::BlinkingBar)?; + + let mut history = HistoryManager::new(); + + loop { + self.ahnlich_prompt(&mut stdout)?; + let input = self.read_line(&mut stdout, &mut history)?; + match input { + LineResult::Exit => { + break; + } + LineResult::Command(input) => match input.as_str() { + "quit" | "exit" | "exit()" => break, + command => { + let response = self.client_pool.parse_queries(command).await; + + match response { + Ok(success) => { + disable_raw_mode()?; + for msg in success { + queue!( + stdout, + Print(format!("{}\n", msg)), + cursor::MoveToColumn(0) + )?; + } + stdout.flush()?; + enable_raw_mode()? + } + Err(err) => { + queue!( + stdout, + Print(format!("{}\n", err.red())), + cursor::MoveToColumn(0) + )?; + stdout.flush()?; + } + } + } + }, }; } + disable_raw_mode()?; Ok(()) } } diff --git a/ahnlich/dsl/src/ai.rs b/ahnlich/dsl/src/ai.rs index 52416675..7bc21ce2 100644 --- a/ahnlich/dsl/src/ai.rs +++ b/ahnlich/dsl/src/ai.rs @@ -42,22 +42,24 @@ fn parse_to_ai_model(input: &str) -> Result { // Parse raw strings separated by ; into a Vec. Examples include but are not restricted // to -// -// PING -// LISTCLIENTS -// LISTSTORES -// INFOSERVER -// PURGESTORES -// DROPSTORE store_name IF EXISTS -// CREATEPREDINDEX (key_1, key_2) in store_name -// DROPPREDINDEX IF EXISTS (key1, key2) in store_name -// CREATENONLINEARALGORITHMINDEX (kdtree) in store_name -// DROPNONLINEARALGORITHMINDEX IF EXISTS (kdtree) in store_name -// DELKEY ([input 1 text], [input 2 text]) IN my_store -// GETPRED ((author = dickens) OR (country != Nigeria)) IN my_store -// GETSIMN 4 WITH [random text inserted here] USING cosinesimilarity IN my_store WHERE (author = dickens) -// CREATESTORE IF NOT EXISTS my_store QUERYMODEL dalle3 INDEXMODEL dalle3 PREDICATES (author, country) NONLINEARALGORITHMINDEX (kdtree) -// SET (([This is the life of Haks paragraphed], {name: Haks, category: dev}), ([This is the life of Deven paragraphed], {name: Deven, category: dev})) in store +pub const COMMANDS: &[&str] = &[ + "ping", + "listclients", + "liststores", + "infoserver", + "purgestores", + "dropstore", // store_name if exists can be handled dynamically + "createpredindex", // (key_1, key_2) in store_name + "droppredindex", // if exists (key1, key2) in store_name + "createnonlinearalgorithmindex", // (kdtree) in store_name + "dropnonlinearalgorithmindex", // if exists (kdtree) in store_name + "delkey", // ([input 1 text], [input 2 text]) in my_store + "getpred", // ((author = dickens) or (country != Nigeria)) in my_store + "getsimn", // 4 with [random text inserted here] using cosinesimilarity in my_store where (author = dickens) + "createstore", // if not exists my_store querymodel dalle3 indexmodel dalle3 predicates (author, country) nonlinearalgorithmindex (kdtree) + "set", // (([This is the life of Haks paragraphed], {name: Haks, category: dev}), ([This is the life of Deven paragraphed], {name: Deven, category: dev})) in store +]; + pub fn parse_ai_query(input: &str) -> Result, DslError> { let pairs = QueryParser::parse(Rule::ai_query, input).map_err(Box::new)?; let statements = pairs.into_iter().collect::>(); diff --git a/ahnlich/dsl/src/db.rs b/ahnlich/dsl/src/db.rs index 9b14f41b..b30a9988 100644 --- a/ahnlich/dsl/src/db.rs +++ b/ahnlich/dsl/src/db.rs @@ -17,22 +17,24 @@ use crate::{error::DslError, predicate::parse_predicate_expression}; // Parse raw strings separated by ; into a Vec. Examples include but are not restricted // to -// -// PING -// LISTCLIENTS -// LISTSTORES -// INFOSERVER -// DROPSTORE store_name IF EXISTS -// CREATEPREDINDEX (key_1, key_2) in store_name -// DROPPREDINDEX IF EXISTS (key1, key2) in store_name -// CREATENONLINEARALGORITHMINDEX (kdtree) in store_name -// DROPNONLINEARALGORITHMINDEX IF EXISTS (kdtree) in store_name -// GETKEY ([1.0, 2.0], [3.0, 4.0]) IN my_store -// DELKEY ([1.2, 3.0], [5.6, 7.8]) IN my_store -// GETPRED ((author = dickens) OR (country != Nigeria)) IN my_store -// GETSIMN 4 WITH [0.65, 2.78] USING cosinesimilarity IN my_store WHERE (author = dickens) -// CREATESTORE IF NOT EXISTS my_store DIMENSION 21 PREDICATES (author, country) NONLINEARALGORITHMINDEX (kdtree) -// SET (([1.0, 2.1, 3.2], {name: Haks, category: dev}), ([3.1, 4.8, 5.0], {name: Deven, category: dev})) in store +pub const COMMANDS: &[&str] = &[ + "ping", + "listclients", + "liststores", + "infoserver", + "dropstore", // store_name if exists can be handled dynamically + "createpredindex", // (key_1, key_2) in store_name + "droppredindex", // if exists (key1, key2) in store_name + "createnonlinearalgorithmindex", // (kdtree) in store_name + "dropnonlinearalgorithmindex", // if exists (kdtree) in store_name + "getkey", // ([1.0, 2.0], [3.0, 4.0]) in my_store + "delkey", // ([1.2, 3.0], [5.6, 7.8]) in my_store + "getpred", // ((author = dickens) or (country != Nigeria)) in my_store + "getsimn", // 4 with [0.65, 2.78] using cosinesimilarity in my_store where (author = dickens) + "createstore", // if not exists my_store dimension 21 predicates (author, country) nonlinearalgorithmindex (kdtree) + "set", // (([1.0, 2.1, 3.2], {name: Haks, category: dev}), ([3.1, 4.8, 5.0], {name: Deven, category: dev})) in store +]; + pub fn parse_db_query(input: &str) -> Result, DslError> { let pairs = QueryParser::parse(Rule::db_query, input).map_err(Box::new)?; let statements = pairs.into_iter().collect::>(); diff --git a/ahnlich/dsl/src/syntax/syntax.pest b/ahnlich/dsl/src/syntax/syntax.pest index 6b8c3e96..96d7e113 100644 --- a/ahnlich/dsl/src/syntax/syntax.pest +++ b/ahnlich/dsl/src/syntax/syntax.pest @@ -40,12 +40,12 @@ db_statement = _{ invalid_statement } -ping = { whitespace* ~ ^"ping" ~ whitespace* } -info_server = { whitespace* ~ ^"infoserver" ~ whitespace* } -list_stores = { whitespace* ~ ^"liststores" ~ whitespace* } -list_clients = { whitespace* ~ ^"listclients" ~ whitespace* } -purge_stores = { whitespace* ~ ^"purgestores" ~ whitespace* } -drop_store = { whitespace* ~ ^"dropstore" ~ whitespace* ~ store_name ~ (if_exists | invalid_statement)? } +ping = { whitespace* ~ ^"ping" ~ whitespace* ~ !(ASCII_ALPHANUMERIC) } +info_server = { whitespace* ~ ^"infoserver" ~ whitespace* ~ !(ASCII_ALPHANUMERIC)} +list_stores = { whitespace* ~ ^"liststores" ~ whitespace* ~ !(ASCII_ALPHANUMERIC)} +list_clients = { whitespace* ~ ^"listclients" ~ whitespace* ~ !(ASCII_ALPHANUMERIC)} +purge_stores = { whitespace* ~ ^"purgestores" ~ whitespace* ~ !(ASCII_ALPHANUMERIC)} +drop_store = { whitespace* ~ ^"dropstore" ~ whitespace* ~ store_name ~ (if_exists | invalid_statement)?} create_pred_index = { whitespace* ~ ^"createpredindex" ~ whitespace* ~ "(" ~ index_names ~ ")" ~ in_ignored ~ store_name } create_non_linear_algorithm_index = { whitespace* ~ ^"createnonlinearalgorithmindex" ~ whitespace* ~ "(" ~ non_linear_algorithms ~ ")" ~ in_ignored ~ store_name} drop_pred_index = { whitespace* ~ ^"droppredindex" ~ whitespace* ~ (if_exists)? ~ "(" ~ index_names ~ ")" ~ in_ignored ~ store_name }