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.as_lines_mut().push(name.tag_value.clone());
321
                            }
322
                        }
323
                        _ => continue,
324
                    }
325
                }
326
                return;
327
            }
328
        }
329

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

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

            
386
            trace!("Path: {path:?}");
387

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

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

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

            
421
            trace!("Current level: {current_level:?} {prefix:?}");
422

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

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

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

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

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

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

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

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

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

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

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

            
532
    // Skip the command part (everything before first space)
533
53
    for c in chars.by_ref() {
534
53
        if c == ' ' {
535
5
            break;
536
48
        }
537
    }
538

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

            
571
    // Handle the last argument
572
6
    if !current_arg.is_empty()
573
5
        && let Some((key, value)) = parse_single_argument(&current_arg)
574
5
    {
575
5
        args.insert(key, value);
576
5
    }
577
6
}
578

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

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

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

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

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

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

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

            
663
            ListItem::new(Line::from(spans))
664
        })
665
        .collect();
666

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

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

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

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

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

            
722
            remaining = rest;
723
        }
724
    }
725

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

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

            
737
    f.render_widget(log, chunks[2]);
738
}
739

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

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

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

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

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

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

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

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

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