Lines
15.28 %
Functions
2.88 %
Branches
100 %
use log::{info, trace, warn};
use sqlx::types::Uuid;
use crate::run::{CommandError, CommandNode};
use crossterm::event::{self, Event, KeyCode, KeyEvent};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Position},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
};
use server::command::{Argument, CmdResult, FinanceEntity};
use std::{
collections::HashMap,
error::Error,
io,
time::{Duration, Instant},
use tokio::runtime::Handle;
use tokio::sync::mpsc;
use tokio::task::block_in_place;
extern crate sm;
use sm::sm;
// Simplified state machine with clear states for each mode
sm! {
CommandCompletion {
InitialStates { Start }
// Transitions for command path completion
BeginCommandInput {
Start => CommandInput
}
CycleCommands {
CommandInput => CommandInput
// Transitions for argument completion
BeginArgumentInput {
CommandInput => ArgumentInput
ReturnToCommandInput {
ArgumentInput => CommandInput
CycleArguments {
ArgumentInput => ArgumentInput
BeginParamInput {
ArgumentInput => ParamInput
CycleParams {
ParamInput => ParamInput
CompleteParam {
ParamInput => ArgumentInput
ReturnToArgumentInput {
// Reset to start
Complete {
CommandInput, ArgumentInput => Start
use CommandCompletion::{
BeginArgumentInput, BeginCommandInput, BeginParamInput, CompleteParam, CycleCommands,
CycleParams, ReturnToArgumentInput, ReturnToCommandInput, Start,
Variant::{
ArgumentInputByBeginArgumentInput, ArgumentInputByCompleteParam,
ArgumentInputByCycleArguments, ArgumentInputByReturnToArgumentInput,
CommandInputByBeginCommandInput, CommandInputByCycleCommands,
CommandInputByReturnToCommandInput, InitialStart, ParamInputByBeginParamInput,
ParamInputByCycleParams,
},
pub struct App {
input: String,
suggested_input: String,
suggestions: CmdResult,
parameter_ids: Vec<Uuid>,
comments: Vec<String>,
selected_idx: usize,
last_tick: Instant,
log_receiver: mpsc::Receiver<String>,
log_buffer: Vec<String>,
userid: Uuid,
impl App {
async fn current_path(&self) -> Vec<String> {
self.suggested_input
.trim_start_matches('/')
.split(' ')
.next()
.unwrap()
.split('/')
.filter(|s| !s.is_empty())
.take_while(|s| !s.contains(' '))
.map(String::from)
.collect()
pub fn new(log_receiver: mpsc::Receiver<String>, userid: Uuid) -> Self {
Self {
input: "/".to_string(),
suggested_input: "/".to_string(),
suggestions: CmdResult::Lines(vec![]),
parameter_ids: Vec::new(),
comments: Vec::new(),
selected_idx: 0,
last_tick: Instant::now(),
log_receiver,
log_buffer: Vec::new(),
userid,
pub async fn update(&mut self) {
// Process messages until channel is empty
while let Ok(message) = self.log_receiver.try_recv() {
self.log_buffer.push(message);
if self.log_buffer.len() > 1000 {
self.log_buffer.remove(0);
async fn suggest_command(&mut self) {
if self.suggestions.as_lines().is_empty() {
return;
let selected = &self.suggestions.as_lines_mut()[self.selected_idx];
// Handle command completion
if let Some(last_slash) = self.input.rfind('/') {
let prefix = &self.input[..=last_slash];
let current = &self.input[last_slash + 1..];
trace!(
"Command completion - prefix: '{prefix}', current: '{current}', selected: '{selected}'"
);
if selected.starts_with(current) {
self.suggested_input = format!("{prefix}{selected}");
trace!("Updated suggested input to: '{}'", self.suggested_input);
async fn suggest_argument(&mut self) {
if let Some(last_space) = self.input.rfind(' ') {
let prefix = &self.input[..=last_space];
let current = &self.input[last_space + 1..];
"Argument completion - prefix: '{prefix}', current: '{current}', selected: '{selected}'"
self.suggested_input = format!("{prefix}{selected}=");
async fn suggest_parameter(&mut self) {
trace!("Suggesting parameters");
// Extract the argument name and position
let (_, last_space) = match (self.input.split_whitespace().last(), self.input.rfind(' ')) {
(Some(arg), Some(space)) => match arg.split('=').next() {
Some(name) => (name, space),
None => return,
_ => return,
// Find the equals sign position
let eq_pos = match self.input[last_space..].find('=') {
Some(eq) => eq,
// Get the ID for the selected suggestion
let id = match self.parameter_ids.get(self.selected_idx) {
Some(id) => id,
// Build the suggested input
let prefix = &self.input[..last_space];
let arg_prefix = &self.input[last_space..=(last_space + eq_pos)];
let new_suggestion = format!("{prefix}{arg_prefix}\"{id}");
// Only update suggestion if it matches the input up to input's length
if !self
.input
.starts_with(&new_suggestion[..self.input.len().min(new_suggestion.len())])
{
self.suggested_input = self.input.clone();
self.suggested_input = new_suggestion;
async fn handle_tab(&mut self, commands: &[CommandNode], state: &CommandCompletion::Variant) {
trace!("handle_tab called with state: {state:?}");
trace!("suggestions: {:?}", self.suggestions);
if self.input.is_empty() {
self.input = "/".to_string();
self.suggested_input = "/".to_string();
// Don't update suggestions here - they should already be current
// If no suggestions, update them once
self.update_suggestions(commands).await;
trace!("No suggestions available, returning");
// Save current suggestions and index before any updates
let current_suggestions = self.suggestions.as_lines().clone();
let prev_idx = self.selected_idx;
self.selected_idx = (self.selected_idx + 1) % current_suggestions.len();
let selected = ¤t_suggestions[self.selected_idx];
"Tab cycling from idx {} to {}, selected: {}",
prev_idx, self.selected_idx, selected
match state {
CommandCompletion::Variant::CommandInputByBeginCommandInput(_)
| CommandCompletion::Variant::CommandInputByCycleCommands(_)
| CommandCompletion::Variant::CommandInputByReturnToCommandInput(_) => {
self.suggest_command().await;
CommandCompletion::Variant::ArgumentInputByBeginArgumentInput(_)
| CommandCompletion::Variant::ArgumentInputByReturnToArgumentInput(_)
| CommandCompletion::Variant::ArgumentInputByCompleteParam(_)
| CommandCompletion::Variant::ArgumentInputByCycleArguments(_) => {
self.suggest_argument().await;
CommandCompletion::Variant::ParamInputByBeginParamInput(_)
| CommandCompletion::Variant::ParamInputByCycleParams(_) => {
self.suggest_parameter().await;
_ => {}
// Restore suggestions and keep the cycled index
*self.suggestions.as_lines_mut() = current_suggestions;
async fn update_suggestions(&mut self, commands: &[CommandNode]) {
trace!("Updating suggestions for input: '{}'", self.input);
self.suggestions.as_lines_mut().clear();
self.parameter_ids.clear();
self.comments.clear();
self.selected_idx = 0;
// Don't provide suggestions for invalid inputs
if !self.input.starts_with('/') {
// Check if we're completing a parameter value
if let Some(last_arg) = self.input.split_whitespace().last()
&& last_arg.contains('=')
&& last_arg.matches('"').count() < 2
let current_path = self.current_path().await;
if let Some(cmd) = find_command(
commands,
¤t_path
.iter()
.map(String::as_str)
.collect::<Vec<&str>>(),
) && let Some(arg_name) = last_arg.split('=').next()
&& let Some(arg_def) = cmd.arguments.iter().find(|a| a.name == arg_name)
&& let Some(completion) = &arg_def.completions
&& let Ok(Some(CmdResult::TaggedEntities(entities))) = completion
.run(&HashMap::from([("user_id", &Argument::Uuid(self.userid))]))
.await
trace!("Entities! {entities:?}");
for (entity, tags) in entities {
match entity {
FinanceEntity::Commodity(c) => {
if let (
Some(FinanceEntity::Tag(symbol)),
Some(FinanceEntity::Tag(name)),
) = (tags.get("symbol"), tags.get("name"))
self.parameter_ids.push(c.id);
self.suggestions
.as_lines_mut()
.push(format!("{} - {}", symbol.tag_value, name.tag_value));
FinanceEntity::Account(a) => {
if let Some(FinanceEntity::Tag(name)) = tags.get("name") {
self.parameter_ids.push(a.id);
.push(name.tag_value.to_string());
_ => continue,
if self.input.contains(' ') {
// In argument mode
) {
let last_part = self.input.split_whitespace().last().unwrap_or("");
*self.suggestions.as_lines_mut() = if last_part.contains('/') {
// Show all arguments when no partial input
cmd.arguments
.map(|a| {
self.comments.push(a.comment.clone());
a.name.clone()
})
} else {
let completed_args: Vec<&str> = self
.split_whitespace()
.filter(|arg| arg.contains('='))
.map(|arg| arg.split('=').next().unwrap_or(arg))
.collect();
trace!("Completed arguments: {completed_args:?} for cmd {cmd:?}");
// Filter remaining arguments based on partial input
trace!("Filtering with input {last_part:?}");
.filter(|arg| !completed_args.contains(&arg.name.as_str()))
.filter(|arg| {
if self.input.ends_with(' ') {
true
arg.name.starts_with(last_part)
.map(|arg| {
self.comments.push(arg.comment.clone());
arg.name.to_string()
let path: Vec<&str> = self
trace!("Path: {path:?}");
// Get suggestions based on the current path
let (current_level, prefix) = if path.is_empty() {
// At root level, show all commands
(commands, "")
// For nested paths, use recursive search
let (parent_path, current_segment) = path.split_at(path.len() - 1);
trace!("Parent path: {parent_path:?}, current segment: {current_segment:?}");
// Include current_segment in the search path
let mut full_path = parent_path.to_vec();
full_path.push(current_segment[0]);
trace!("Full search path: {full_path:?}");
if let Some(cmd) = find_command(commands, &full_path) {
trace!("Found command: {}", cmd.name);
if self.input.ends_with('/') {
// At command boundary (after /), show this command's subcommands
(&cmd.subcommands[..], "")
// In the middle of typing, show parent's subcommands filtered by current input
if let Some(parent) = find_command(commands, parent_path) {
(&parent.subcommands[..], current_segment[0])
(commands, current_segment[0])
trace!("No valid command path found");
trace!("Current level: {current_level:?} {prefix:?}");
// Filter suggestions based on the current prefix
*self.suggestions.as_lines_mut() = current_level
.filter(|c| c.name.starts_with(prefix))
.map(|c| {
self.comments.push(c.comment.clone());
c.name.clone()
"Current level suggestions for prefix '{}': {:?}",
prefix, self.suggestions
trace!("Updated suggestions: {:?}", self.suggestions);
pub async fn complete_current_command(&mut self, commands: &[CommandNode]) -> bool {
self.input = self.suggested_input.clone();
let path_refs = self.current_path().await;
if let Some(current_cmd) = find_command(
&path_refs.iter().map(String::as_str).collect::<Vec<&str>>(),
if current_cmd.subcommands.is_empty() {
trace!("There are no subcommands, pushing space");
self.input.push(' ');
trace!("There are subcommands, pushing /");
if !self.input.ends_with('/') {
self.input.push('/');
false
pub async fn complete_current_argument(&mut self) -> bool {
trace!("Completing the argument");
pub async fn complete_current_parameter(&mut self) -> bool {
trace!("Completing the parameter");
self.suggested_input.push('"');
self.suggested_input.push(' ');
fn find_command<'a>(commands: &'a [CommandNode], path: &[&str]) -> Option<&'a CommandNode> {
if path.is_empty() {
return None;
let first_cmd = commands.iter().find(|cmd| cmd.name.starts_with(path[0]));
match (first_cmd, path.len()) {
(Some(cmd), 1) => Some(cmd),
(Some(cmd), _) => find_command(&cmd.subcommands, &path[1..]),
(None, _) => None,
pub async fn execute<'input, 'cmd: 'input>(
commands: &'cmd [CommandNode],
input: &'input str,
args_map: &'input HashMap<&'input str, &'input Argument>, // Accept parsed arguments from the caller
) -> Result<Option<CmdResult>, CommandError> {
let cmd: Vec<&str> = input
trace!("Input: {cmd:?}");
if let Some(cmd) = find_command(commands, &cmd) {
if let Some(cmd) = &cmd.command {
cmd.run(args_map).await // Pass the parsed arguments directly
Err(CommandError::Command(format!("{cmd:?} is not runnable")))
Err(CommandError::Command(format!("{cmd:?}")))
fn parse_arguments(input: &str, args: &mut HashMap<String, Argument>) {
let mut chars = input.chars().peekable();
let mut current_arg = String::new();
let mut in_quotes = false;
let mut escaped = false;
// Skip the command part (everything before first space)
for c in chars.by_ref() {
if c == ' ' {
break;
while let Some(&c) = chars.peek() {
match (c, escaped, in_quotes) {
('\\', false, true) => {
escaped = true;
chars.next();
('"', false, _) => {
in_quotes = !in_quotes;
let quote = chars.next().unwrap();
current_arg.push(quote);
(' ', false, false) => {
if !current_arg.is_empty() {
if let Some((key, value)) = parse_single_argument(¤t_arg) {
args.insert(key, value);
current_arg.clear();
(c, true, _) => {
current_arg.push(c);
escaped = false;
(c, false, _) => {
// Handle the last argument
if !current_arg.is_empty()
&& let Some((key, value)) = parse_single_argument(¤t_arg)
fn parse_single_argument(arg: &str) -> Option<(String, Argument)> {
let mut parts = arg.splitn(2, '=');
match (parts.next(), parts.next()) {
(Some(key), Some(value)) => {
let clean_value = value.trim_matches('"');
// Unescape any escaped quotes in the value
let unescaped_value = clean_value.replace("\\\"", "\"");
let arg = unescaped_value
.parse::<i64>()
.ok()
.filter(|_| unescaped_value.chars().all(|c| c.is_ascii_digit()))
.map_or_else(
|| Argument::String(unescaped_value),
|n| Argument::Rational(n.into()),
let uuid_arg = if let Argument::String(uuid_str) = &arg {
if let Ok(code) = Uuid::parse_str(uuid_str) {
Argument::Uuid(code)
arg
Some((key.to_string(), uuid_arg))
_ => None,
async fn ui(f: &mut Frame<'_>, app: &App) {
// Draw main UI
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(8),
Constraint::Min(0),
])
.split(f.area());
// Input widget with colored suggestion
let input_len = app.input.len();
let suggestion_text = if app.suggested_input.len() > input_len {
let (base, suggestion) = app.suggested_input.split_at(input_len);
Line::from(vec![
Span::raw(base),
Span::styled(suggestion, Style::default().fg(Color::DarkGray)),
Line::from(app.suggested_input.as_str())
let input = Paragraph::new(suggestion_text)
.block(Block::default().borders(Borders::ALL).title("Input"));
f.render_widget(input, chunks[0]);
// Set cursor
let input_area = chunks[0]; // The area of the input widget
let cursor_position: u16 = app.input.len().try_into().unwrap();
let x = input_area.x + cursor_position + 1; // Offset by border
let y = input_area.y + 1; // Offset by border
f.set_cursor_position(Position { x, y });
// Suggestions widget
let items: Vec<ListItem> = app
.suggestions
.as_lines()
.enumerate()
.map(|(i, s)| {
let style = if i == app.selected_idx {
Style::default().fg(Color::Yellow)
Style::default()
// Create spans for suggestion and comment
let mut spans = vec![Span::styled(s.clone(), style)];
// Add comment if available
if let Some(comment) = app.comments.get(i) {
spans.extend_from_slice(&[Span::raw(" - "), Span::styled(comment.clone(), style)]);
ListItem::new(Line::from(spans))
let suggestions_title = if app.input.contains(' ') {
"Suggestions (press = to complete)"
"Suggestions (press / to complete)"
let suggestions = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title(suggestions_title),
f.render_widget(suggestions, chunks[1]);
// Log widget
let log_area = chunks[2];
let width = log_area.width as usize;
let height = log_area.height as usize;
let max_lines = height.saturating_sub(2); // Subtract 2 for borders
// Wrap and split long lines into multiple lines
let mut wrapped_lines: Vec<Line> = Vec::new();
for log_line in &app.log_buffer {
// Determine style for the entire line first
let style = if log_line.contains("ERROR") {
Style::default().fg(Color::Red)
} else if log_line.contains("WARN") {
} else if log_line.contains("INFO") {
Style::default().fg(Color::LightGreen)
} else if log_line.contains("DEBUG") {
Style::default().fg(Color::Gray)
} else if log_line.contains("TRACE") {
Style::default().fg(Color::DarkGray)
let mut remaining = log_line.as_str();
while !remaining.is_empty() {
let (line, rest) = if remaining.len() > width.saturating_sub(2) {
// Find a good break point
let slice = &remaining[..width.saturating_sub(2)];
match slice.rfind(char::is_whitespace) {
Some(pos) if pos > 0 => {
let (l, r) = remaining.split_at(pos);
(l.trim(), r.trim())
_ => remaining.split_at(width.saturating_sub(2)),
(remaining, "")
// Apply the same style to each wrapped line
wrapped_lines.push(Line::from(vec![Span::styled(line, style)]));
remaining = rest;
// Take only as many wrapped lines as can fit
let visible_lines = if wrapped_lines.len() > max_lines {
&wrapped_lines[wrapped_lines.len() - max_lines..]
&wrapped_lines[..]
let log = Paragraph::new(visible_lines.to_vec())
.block(Block::default().borders(Borders::ALL).title("Log"))
.wrap(Wrap { trim: true });
f.render_widget(log, chunks[2]);
pub async fn run_app(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
commands: &[CommandNode],
) -> Result<(), Box<dyn Error>> {
let tick_rate = Duration::from_millis(200);
let mut state = CommandCompletion::Machine::new(Start)
.transition(BeginCommandInput)
.as_enum();
// Initialize suggestions
app.update_suggestions(commands).await;
app.selected_idx = app.suggestions.as_lines().len() - 1;
app.handle_tab(commands, &state).await;
loop {
app.update().await;
terminal.draw(|f| block_in_place(|| Handle::current().block_on(ui(f, app))))?;
let timeout = tick_rate
.checked_sub(app.last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if event::poll(timeout)?
&& let Event::Key(KeyEvent { code, .. }) = event::read()?
trace!("Received key event: {code:?}");
match code {
KeyCode::Char('/') => {
if app.input.ends_with('/') && app.suggested_input.ends_with('/') {
continue;
if app.input.is_empty() {
state = match state {
InitialStart(s) => {
trace!("Transitioned to CommandInput state");
s.transition(BeginCommandInput).as_enum()
_ => state,
ParamInputByBeginParamInput(_) | ParamInputByCycleParams(_) => {
app.input.push('/');
app.suggested_input = app.input.clone();
_ => {
if !app.suggested_input.is_empty() {
let path_refs = app.current_path().await;
&path_refs
) && !cmd.subcommands.is_empty()
app.input = app.suggested_input.clone();
if !app.suggestions.as_lines().is_empty() {
KeyCode::Char('=') => {
ArgumentInputByBeginArgumentInput(s) => {
app.complete_current_parameter().await;
s.transition(BeginParamInput).as_enum()
ArgumentInputByCompleteParam(s) => {
ArgumentInputByCycleArguments(s) => {
ArgumentInputByReturnToArgumentInput(s) => {
KeyCode::Char('"') => {
ParamInputByBeginParamInput(s) => {
trace!("Starting param");
app.input.push('"');
s.transition(CycleParams).as_enum()
ParamInputByCycleParams(s) => {
trace!("Completing param");
app.input.push(' ');
app.suggest_argument().await;
s.transition(CompleteParam).as_enum()
KeyCode::Char(' ') => {
if !app.input.ends_with(' ') && !app.input.is_empty() {
// First check the path and handle basic input updates
let current_path = app.current_path().await;
let cmd_opt = find_command(
trace!("cmd_opt: {cmd_opt:?}");
// Handle app updates before state transitions
match cmd_opt {
Some(cmd) if cmd.subcommands.is_empty() => {
Some(_) => {
None => {
// Now handle state transitions by consuming the state
state = match (state, cmd_opt) {
(CommandInputByBeginCommandInput(s), Some(cmd)) => {
if cmd.subcommands.is_empty() {
trace!("BeginArgumentInput");
s.transition(BeginArgumentInput).as_enum()
trace!("CycleCommands");
s.transition(CycleCommands).as_enum()
(CommandInputByCycleCommands(s), Some(cmd)) => {
(CommandInputByReturnToCommandInput(s), _) => {
(ArgumentInputByBeginArgumentInput(s), _) => {
trace!("Accepting Argument");
(s, _) => {
warn!("State: {s:?}");
s
KeyCode::Char(c) => {
app.input.push(c);
KeyCode::Tab => {
KeyCode::Backspace => {
state = CommandCompletion::Machine::new(Start)
let ends_with_equals = app.input.ends_with('=');
let ends_with_quote = app.input.ends_with('"');
let contains_space = app.input.contains(' ');
app.input.pop();
state = match (state, ends_with_equals, ends_with_quote, contains_space) {
(ParamInputByBeginParamInput(s), true, _, _) => {
s.transition(ReturnToArgumentInput).as_enum()
(ParamInputByCycleParams(s), true, _, _) => {
(ArgumentInputByCompleteParam(s), _, true, _) => {
(ArgumentInputByBeginArgumentInput(s), _, _, false) => {
s.transition(ReturnToCommandInput).as_enum()
(ArgumentInputByCycleArguments(s), _, _, false) => {
(ArgumentInputByCompleteParam(s), _, _, false) => {
(s, _, _, _) => {
app.handle_tab(commands, &s).await;
KeyCode::Enter => {
if app.input == app.suggested_input {
let mut args_map = HashMap::new();
parse_arguments(&app.input, &mut args_map);
args_map.insert("user_id".to_string(), Argument::Uuid(app.userid));
let args_ref: HashMap<&str, &Argument> =
args_map.iter().map(|(k, v)| (k.as_ref(), v)).collect();
match execute(commands, &app.input, &args_ref).await {
Ok(res) => {
if let Some(res) = res {
info!("{res}");
info!("Successfully completed");
Err(e) => warn!("Can't execute the command: {e}"),
app.input = "/".to_string();
app.suggested_input.clear();
CommandInputByBeginCommandInput(s) => {
if app.complete_current_command(commands).await {
CommandInputByReturnToCommandInput(s) => {
CommandInputByCycleCommands(s) => {
app.complete_current_argument().await;
KeyCode::Esc => return Ok(()),
if app.last_tick.elapsed() >= tick_rate {
app.last_tick = Instant::now();
#[cfg(test)]
mod tests {
use super::*;
use server::command::Argument;
#[test]
fn test_parse_arguments_empty() {
let input = "/command";
let mut args = HashMap::new();
parse_arguments(input, &mut args);
assert!(args.is_empty());
fn test_parse_arguments_single() {
let input = "/command arg=value";
assert_eq!(args.len(), 1);
assert!(matches!(args.get("arg"),
Some(Argument::String(s)) if s == "value"));
fn test_parse_arguments_multiple() {
let input = "/command arg1=value1 arg2=value2";
assert_eq!(args.len(), 2);
assert!(matches!(args.get("arg1"),
Some(Argument::String(s)) if s == "value1"));
assert!(matches!(args.get("arg2"),
Some(Argument::String(s)) if s == "value2"));
fn test_parse_arguments_with_spaces() {
let input = r#"/command arg1="value with spaces" arg2="another spaced value""#;
Some(Argument::String(s)) if s == "value with spaces"));
Some(Argument::String(s)) if s == "another spaced value"));
fn test_parse_arguments_mixed_types() {
let input = r#"/command num=42 text="hello world" flag=123"#;
assert_eq!(args.len(), 3);
assert!(matches!(args.get("num"),
Some(Argument::Rational(n)) if n == &42.into()));
assert!(matches!(args.get("text"),
Some(Argument::String(s)) if s == "hello world"));
assert!(matches!(args.get("flag"),
Some(Argument::Rational(n)) if n == &123.into()));
fn test_parse_arguments_with_nested_quotes() {
let input = r#"/command arg="value \"quoted\" here""#;
Some(Argument::String(s)) if s == r#"value "quoted" here"#));