1
use log::{info, trace, warn};
2
use sqlx::types::Uuid;
3

            
4
use crate::run::{CommandError, CommandNode};
5
use crossterm::event::{self, Event, KeyCode, KeyEvent};
6
use ratatui::{
7
    Frame, Terminal,
8
    backend::CrosstermBackend,
9
    layout::{Constraint, Direction, Layout, Position},
10
    style::{Color, Style},
11
    text::{Line, Span},
12
    widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
13
};
14
use server::command::{Argument, CmdResult, FinanceEntity};
15
use std::{
16
    collections::HashMap,
17
    error::Error,
18
    io,
19
    time::{Duration, Instant},
20
};
21
use tokio::runtime::Handle;
22
use tokio::sync::mpsc;
23
use tokio::task::block_in_place;
24

            
25
extern crate sm;
26
use sm::sm;
27

            
28
// Simplified state machine with clear states for each mode
29
sm! {
30
    CommandCompletion {
31
        InitialStates { Start }
32

            
33
        // Transitions for command path completion
34
        BeginCommandInput {
35
            Start => CommandInput
36
        }
37

            
38
        CycleCommands {
39
            CommandInput => CommandInput
40
        }
41

            
42
        // Transitions for argument completion
43
        BeginArgumentInput {
44
            CommandInput => ArgumentInput
45
        }
46

            
47
        ReturnToCommandInput {
48
            ArgumentInput => CommandInput
49
        }
50

            
51
        CycleArguments {
52
            ArgumentInput => ArgumentInput
53
        }
54

            
55
    BeginParamInput {
56
            ArgumentInput => ParamInput
57
    }
58

            
59
    CycleParams {
60
            ParamInput => ParamInput
61
    }
62

            
63
    CompleteParam {
64
            ParamInput => ArgumentInput
65
    }
66

            
67
    ReturnToArgumentInput {
68
            ParamInput => ArgumentInput
69
    }
70

            
71
        // Reset to start
72
        Complete {
73
            CommandInput, ArgumentInput => Start
74
        }
75
    }
76
}
77

            
78
use CommandCompletion::{
79
    BeginArgumentInput, BeginCommandInput, BeginParamInput, CompleteParam, CycleCommands,
80
    CycleParams, ReturnToArgumentInput, ReturnToCommandInput, Start,
81
    Variant::{
82
        ArgumentInputByBeginArgumentInput, ArgumentInputByCompleteParam,
83
        ArgumentInputByCycleArguments, ArgumentInputByReturnToArgumentInput,
84
        CommandInputByBeginCommandInput, CommandInputByCycleCommands,
85
        CommandInputByReturnToCommandInput, InitialStart, ParamInputByBeginParamInput,
86
        ParamInputByCycleParams,
87
    },
88
};
89

            
90
pub struct App {
91
    input: String,
92
    suggested_input: String,
93
    suggestions: CmdResult,
94
    parameter_ids: Vec<Uuid>,
95
    comments: Vec<String>,
96
    selected_idx: usize,
97
    last_tick: Instant,
98
    log_receiver: mpsc::Receiver<String>,
99
    log_buffer: Vec<String>,
100
    userid: Uuid,
101
}
102

            
103
impl App {
104
    async fn current_path(&self) -> Vec<String> {
105
        self.suggested_input
106
            .trim_start_matches('/')
107
            .split(' ')
108
            .next()
109
            .unwrap()
110
            .split('/')
111
            .filter(|s| !s.is_empty())
112
            .take_while(|s| !s.contains(' '))
113
            .map(String::from)
114
            .collect()
115
    }
116

            
117
    pub fn new(log_receiver: mpsc::Receiver<String>, userid: Uuid) -> Self {
118
        Self {
119
            input: "/".to_string(),
120
            suggested_input: "/".to_string(),
121
            suggestions: CmdResult::Lines(vec![]),
122
            parameter_ids: Vec::new(),
123
            comments: Vec::new(),
124
            selected_idx: 0,
125
            last_tick: Instant::now(),
126
            log_receiver,
127
            log_buffer: Vec::new(),
128
            userid,
129
        }
130
    }
131

            
132
    pub async fn update(&mut self) {
133
        // Process messages until channel is empty
134

            
135
        while let Ok(message) = self.log_receiver.try_recv() {
136
            self.log_buffer.push(message);
137
            if self.log_buffer.len() > 1000 {
138
                self.log_buffer.remove(0);
139
            }
140
        }
141
    }
142

            
143
    async fn suggest_command(&mut self) {
144
        if self.suggestions.as_lines().is_empty() {
145
            return;
146
        }
147
        let selected = &self.suggestions.as_lines_mut()[self.selected_idx];
148
        // Handle command completion
149
        if let Some(last_slash) = self.input.rfind('/') {
150
            let prefix = &self.input[..=last_slash];
151
            let current = &self.input[last_slash + 1..];
152
            trace!(
153
                "Command completion - prefix: '{prefix}', current: '{current}', selected: '{selected}'"
154
            );
155
            if selected.starts_with(current) {
156
                self.suggested_input = format!("{prefix}{selected}");
157
                trace!("Updated suggested input to: '{}'", self.suggested_input);
158
            }
159
        }
160
    }
161

            
162
    async fn suggest_argument(&mut self) {
163
        if self.suggestions.as_lines().is_empty() {
164
            return;
165
        }
166
        let selected = &self.suggestions.as_lines_mut()[self.selected_idx];
167
        if let Some(last_space) = self.input.rfind(' ') {
168
            let prefix = &self.input[..=last_space];
169
            let current = &self.input[last_space + 1..];
170
            trace!(
171
                "Argument completion - prefix: '{prefix}', current: '{current}', selected: '{selected}'"
172
            );
173
            if selected.starts_with(current) {
174
                self.suggested_input = format!("{prefix}{selected}=");
175
                trace!("Updated suggested input to: '{}'", self.suggested_input);
176
            }
177
        }
178
    }
179

            
180
    async fn suggest_parameter(&mut self) {
181
        trace!("Suggesting parameters");
182
        // Extract the argument name and position
183
        let (_, last_space) = match (self.input.split_whitespace().last(), self.input.rfind(' ')) {
184
            (Some(arg), Some(space)) => match arg.split('=').next() {
185
                Some(name) => (name, space),
186
                None => return,
187
            },
188
            _ => return,
189
        };
190

            
191
        // Find the equals sign position
192
        let eq_pos = match self.input[last_space..].find('=') {
193
            Some(eq) => eq,
194
            None => return,
195
        };
196

            
197
        // Get the ID for the selected suggestion
198
        let id = match self.parameter_ids.get(self.selected_idx) {
199
            Some(id) => id,
200
            None => return,
201
        };
202

            
203
        // Build the suggested input
204
        let prefix = &self.input[..last_space];
205
        let arg_prefix = &self.input[last_space..=(last_space + eq_pos)];
206
        let new_suggestion = format!("{prefix}{arg_prefix}\"{id}");
207

            
208
        // Only update suggestion if it matches the input up to input's length
209
        if !self
210
            .input
211
            .starts_with(&new_suggestion[..self.input.len().min(new_suggestion.len())])
212
        {
213
            self.suggested_input = self.input.clone();
214
            return;
215
        }
216
        self.suggested_input = new_suggestion;
217
    }
218

            
219
    async fn handle_tab(&mut self, commands: &[CommandNode], state: &CommandCompletion::Variant) {
220
        trace!("handle_tab called with state: {state:?}");
221
        trace!("suggestions: {:?}", self.suggestions);
222

            
223
        if self.input.is_empty() {
224
            self.input = "/".to_string();
225
            self.suggested_input = "/".to_string();
226
        }
227

            
228
        // Don't update suggestions here - they should already be current
229
        if self.suggestions.as_lines().is_empty() {
230
            // If no suggestions, update them once
231
            self.update_suggestions(commands).await;
232
            if self.suggestions.as_lines().is_empty() {
233
                trace!("No suggestions available, returning");
234
                return;
235
            }
236
        }
237

            
238
        // Save current suggestions and index before any updates
239
        let current_suggestions = self.suggestions.as_lines().clone();
240
        let prev_idx = self.selected_idx;
241
        self.selected_idx = (self.selected_idx + 1) % current_suggestions.len();
242
        let selected = &current_suggestions[self.selected_idx];
243
        trace!(
244
            "Tab cycling from idx {} to {}, selected: {}",
245
            prev_idx, self.selected_idx, selected
246
        );
247

            
248
        match state {
249
            CommandCompletion::Variant::CommandInputByBeginCommandInput(_)
250
            | CommandCompletion::Variant::CommandInputByCycleCommands(_)
251
            | CommandCompletion::Variant::CommandInputByReturnToCommandInput(_) => {
252
                self.suggest_command().await;
253
            }
254
            CommandCompletion::Variant::ArgumentInputByBeginArgumentInput(_)
255
            | CommandCompletion::Variant::ArgumentInputByReturnToArgumentInput(_)
256
            | CommandCompletion::Variant::ArgumentInputByCompleteParam(_)
257
            | CommandCompletion::Variant::ArgumentInputByCycleArguments(_) => {
258
                self.suggest_argument().await;
259
            }
260
            CommandCompletion::Variant::ParamInputByBeginParamInput(_)
261
            | CommandCompletion::Variant::ParamInputByCycleParams(_) => {
262
                self.suggest_parameter().await;
263
            }
264
            _ => {}
265
        }
266

            
267
        // Restore suggestions and keep the cycled index
268
        *self.suggestions.as_lines_mut() = current_suggestions;
269
    }
270

            
271
    async fn update_suggestions(&mut self, commands: &[CommandNode]) {
272
        trace!("Updating suggestions for input: '{}'", self.input);
273
        self.suggestions.as_lines_mut().clear();
274
        self.parameter_ids.clear();
275
        self.comments.clear();
276
        self.selected_idx = 0;
277

            
278
        // Don't provide suggestions for invalid inputs
279
        if !self.input.starts_with('/') {
280
            return;
281
        }
282

            
283
        // Check if we're completing a parameter value
284
        if let Some(last_arg) = self.input.split_whitespace().last()
285
            && last_arg.contains('=')
286
            && last_arg.matches('"').count() < 2
287
        {
288
            let current_path = self.current_path().await;
289
            if let Some(cmd) = find_command(
290
                commands,
291
                &current_path
292
                    .iter()
293
                    .map(String::as_str)
294
                    .collect::<Vec<&str>>(),
295
            ) && let Some(arg_name) = last_arg.split('=').next()
296
                && let Some(arg_def) = cmd.arguments.iter().find(|a| a.name == arg_name)
297
                && let Some(completion) = &arg_def.completions
298
                && let Ok(Some(CmdResult::TaggedEntities(entities))) = completion
299
                    .run(&HashMap::from([("user_id", &Argument::Uuid(self.userid))]))
300
                    .await
301
            {
302
                trace!("Entities! {entities:?}");
303
                for (entity, tags) in entities {
304
                    match entity {
305
                        FinanceEntity::Commodity(c) => {
306
                            if let (
307
                                Some(FinanceEntity::Tag(symbol)),
308
                                Some(FinanceEntity::Tag(name)),
309
                            ) = (tags.get("symbol"), tags.get("name"))
310
                            {
311
                                self.parameter_ids.push(c.id);
312
                                self.suggestions
313
                                    .as_lines_mut()
314
                                    .push(format!("{} - {}", symbol.tag_value, name.tag_value));
315
                            }
316
                        }
317
                        FinanceEntity::Account(a) => {
318
                            if let Some(FinanceEntity::Tag(name)) = tags.get("name") {
319
                                self.parameter_ids.push(a.id);
320
                                self.suggestions
321
                                    .as_lines_mut()
322
                                    .push(name.tag_value.to_string());
323
                            }
324
                        }
325
                        _ => continue,
326
                    }
327
                }
328
                return;
329
            }
330
        }
331

            
332
        if self.input.contains(' ') {
333
            // In argument mode
334
            let current_path = self.current_path().await;
335
            if let Some(cmd) = find_command(
336
                commands,
337
                &current_path
338
                    .iter()
339
                    .map(String::as_str)
340
                    .collect::<Vec<&str>>(),
341
            ) {
342
                let last_part = self.input.split_whitespace().last().unwrap_or("");
343
                *self.suggestions.as_lines_mut() = if last_part.contains('/') {
344
                    // Show all arguments when no partial input
345
                    cmd.arguments
346
                        .iter()
347
                        .map(|a| {
348
                            self.comments.push(a.comment.clone());
349
                            a.name.clone()
350
                        })
351
                        .collect()
352
                } else {
353
                    let completed_args: Vec<&str> = self
354
                        .input
355
                        .split_whitespace()
356
                        .filter(|arg| arg.contains('='))
357
                        .map(|arg| arg.split('=').next().unwrap_or(arg))
358
                        .collect();
359
                    trace!("Completed arguments: {completed_args:?} for cmd {cmd:?}");
360

            
361
                    // Filter remaining arguments based on partial input
362
                    trace!("Filtering with input {last_part:?}");
363
                    cmd.arguments
364
                        .iter()
365
                        .filter(|arg| !completed_args.contains(&arg.name.as_str()))
366
                        .filter(|arg| {
367
                            if self.input.ends_with(' ') {
368
                                true
369
                            } else {
370
                                arg.name.starts_with(last_part)
371
                            }
372
                        })
373
                        .map(|arg| {
374
                            self.comments.push(arg.comment.clone());
375
                            arg.name.to_string()
376
                        })
377
                        .collect()
378
                }
379
            }
380
        } else {
381
            let path: Vec<&str> = self
382
                .input
383
                .trim_start_matches('/')
384
                .split('/')
385
                .filter(|s| !s.is_empty())
386
                .collect();
387

            
388
            trace!("Path: {path:?}");
389

            
390
            // Get suggestions based on the current path
391
            let (current_level, prefix) = if path.is_empty() {
392
                // At root level, show all commands
393
                (commands, "")
394
            } else {
395
                // For nested paths, use recursive search
396
                let (parent_path, current_segment) = path.split_at(path.len() - 1);
397
                trace!("Parent path: {parent_path:?}, current segment: {current_segment:?}");
398

            
399
                // Include current_segment in the search path
400
                let mut full_path = parent_path.to_vec();
401
                full_path.push(current_segment[0]);
402
                trace!("Full search path: {full_path:?}");
403

            
404
                if let Some(cmd) = find_command(commands, &full_path) {
405
                    trace!("Found command: {}", cmd.name);
406
                    if self.input.ends_with('/') {
407
                        // At command boundary (after /), show this command's subcommands
408
                        (&cmd.subcommands[..], "")
409
                    } else {
410
                        // In the middle of typing, show parent's subcommands filtered by current input
411
                        if let Some(parent) = find_command(commands, parent_path) {
412
                            (&parent.subcommands[..], current_segment[0])
413
                        } else {
414
                            (commands, current_segment[0])
415
                        }
416
                    }
417
                } else {
418
                    trace!("No valid command path found");
419
                    return;
420
                }
421
            };
422

            
423
            trace!("Current level: {current_level:?} {prefix:?}");
424

            
425
            self.comments.clear();
426
            // Filter suggestions based on the current prefix
427
            *self.suggestions.as_lines_mut() = current_level
428
                .iter()
429
                .filter(|c| c.name.starts_with(prefix))
430
                .map(|c| {
431
                    self.comments.push(c.comment.clone());
432
                    c.name.clone()
433
                })
434
                .collect();
435

            
436
            trace!(
437
                "Current level suggestions for prefix '{}': {:?}",
438
                prefix, self.suggestions
439
            );
440
        }
441
        trace!("Updated suggestions: {:?}", self.suggestions);
442
    }
443

            
444
    pub async fn complete_current_command(&mut self, commands: &[CommandNode]) -> bool {
445
        self.input = self.suggested_input.clone();
446
        let path_refs = self.current_path().await;
447
        if let Some(current_cmd) = find_command(
448
            commands,
449
            &path_refs.iter().map(String::as_str).collect::<Vec<&str>>(),
450
        ) {
451
            if current_cmd.subcommands.is_empty() {
452
                trace!("There are no subcommands, pushing space");
453
                self.input.push(' ');
454
                self.update_suggestions(commands).await;
455
                self.suggest_argument().await;
456
                true
457
            } else {
458
                trace!("There are subcommands, pushing /");
459
                if !self.input.ends_with('/') {
460
                    self.input.push('/');
461
                    self.suggested_input = self.input.clone();
462
                }
463
                self.update_suggestions(commands).await;
464
                self.suggest_command().await;
465
                false
466
            }
467
        } else {
468
            false
469
        }
470
    }
471

            
472
    pub async fn complete_current_argument(&mut self) -> bool {
473
        trace!("Completing the argument");
474
        self.input = self.suggested_input.clone();
475
        true
476
    }
477

            
478
    pub async fn complete_current_parameter(&mut self) -> bool {
479
        trace!("Completing the parameter");
480
        self.suggested_input.push('"');
481
        self.suggested_input.push(' ');
482
        self.input = self.suggested_input.clone();
483
        true
484
    }
485
}
486

            
487
fn find_command<'a>(commands: &'a [CommandNode], path: &[&str]) -> Option<&'a CommandNode> {
488
    if path.is_empty() {
489
        return None;
490
    }
491

            
492
    let first_cmd = commands.iter().find(|cmd| cmd.name.starts_with(path[0]));
493

            
494
    match (first_cmd, path.len()) {
495
        (Some(cmd), 1) => Some(cmd),
496
        (Some(cmd), _) => find_command(&cmd.subcommands, &path[1..]),
497
        (None, _) => None,
498
    }
499
}
500

            
501
pub async fn execute<'input, 'cmd: 'input>(
502
    commands: &'cmd [CommandNode],
503
    input: &'input str,
504
    args_map: &'input HashMap<&'input str, &'input Argument>, // Accept parsed arguments from the caller
505
) -> Result<Option<CmdResult>, CommandError> {
506
    let cmd: Vec<&str> = input
507
        .trim_start_matches('/')
508
        .split(' ')
509
        .next()
510
        .unwrap()
511
        .split('/')
512
        .filter(|s| !s.is_empty())
513
        .take_while(|s| !s.contains(' '))
514
        .collect();
515
    trace!("Input: {cmd:?}");
516

            
517
    if let Some(cmd) = find_command(commands, &cmd) {
518
        if let Some(cmd) = &cmd.command {
519
            cmd.run(args_map).await // Pass the parsed arguments directly
520
        } else {
521
            Err(CommandError::Command(format!("{cmd:?} is not runnable")))
522
        }
523
    } else {
524
        Err(CommandError::Command(format!("{cmd:?}")))
525
    }
526
}
527

            
528
12
fn parse_arguments(input: &str, args: &mut HashMap<String, Argument>) {
529
12
    let mut chars = input.chars().peekable();
530
12
    let mut current_arg = String::new();
531
12
    let mut in_quotes = false;
532
12
    let mut escaped = false;
533

            
534
    // Skip the command part (everything before first space)
535
106
    for c in chars.by_ref() {
536
106
        if c == ' ' {
537
10
            break;
538
96
        }
539
    }
540

            
541
302
    while let Some(&c) = chars.peek() {
542
290
        match (c, escaped, in_quotes) {
543
4
            ('\\', false, true) => {
544
4
                escaped = true;
545
4
                chars.next();
546
4
            }
547
16
            ('"', false, _) => {
548
16
                in_quotes = !in_quotes;
549
16
                let quote = chars.next().unwrap();
550
16
                current_arg.push(quote);
551
16
            }
552
            (' ', false, false) => {
553
8
                if !current_arg.is_empty() {
554
8
                    if let Some((key, value)) = parse_single_argument(&current_arg) {
555
8
                        args.insert(key, value);
556
8
                    }
557
8
                    current_arg.clear();
558
                }
559
8
                chars.next();
560
            }
561
4
            (c, true, _) => {
562
4
                current_arg.push(c);
563
4
                escaped = false;
564
4
                chars.next();
565
4
            }
566
258
            (c, false, _) => {
567
258
                current_arg.push(c);
568
258
                chars.next();
569
258
            }
570
        }
571
    }
572

            
573
    // Handle the last argument
574
12
    if !current_arg.is_empty()
575
10
        && let Some((key, value)) = parse_single_argument(&current_arg)
576
10
    {
577
10
        args.insert(key, value);
578
10
    }
579
12
}
580

            
581
18
fn parse_single_argument(arg: &str) -> Option<(String, Argument)> {
582
18
    let mut parts = arg.splitn(2, '=');
583
18
    match (parts.next(), parts.next()) {
584
18
        (Some(key), Some(value)) => {
585
18
            let clean_value = value.trim_matches('"');
586
            // Unescape any escaped quotes in the value
587
18
            let unescaped_value = clean_value.replace("\\\"", "\"");
588
18
            let arg = unescaped_value
589
18
                .parse::<i64>()
590
18
                .ok()
591
23
                .filter(|_| unescaped_value.chars().all(|c| c.is_ascii_digit()))
592
18
                .map_or_else(
593
14
                    || Argument::String(unescaped_value),
594
4
                    |n| Argument::Rational(n.into()),
595
                );
596
18
            let uuid_arg = if let Argument::String(uuid_str) = &arg {
597
14
                if let Ok(code) = Uuid::parse_str(uuid_str) {
598
                    Argument::Uuid(code)
599
                } else {
600
14
                    arg
601
                }
602
            } else {
603
4
                arg
604
            };
605
18
            Some((key.to_string(), uuid_arg))
606
        }
607
        _ => None,
608
    }
609
18
}
610

            
611
async fn ui(f: &mut Frame<'_>, app: &App) {
612
    // Draw main UI
613
    let chunks = Layout::default()
614
        .direction(Direction::Vertical)
615
        .constraints([
616
            Constraint::Length(3),
617
            Constraint::Length(8),
618
            Constraint::Min(0),
619
        ])
620
        .split(f.area());
621

            
622
    // Input widget with colored suggestion
623
    let input_len = app.input.len();
624
    let suggestion_text = if app.suggested_input.len() > input_len {
625
        let (base, suggestion) = app.suggested_input.split_at(input_len);
626
        Line::from(vec![
627
            Span::raw(base),
628
            Span::styled(suggestion, Style::default().fg(Color::DarkGray)),
629
        ])
630
    } else {
631
        Line::from(app.suggested_input.as_str())
632
    };
633
    let input = Paragraph::new(suggestion_text)
634
        .block(Block::default().borders(Borders::ALL).title("Input"));
635
    f.render_widget(input, chunks[0]);
636

            
637
    // Set cursor
638
    let input_area = chunks[0]; // The area of the input widget
639
    let cursor_position: u16 = app.input.len().try_into().unwrap();
640
    let x = input_area.x + cursor_position + 1; // Offset by border
641
    let y = input_area.y + 1; // Offset by border
642
    f.set_cursor_position(Position { x, y });
643

            
644
    // Suggestions widget
645
    let items: Vec<ListItem> = app
646
        .suggestions
647
        .as_lines()
648
        .iter()
649
        .enumerate()
650
        .map(|(i, s)| {
651
            let style = if i == app.selected_idx {
652
                Style::default().fg(Color::Yellow)
653
            } else {
654
                Style::default()
655
            };
656

            
657
            // Create spans for suggestion and comment
658
            let mut spans = vec![Span::styled(s.clone(), style)];
659

            
660
            // Add comment if available
661
            if let Some(comment) = app.comments.get(i) {
662
                spans.extend_from_slice(&[Span::raw(" - "), Span::styled(comment.clone(), style)]);
663
            }
664

            
665
            ListItem::new(Line::from(spans))
666
        })
667
        .collect();
668

            
669
    let suggestions_title = if app.input.contains(' ') {
670
        "Suggestions (press = to complete)"
671
    } else {
672
        "Suggestions (press / to complete)"
673
    };
674
    let suggestions = List::new(items).block(
675
        Block::default()
676
            .borders(Borders::ALL)
677
            .title(suggestions_title),
678
    );
679
    f.render_widget(suggestions, chunks[1]);
680

            
681
    // Log widget
682
    let log_area = chunks[2];
683
    let width = log_area.width as usize;
684
    let height = log_area.height as usize;
685
    let max_lines = height.saturating_sub(2); // Subtract 2 for borders
686

            
687
    // Wrap and split long lines into multiple lines
688
    let mut wrapped_lines: Vec<Line> = Vec::new();
689
    for log_line in &app.log_buffer {
690
        // Determine style for the entire line first
691
        let style = if log_line.contains("ERROR") {
692
            Style::default().fg(Color::Red)
693
        } else if log_line.contains("WARN") {
694
            Style::default().fg(Color::Yellow)
695
        } else if log_line.contains("INFO") {
696
            Style::default().fg(Color::LightGreen)
697
        } else if log_line.contains("DEBUG") {
698
            Style::default().fg(Color::Gray)
699
        } else if log_line.contains("TRACE") {
700
            Style::default().fg(Color::DarkGray)
701
        } else {
702
            Style::default()
703
        };
704

            
705
        let mut remaining = log_line.as_str();
706
        while !remaining.is_empty() {
707
            let (line, rest) = if remaining.len() > width.saturating_sub(2) {
708
                // Find a good break point
709
                let slice = &remaining[..width.saturating_sub(2)];
710
                match slice.rfind(char::is_whitespace) {
711
                    Some(pos) if pos > 0 => {
712
                        let (l, r) = remaining.split_at(pos);
713
                        (l.trim(), r.trim())
714
                    }
715
                    _ => remaining.split_at(width.saturating_sub(2)),
716
                }
717
            } else {
718
                (remaining, "")
719
            };
720

            
721
            // Apply the same style to each wrapped line
722
            wrapped_lines.push(Line::from(vec![Span::styled(line, style)]));
723

            
724
            remaining = rest;
725
        }
726
    }
727

            
728
    // Take only as many wrapped lines as can fit
729
    let visible_lines = if wrapped_lines.len() > max_lines {
730
        &wrapped_lines[wrapped_lines.len() - max_lines..]
731
    } else {
732
        &wrapped_lines[..]
733
    };
734

            
735
    let log = Paragraph::new(visible_lines.to_vec())
736
        .block(Block::default().borders(Borders::ALL).title("Log"))
737
        .wrap(Wrap { trim: true });
738

            
739
    f.render_widget(log, chunks[2]);
740
}
741

            
742
pub async fn run_app(
743
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
744
    app: &mut App,
745
    commands: &[CommandNode],
746
) -> Result<(), Box<dyn Error>> {
747
    let tick_rate = Duration::from_millis(200);
748
    let mut state = CommandCompletion::Machine::new(Start)
749
        .transition(BeginCommandInput)
750
        .as_enum();
751

            
752
    // Initialize suggestions
753
    app.update_suggestions(commands).await;
754
    app.selected_idx = app.suggestions.as_lines().len() - 1;
755
    app.handle_tab(commands, &state).await;
756

            
757
    loop {
758
        app.update().await;
759
        terminal.draw(|f| block_in_place(|| Handle::current().block_on(ui(f, app))))?;
760

            
761
        let timeout = tick_rate
762
            .checked_sub(app.last_tick.elapsed())
763
            .unwrap_or_else(|| Duration::from_secs(0));
764

            
765
        if event::poll(timeout)?
766
            && let Event::Key(KeyEvent { code, .. }) = event::read()?
767
        {
768
            trace!("Received key event: {code:?}");
769

            
770
            match code {
771
                KeyCode::Char('/') => {
772
                    if app.input.ends_with('/') && app.suggested_input.ends_with('/') {
773
                        continue;
774
                    }
775
                    if app.input.is_empty() {
776
                        state = match state {
777
                            InitialStart(s) => {
778
                                trace!("Transitioned to CommandInput state");
779
                                s.transition(BeginCommandInput).as_enum()
780
                            }
781
                            _ => state,
782
                        };
783
                    } else {
784
                        match state {
785
                            ParamInputByBeginParamInput(_) | ParamInputByCycleParams(_) => {
786
                                app.input.push('/');
787
                                app.suggested_input = app.input.clone();
788
                            }
789
                            _ => {
790
                                if !app.suggested_input.is_empty() {
791
                                    let path_refs = app.current_path().await;
792
                                    if let Some(cmd) = find_command(
793
                                        commands,
794
                                        &path_refs
795
                                            .iter()
796
                                            .map(String::as_str)
797
                                            .collect::<Vec<&str>>(),
798
                                    ) && !cmd.subcommands.is_empty()
799
                                    {
800
                                        app.input = app.suggested_input.clone();
801
                                        app.input.push('/');
802
                                        app.update_suggestions(commands).await;
803
                                        app.handle_tab(commands, &state).await;
804
                                        if !app.suggestions.as_lines().is_empty() {
805
                                            app.selected_idx = app.suggestions.as_lines().len() - 1;
806
                                            app.handle_tab(commands, &state).await;
807
                                        }
808
                                    }
809
                                }
810
                            }
811
                        }
812
                    }
813
                }
814
                KeyCode::Char('=') => {
815
                    state = match state {
816
                        ArgumentInputByBeginArgumentInput(s) => {
817
                            app.complete_current_parameter().await;
818
                            s.transition(BeginParamInput).as_enum()
819
                        }
820
                        ArgumentInputByCompleteParam(s) => {
821
                            app.complete_current_parameter().await;
822
                            s.transition(BeginParamInput).as_enum()
823
                        }
824
                        ArgumentInputByCycleArguments(s) => {
825
                            app.complete_current_parameter().await;
826
                            s.transition(BeginParamInput).as_enum()
827
                        }
828
                        ArgumentInputByReturnToArgumentInput(s) => {
829
                            app.complete_current_parameter().await;
830
                            s.transition(BeginParamInput).as_enum()
831
                        }
832
                        _ => state,
833
                    };
834
                    app.update_suggestions(commands).await;
835
                }
836
                KeyCode::Char('"') => {
837
                    state = match state {
838
                        ParamInputByBeginParamInput(s) => {
839
                            trace!("Starting param");
840
                            app.input.push('"');
841
                            app.suggested_input = app.input.clone();
842
                            app.update_suggestions(commands).await;
843
                            s.transition(CycleParams).as_enum()
844
                        }
845
                        ParamInputByCycleParams(s) => {
846
                            trace!("Completing param");
847
                            app.input.push('"');
848
                            app.input.push(' ');
849
                            app.suggested_input = app.input.clone();
850
                            app.update_suggestions(commands).await;
851
                            app.suggest_argument().await;
852
                            s.transition(CompleteParam).as_enum()
853
                        }
854
                        _ => state,
855
                    };
856
                }
857
                KeyCode::Char(' ') => {
858
                    if !app.input.ends_with(' ') && !app.input.is_empty() {
859
                        if !app.suggested_input.is_empty() {
860
                            app.input = app.suggested_input.clone();
861
                        }
862

            
863
                        // First check the path and handle basic input updates
864
                        let current_path = app.current_path().await;
865
                        let cmd_opt = find_command(
866
                            commands,
867
                            &current_path
868
                                .iter()
869
                                .map(String::as_str)
870
                                .collect::<Vec<&str>>(),
871
                        );
872
                        trace!("cmd_opt: {cmd_opt:?}");
873
                        // Handle app updates before state transitions
874
                        match cmd_opt {
875
                            Some(cmd) if cmd.subcommands.is_empty() => {
876
                                app.input.push(' ');
877
                            }
878
                            Some(_) => {
879
                                app.input.push('/');
880
                            }
881
                            None => {
882
                                app.input.push(' ');
883
                            }
884
                        }
885

            
886
                        // Now handle state transitions by consuming the state
887
                        state = match (state, cmd_opt) {
888
                            (CommandInputByBeginCommandInput(s), Some(cmd)) => {
889
                                if cmd.subcommands.is_empty() {
890
                                    trace!("BeginArgumentInput");
891
                                    s.transition(BeginArgumentInput).as_enum()
892
                                } else {
893
                                    trace!("CycleCommands");
894
                                    s.transition(CycleCommands).as_enum()
895
                                }
896
                            }
897
                            (CommandInputByCycleCommands(s), Some(cmd)) => {
898
                                if cmd.subcommands.is_empty() {
899
                                    trace!("BeginArgumentInput");
900
                                    s.transition(BeginArgumentInput).as_enum()
901
                                } else {
902
                                    trace!("CycleCommands");
903
                                    s.transition(CycleCommands).as_enum()
904
                                }
905
                            }
906
                            (CommandInputByReturnToCommandInput(s), _) => {
907
                                trace!("BeginArgumentInput");
908
                                s.transition(BeginArgumentInput).as_enum()
909
                            }
910
                            (ArgumentInputByBeginArgumentInput(s), _) => {
911
                                trace!("Accepting Argument");
912
                                app.input = app.suggested_input.clone();
913
                                app.input.push('"');
914
                                s.transition(BeginParamInput).as_enum()
915
                            }
916
                            (s, _) => {
917
                                warn!("State: {s:?}");
918
                                s
919
                            }
920
                        };
921
                        app.update_suggestions(commands).await;
922
                        if !app.suggestions.as_lines().is_empty() {
923
                            app.selected_idx = app.suggestions.as_lines().len() - 1;
924
                        }
925
                        app.handle_tab(commands, &state).await;
926
                    }
927
                }
928
                KeyCode::Char(c) => {
929
                    app.input.push(c);
930
                    app.suggested_input = app.input.clone();
931
                    app.update_suggestions(commands).await;
932
                    app.handle_tab(commands, &state).await;
933
                }
934
                KeyCode::Tab => {
935
                    app.handle_tab(commands, &state).await;
936
                }
937
                KeyCode::Backspace => {
938
                    if app.input.is_empty() {
939
                        state = CommandCompletion::Machine::new(Start)
940
                            .transition(BeginCommandInput)
941
                            .as_enum();
942
                    } else {
943
                        let ends_with_equals = app.input.ends_with('=');
944
                        let ends_with_quote = app.input.ends_with('"');
945
                        let contains_space = app.input.contains(' ');
946
                        app.input.pop();
947
                        app.suggested_input = app.input.clone();
948

            
949
                        state = match (state, ends_with_equals, ends_with_quote, contains_space) {
950
                            (ParamInputByBeginParamInput(s), true, _, _) => {
951
                                s.transition(ReturnToArgumentInput).as_enum()
952
                            }
953
                            (ParamInputByCycleParams(s), true, _, _) => {
954
                                s.transition(ReturnToArgumentInput).as_enum()
955
                            }
956
                            (ArgumentInputByCompleteParam(s), _, true, _) => {
957
                                s.transition(BeginParamInput).as_enum()
958
                            }
959
                            (ArgumentInputByBeginArgumentInput(s), _, _, false) => {
960
                                s.transition(ReturnToCommandInput).as_enum()
961
                            }
962
                            (ArgumentInputByCycleArguments(s), _, _, false) => {
963
                                s.transition(ReturnToCommandInput).as_enum()
964
                            }
965
                            (ArgumentInputByCompleteParam(s), _, _, false) => {
966
                                s.transition(ReturnToCommandInput).as_enum()
967
                            }
968
                            (s, _, _, _) => {
969
                                app.update_suggestions(commands).await;
970
                                app.handle_tab(commands, &s).await;
971
                                s
972
                            }
973
                        };
974
                    }
975
                }
976
                KeyCode::Enter => {
977
                    if app.input == app.suggested_input {
978
                        let mut args_map = HashMap::new();
979
                        parse_arguments(&app.input, &mut args_map);
980
                        args_map.insert("user_id".to_string(), Argument::Uuid(app.userid));
981
                        let args_ref: HashMap<&str, &Argument> =
982
                            args_map.iter().map(|(k, v)| (k.as_ref(), v)).collect();
983
                        match execute(commands, &app.input, &args_ref).await {
984
                            Ok(res) => {
985
                                if let Some(res) = res {
986
                                    info!("{res}");
987
                                } else {
988
                                    info!("Successfully completed");
989
                                }
990
                            }
991
                            Err(e) => warn!("Can't execute the command: {e}"),
992
                        }
993
                        state = CommandCompletion::Machine::new(Start)
994
                            .transition(BeginCommandInput)
995
                            .as_enum();
996
                        app.input = "/".to_string();
997
                        app.suggested_input.clear();
998
                        app.update_suggestions(commands).await;
999
                        app.selected_idx = app.suggestions.as_lines().len() - 1;
                        app.handle_tab(commands, &state).await;
                    } else {
                        app.input = app.suggested_input.clone();
                        state = match state {
                            CommandInputByBeginCommandInput(s) => {
                                if app.complete_current_command(commands).await {
                                    s.transition(BeginArgumentInput).as_enum()
                                } else {
                                    s.transition(CycleCommands).as_enum()
                                }
                            }
                            CommandInputByReturnToCommandInput(s) => {
                                if app.complete_current_command(commands).await {
                                    s.transition(BeginArgumentInput).as_enum()
                                } else {
                                    s.transition(CycleCommands).as_enum()
                                }
                            }
                            CommandInputByCycleCommands(s) => {
                                if app.complete_current_command(commands).await {
                                    s.transition(BeginArgumentInput).as_enum()
                                } else {
                                    s.transition(CycleCommands).as_enum()
                                }
                            }
                            ArgumentInputByBeginArgumentInput(s) => {
                                app.complete_current_argument().await;
                                s.transition(BeginParamInput).as_enum()
                            }
                            ArgumentInputByCompleteParam(s) => {
                                app.complete_current_argument().await;
                                s.transition(BeginParamInput).as_enum()
                            }
                            ArgumentInputByCycleArguments(s) => {
                                app.complete_current_argument().await;
                                s.transition(BeginParamInput).as_enum()
                            }
                            ArgumentInputByReturnToArgumentInput(s) => {
                                app.complete_current_argument().await;
                                s.transition(BeginParamInput).as_enum()
                            }
                            ParamInputByBeginParamInput(s) => {
                                app.complete_current_parameter().await;
                                s.transition(ReturnToArgumentInput).as_enum()
                            }
                            ParamInputByCycleParams(s) => {
                                app.complete_current_parameter().await;
                                s.transition(ReturnToArgumentInput).as_enum()
                            }
                            _ => state,
                        };
                        app.update_suggestions(commands).await;
                        if !app.suggestions.as_lines().is_empty() {
                            app.selected_idx = app.suggestions.as_lines().len() - 1;
                        }
                        app.handle_tab(commands, &state).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]
2
    fn test_parse_arguments_empty() {
2
        let input = "/command";
2
        let mut args = HashMap::new();
2
        parse_arguments(input, &mut args);
2
        assert!(args.is_empty());
2
    }
    #[test]
2
    fn test_parse_arguments_single() {
2
        let input = "/command arg=value";
2
        let mut args = HashMap::new();
2
        parse_arguments(input, &mut args);
2
        assert_eq!(args.len(), 1);
2
        assert!(matches!(args.get("arg"),
2
            Some(Argument::String(s)) if s == "value"));
2
    }
    #[test]
2
    fn test_parse_arguments_multiple() {
2
        let input = "/command arg1=value1 arg2=value2";
2
        let mut args = HashMap::new();
2
        parse_arguments(input, &mut args);
2
        assert_eq!(args.len(), 2);
2
        assert!(matches!(args.get("arg1"),
2
            Some(Argument::String(s)) if s == "value1"));
2
        assert!(matches!(args.get("arg2"),
2
            Some(Argument::String(s)) if s == "value2"));
2
    }
    #[test]
2
    fn test_parse_arguments_with_spaces() {
2
        let input = r#"/command arg1="value with spaces" arg2="another spaced value""#;
2
        let mut args = HashMap::new();
2
        parse_arguments(input, &mut args);
2
        assert_eq!(args.len(), 2);
2
        assert!(matches!(args.get("arg1"),
2
            Some(Argument::String(s)) if s == "value with spaces"));
2
        assert!(matches!(args.get("arg2"),
2
            Some(Argument::String(s)) if s == "another spaced value"));
2
    }
    #[test]
2
    fn test_parse_arguments_mixed_types() {
2
        let input = r#"/command num=42 text="hello world" flag=123"#;
2
        let mut args = HashMap::new();
2
        parse_arguments(input, &mut args);
2
        assert_eq!(args.len(), 3);
2
        assert!(matches!(args.get("num"),
2
            Some(Argument::Rational(n)) if n == &42.into()));
2
        assert!(matches!(args.get("text"),
2
            Some(Argument::String(s)) if s == "hello world"));
2
        assert!(matches!(args.get("flag"),
2
            Some(Argument::Rational(n)) if n == &123.into()));
2
    }
    #[test]
2
    fn test_parse_arguments_with_nested_quotes() {
2
        let input = r#"/command arg="value \"quoted\" here""#;
2
        let mut args = HashMap::new();
2
        parse_arguments(input, &mut args);
2
        assert_eq!(args.len(), 1);
2
        assert!(matches!(args.get("arg"),
2
            Some(Argument::String(s)) if s == r#"value "quoted" here"#));
2
    }
}