alopex_cli/tui/admin/
mod.rs

1//! Admin TUI entry point.
2
3pub mod actions;
4
5use std::collections::HashSet;
6use std::io::{self, Stdout, Write};
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, Mutex};
9use std::time::{Duration, Instant};
10
11use crossterm::event::{self, Event, KeyCode, KeyEvent};
12use crossterm::execute;
13use crossterm::terminal::{
14    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
15};
16use ratatui::backend::CrosstermBackend;
17use ratatui::layout::{Constraint, Direction, Layout, Rect};
18use ratatui::style::{Color, Modifier, Style};
19use ratatui::text::{Line, Span};
20use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
21use ratatui::Terminal;
22use tokio::runtime::Runtime;
23
24use alopex_embedded::{CreateCatalogRequest, CreateNamespaceRequest};
25
26use crate::error::{CliError, Result};
27use crate::models::{Column, DataType, Row, Value};
28use crate::output::formatter::{create_formatter, Formatter};
29use crate::ui::mode::UiMode;
30use crate::{
31    batch::BatchMode,
32    cli::{
33        ColumnarCommand, DistanceMetric, HnswCommand, IndexCommand, KvCommand, LifecycleCommand,
34        OutputFormat, SqlCommand, VectorCommand,
35    },
36    client::http::HttpClient,
37};
38
39use self::actions::{
40    all_actions, execute_local_action, execute_remote_action, AdminAction, AdminCommand,
41    AdminRequest,
42};
43use super::is_tty;
44
45#[allow(dead_code)]
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum AuthScope {
48    Full,
49    Restricted,
50}
51
52#[derive(Debug, Clone)]
53pub struct AuthCapabilities {
54    scope: AuthScope,
55    allowed_actions: HashSet<AdminAction>,
56}
57
58impl AuthCapabilities {
59    pub fn full() -> Self {
60        Self {
61            scope: AuthScope::Full,
62            allowed_actions: HashSet::new(),
63        }
64    }
65
66    #[allow(dead_code)]
67    pub fn restricted(allowed_actions: HashSet<AdminAction>) -> Self {
68        Self {
69            scope: AuthScope::Restricted,
70            allowed_actions,
71        }
72    }
73
74    pub fn restricted_all() -> Self {
75        Self {
76            scope: AuthScope::Restricted,
77            allowed_actions: all_actions(),
78        }
79    }
80
81    fn allows(&self, action: AdminAction) -> bool {
82        match self.scope {
83            AuthScope::Full => true,
84            AuthScope::Restricted => self.allowed_actions.contains(&action),
85        }
86    }
87}
88
89#[derive(Debug, Clone)]
90struct AdminItem {
91    action: AdminAction,
92    title: &'static str,
93    description: &'static str,
94    enabled: bool,
95}
96
97struct AdminApp<'a> {
98    items: Vec<AdminItem>,
99    selected: usize,
100    show_help: bool,
101    connection_label: String,
102    backend: AdminBackend<'a>,
103    last_result: Option<AdminResult>,
104    target: AdminTarget,
105    params: String,
106    form_fields: Vec<AdminFormField>,
107    active_field: usize,
108    use_raw_params: bool,
109    input_mode: AdminInputMode,
110    last_action: Option<AdminAction>,
111    selection: Option<SelectionOverlay>,
112    focus: AdminFocus,
113    resources: ResourceTree,
114    preview_scroll: usize,
115}
116
117impl<'a> AdminApp<'a> {
118    fn new(
119        connection_label: impl Into<String>,
120        auth: AuthCapabilities,
121        backend: AdminBackend<'a>,
122        initial_target: Option<AdminTarget>,
123    ) -> Self {
124        let mut items = default_items();
125        for item in &mut items {
126            item.enabled = auth.allows(item.action);
127        }
128        let target = initial_target.unwrap_or(AdminTarget::Sql);
129        let selected_action = items.first().map(|item| item.action);
130        let form_fields = selected_action
131            .map(|action| build_form_fields(target, action))
132            .unwrap_or_default();
133        let resources = ResourceTree::new(&backend);
134        let last_result = resources
135            .last_error
136            .as_ref()
137            .map(|err| AdminResult::status(format!("Resource load failed: {err}")));
138        Self {
139            items,
140            selected: 0,
141            show_help: false,
142            connection_label: connection_label.into(),
143            backend,
144            last_result,
145            target,
146            params: String::new(),
147            form_fields,
148            active_field: 0,
149            use_raw_params: false,
150            input_mode: AdminInputMode::Normal,
151            last_action: selected_action,
152            selection: None,
153            focus: AdminFocus::Table,
154            resources,
155            preview_scroll: 0,
156        }
157    }
158
159    fn run(mut self) -> Result<()> {
160        if !is_tty() {
161            return Err(CliError::InvalidArgument(
162                "TUI requires a TTY. Run without --tui in batch mode.".to_string(),
163            ));
164        }
165        enable_raw_mode()?;
166        let mut stdout = io::stdout();
167        execute!(stdout, EnterAlternateScreen)?;
168
169        let backend = CrosstermBackend::new(stdout);
170        let mut terminal = Terminal::new(backend)?;
171        terminal.clear()?;
172
173        let tick_rate = Duration::from_millis(16);
174        let mut last_tick = Instant::now();
175
176        loop {
177            terminal.draw(|frame| self.draw(frame))?;
178
179            let timeout = tick_rate
180                .checked_sub(last_tick.elapsed())
181                .unwrap_or_else(|| Duration::from_secs(0));
182
183            if event::poll(timeout)? {
184                if let Event::Key(key) = event::read()? {
185                    if self.handle_key(key)? {
186                        break;
187                    }
188                }
189            }
190
191            if last_tick.elapsed() >= tick_rate {
192                last_tick = Instant::now();
193            }
194        }
195
196        cleanup_terminal(terminal)
197    }
198
199    fn draw(&mut self, frame: &mut ratatui::Frame<'_>) {
200        let area = frame.size();
201        let chunks = Layout::default()
202            .direction(Direction::Vertical)
203            .constraints([Constraint::Min(5), Constraint::Length(3)])
204            .split(area);
205
206        let root_layout = Layout::default()
207            .direction(Direction::Horizontal)
208            .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
209            .split(chunks[0]);
210
211        let right_layout = Layout::default()
212            .direction(Direction::Vertical)
213            .constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
214            .split(root_layout[1]);
215
216        self.render_resources(frame, root_layout[0]);
217        self.render_input(frame, right_layout[0]);
218        self.render_preview(frame, right_layout[1]);
219        self.render_status(frame, chunks[1]);
220
221        if self.show_help {
222            render_help(frame, area);
223        }
224        if let Some(selection) = &self.selection {
225            render_selection_overlay(frame, area, selection);
226        }
227    }
228
229    fn render_action_list(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
230        let items = self
231            .items
232            .iter()
233            .map(|item| {
234                let label = if item.enabled {
235                    item.title.to_string()
236                } else {
237                    format!("{} (locked)", item.title)
238                };
239                ListItem::new(Line::from(Span::raw(label)))
240            })
241            .collect::<Vec<_>>();
242
243        let mut state = ListState::default();
244        state.select(Some(self.selected));
245
246        let list = List::new(items)
247            .block(
248                Block::default()
249                    .borders(Borders::ALL)
250                    .title("Actions")
251                    .border_style(self.focus_style(AdminFocus::Detail)),
252            )
253            .highlight_style(
254                Style::default()
255                    .bg(Color::Blue)
256                    .fg(Color::White)
257                    .add_modifier(Modifier::BOLD),
258            )
259            .highlight_symbol("> ");
260
261        frame.render_stateful_widget(list, area, &mut state);
262    }
263
264    fn render_input(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
265        let layout = Layout::default()
266            .direction(Direction::Vertical)
267            .constraints([Constraint::Length(10), Constraint::Min(6)])
268            .split(area);
269
270        self.render_action_list(frame, layout[0]);
271
272        let detail_area = layout[1];
273        let selected = self.items.get(self.selected);
274        let mut lines = Vec::new();
275        if let Some(item) = selected {
276            lines.push(Line::from(vec![Span::styled(
277                item.title,
278                Style::default().add_modifier(Modifier::BOLD),
279            )]));
280            lines.push(Line::from(""));
281            lines.push(Line::from(format!("Target: {}", self.target.label())));
282            match self.input_mode {
283                AdminInputMode::EditingField => {
284                    lines.push(Line::from("Mode: editing field (Enter/Esc to finish)"));
285                }
286                AdminInputMode::EditingRaw => {
287                    lines.push(Line::from("Mode: editing raw params (Enter/Esc to finish)"));
288                }
289                AdminInputMode::Normal => {}
290            }
291            if self.use_raw_params {
292                lines.push(Line::from("Input: raw parameters (press r to switch)"));
293                let line = if self.params.is_empty() {
294                    "Params: <empty> (press e to edit)".to_string()
295                } else {
296                    format!("Params: {}", self.params)
297                };
298                lines.push(Line::from(line));
299                if let Some(example) = self.target.example_for(item.action) {
300                    lines.push(Line::from(format!("Example: {example}")));
301                }
302            } else {
303                lines.push(Line::from(
304                    "Input: guided fields (Tab to move, e to edit, o to list)",
305                ));
306                for (idx, field) in self.form_fields.iter().enumerate() {
307                    let marker = if idx == self.active_field { ">" } else { " " };
308                    let value = if field.value.is_empty() {
309                        Span::styled(
310                            field.placeholder.to_string(),
311                            Style::default().fg(Color::DarkGray),
312                        )
313                    } else {
314                        Span::raw(field.value.clone())
315                    };
316                    let required = if field.required { " *" } else { "" };
317                    let list_hint = if field.list_source.is_some() {
318                        Span::styled(" (o)", Style::default().fg(Color::Blue))
319                    } else {
320                        Span::raw("")
321                    };
322                    lines.push(Line::from(vec![
323                        Span::raw(format!("{marker} ")),
324                        Span::styled(
325                            format!("{}{}", field.label, required),
326                            Style::default().add_modifier(Modifier::BOLD),
327                        ),
328                        Span::raw(": "),
329                        value,
330                        list_hint,
331                    ]));
332                }
333            }
334            lines.push(Line::from(""));
335            lines.push(Line::from(item.description));
336            lines.push(Line::from(""));
337            if !item.enabled {
338                lines.push(Line::from(Span::styled(
339                    "Disabled: your current authorization does not allow this action.",
340                    Style::default().fg(Color::Red),
341                )));
342            } else if is_not_implemented(item.action) {
343                lines.push(Line::from(Span::styled(
344                    "Status: Not implemented yet.",
345                    Style::default().fg(Color::Yellow),
346                )));
347            } else {
348                lines.push(Line::from(Span::styled(
349                    "Status: Ready.",
350                    Style::default().fg(Color::Green),
351                )));
352            }
353        }
354        let paragraph = Paragraph::new(lines)
355            .block(
356                Block::default()
357                    .borders(Borders::ALL)
358                    .title("Detail")
359                    .border_style(self.focus_style(AdminFocus::Detail)),
360            )
361            .wrap(Wrap { trim: true });
362        frame.render_widget(paragraph, detail_area);
363    }
364
365    fn focus_style(&self, focus: AdminFocus) -> Style {
366        if self.focus == focus {
367            Style::default().fg(Color::Green)
368        } else {
369            Style::default()
370        }
371    }
372
373    fn preview_line_count(&self) -> usize {
374        let mut lines = Vec::new();
375        if let Some(result) = &self.last_result {
376            append_result_lines(&mut lines, result);
377        } else {
378            lines.push(Line::from("No results yet."));
379        }
380        lines.len()
381    }
382
383    fn render_resources(&mut self, frame: &mut ratatui::Frame<'_>, area: Rect) {
384        self.resources.ensure_selection_in_range();
385        let layout = if self.resources.search.is_some() {
386            Layout::default()
387                .direction(Direction::Vertical)
388                .constraints([Constraint::Length(1), Constraint::Min(3)])
389                .split(area)
390        } else {
391            Layout::default()
392                .direction(Direction::Vertical)
393                .constraints([Constraint::Min(3)])
394                .split(area)
395        };
396
397        if let Some(search) = self.resources.search.as_ref() {
398            let search_text = format!("/ {search}");
399            let style = if self.resources.search_focused {
400                Style::default().fg(Color::Yellow)
401            } else {
402                Style::default().fg(Color::Gray)
403            };
404            frame.render_widget(
405                Paragraph::new(search_text)
406                    .block(
407                        Block::default()
408                            .borders(Borders::ALL)
409                            .title("Resources")
410                            .border_style(self.focus_style(AdminFocus::Table)),
411                    )
412                    .style(style),
413                layout[0],
414            );
415        }
416
417        let list_area = if layout.len() == 1 {
418            layout[0]
419        } else {
420            layout[1]
421        };
422        let entries = self.resources.filtered_entries();
423        let items = if entries.is_empty() {
424            vec![ListItem::new(Line::from("No resources found."))]
425        } else {
426            entries
427                .iter()
428                .map(|entry| {
429                    let indent = "  ".repeat(entry.depth);
430                    let mut line = format!("{indent}{}", entry.label);
431                    if !entry.selectable {
432                        line = line.to_string();
433                    }
434                    let style = if entry.selectable {
435                        Style::default()
436                    } else {
437                        Style::default().fg(Color::DarkGray)
438                    };
439                    ListItem::new(Line::from(Span::styled(line, style)))
440                })
441                .collect::<Vec<_>>()
442        };
443
444        let mut state = ListState::default();
445        state.select(Some(self.resources.selected));
446        let list = List::new(items)
447            .block(
448                Block::default()
449                    .borders(Borders::ALL)
450                    .title(if self.resources.search.is_some() {
451                        ""
452                    } else {
453                        "Resources"
454                    })
455                    .border_style(self.focus_style(AdminFocus::Table)),
456            )
457            .highlight_style(
458                Style::default()
459                    .bg(Color::Blue)
460                    .fg(Color::White)
461                    .add_modifier(Modifier::BOLD),
462            )
463            .highlight_symbol("> ");
464
465        frame.render_stateful_widget(list, list_area, &mut state);
466    }
467
468    fn render_preview(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
469        let mut lines = Vec::new();
470        if let Some(result) = &self.last_result {
471            append_result_lines(&mut lines, result);
472        } else {
473            lines.push(Line::from("No results yet."));
474        }
475
476        let height = area.height.saturating_sub(2) as usize;
477        let start = self.preview_scroll.min(lines.len());
478        let end = (start + height).min(lines.len());
479        let view = lines[start..end].to_vec();
480
481        let paragraph = Paragraph::new(view)
482            .block(
483                Block::default()
484                    .borders(Borders::ALL)
485                    .title("Status")
486                    .border_style(self.focus_style(AdminFocus::Status)),
487            )
488            .wrap(Wrap { trim: true });
489        frame.render_widget(paragraph, area);
490    }
491
492    fn render_status(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
493        let action = self
494            .items
495            .get(self.selected)
496            .map(|item| item.title)
497            .unwrap_or("-");
498        let focus_label = match self.focus {
499            AdminFocus::Table => "Table",
500            AdminFocus::Detail => "Detail",
501            AdminFocus::Status => "Status",
502        };
503        let highlight = Style::default()
504            .fg(Color::Yellow)
505            .add_modifier(Modifier::BOLD);
506
507        let mut spans = Vec::new();
508        let push_sep = |spans: &mut Vec<Span<'_>>| {
509            spans.push(Span::raw(" | "));
510        };
511
512        spans.push(Span::raw("Connection: "));
513        spans.push(Span::styled(self.connection_label.to_string(), highlight));
514        push_sep(&mut spans);
515        spans.push(Span::raw("Focus: "));
516        spans.push(Span::styled(focus_label.to_string(), highlight));
517        push_sep(&mut spans);
518        spans.push(Span::raw("Action: "));
519        spans.push(Span::styled(action.to_string(), highlight));
520
521        let mut mode_label = None;
522        if self.show_help {
523            mode_label = Some("Help");
524        } else if self.selection.is_some() {
525            mode_label = Some("Selecting option");
526        } else if self.input_mode == AdminInputMode::EditingField {
527            mode_label = Some("Editing field");
528        } else if self.input_mode == AdminInputMode::EditingRaw {
529            mode_label = Some("Editing raw params");
530        }
531
532        if let Some(mode) = mode_label {
533            push_sep(&mut spans);
534            spans.push(Span::raw(format!("Mode: {mode}")));
535        }
536
537        let (ops_text, move_text) = if self.show_help {
538            ("?: close".to_string(), "-".to_string())
539        } else if self.selection.is_some() {
540            (
541                "Enter: choose, /: search, Esc: cancel".to_string(),
542                "j/k, g/G, Ctrl+d/u".to_string(),
543            )
544        } else if matches!(
545            self.input_mode,
546            AdminInputMode::EditingField | AdminInputMode::EditingRaw
547        ) {
548            ("Enter: done, Esc: cancel".to_string(), "-".to_string())
549        } else {
550            match self.focus {
551                AdminFocus::Table => (
552                    "Enter: select, e: edit, r: raw, R: refresh, a: back, ?: help, q: quit"
553                        .to_string(),
554                    "j/k, g/G, Ctrl+d/u, h/l".to_string(),
555                ),
556                AdminFocus::Detail => (
557                    "Enter: execute, e: edit, o: list, r: raw, a: back, ?: help, q: quit"
558                        .to_string(),
559                    "Up/Down, Tab, h/l".to_string(),
560                ),
561                AdminFocus::Status => (
562                    "a: back, ?: help, q: quit".to_string(),
563                    "j/k, g/G, Ctrl+d/u, h".to_string(),
564                ),
565            }
566        };
567
568        push_sep(&mut spans);
569        spans.push(Span::styled(format!("Ops: {ops_text}"), highlight));
570        push_sep(&mut spans);
571        if move_text == "-" {
572            spans.push(Span::raw("Move: -"));
573        } else {
574            spans.push(Span::raw(format!("Move: {move_text}")));
575        }
576
577        let paragraph = Paragraph::new(Line::from(spans))
578            .block(Block::default().borders(Borders::ALL).title("Status"))
579            .style(Style::default().fg(Color::Gray))
580            .wrap(Wrap { trim: true });
581        frame.render_widget(paragraph, area);
582    }
583
584    fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
585        if let Some(selection) = &mut self.selection {
586            if selection.search_focused {
587                match key.code {
588                    KeyCode::Esc => selection.reset_search(),
589                    KeyCode::Enter => selection.search_focused = false,
590                    KeyCode::Backspace => selection.pop_search(),
591                    KeyCode::Char(ch) => selection.push_search(ch),
592                    _ => {}
593                }
594                return Ok(false);
595            }
596            match key.code {
597                KeyCode::Esc => {
598                    self.selection = None;
599                }
600                KeyCode::Enter => {
601                    if let Some(value) = selection.selected_value() {
602                        if let Some(field) = self.form_fields.get_mut(selection.field_index) {
603                            field.value = value;
604                        }
605                    }
606                    self.selection = None;
607                }
608                KeyCode::Char('/') => {
609                    selection.search_focused = true;
610                }
611                KeyCode::Up | KeyCode::Char('k') => {
612                    selection.move_up();
613                }
614                KeyCode::Down | KeyCode::Char('j') => {
615                    selection.move_down();
616                }
617                KeyCode::Char('g') => {
618                    selection.move_top();
619                }
620                KeyCode::Char('G') => {
621                    selection.move_bottom();
622                }
623                _ => {}
624            }
625            return Ok(false);
626        }
627        match self.input_mode {
628            AdminInputMode::EditingField => {
629                match key.code {
630                    KeyCode::Esc | KeyCode::Enter => {
631                        self.input_mode = AdminInputMode::Normal;
632                    }
633                    KeyCode::Backspace => {
634                        if let Some(field) = self.form_fields.get_mut(self.active_field) {
635                            field.value.pop();
636                        }
637                    }
638                    KeyCode::Char(ch) => {
639                        if let Some(field) = self.form_fields.get_mut(self.active_field) {
640                            field.value.push(ch);
641                        }
642                    }
643                    _ => {}
644                }
645                return Ok(false);
646            }
647            AdminInputMode::EditingRaw => {
648                match key.code {
649                    KeyCode::Esc | KeyCode::Enter => {
650                        self.input_mode = AdminInputMode::Normal;
651                    }
652                    KeyCode::Backspace => {
653                        self.params.pop();
654                    }
655                    KeyCode::Char(ch) => {
656                        self.params.push(ch);
657                    }
658                    _ => {}
659                }
660                return Ok(false);
661            }
662            AdminInputMode::Normal => {}
663        }
664
665        if matches!(self.focus, AdminFocus::Table)
666            && self.resources.search_focused
667            && key.code == KeyCode::Esc
668        {
669            self.resources.reset_search();
670            return Ok(false);
671        }
672
673        match key.code {
674            KeyCode::Char('q') | KeyCode::Char('a') | KeyCode::Esc => return Ok(true),
675            KeyCode::Char('?') => {
676                self.show_help = !self.show_help;
677                return Ok(false);
678            }
679            KeyCode::Char('h') | KeyCode::Left => {
680                self.focus = self.focus_left();
681                return Ok(false);
682            }
683            KeyCode::Char('l') | KeyCode::Right => {
684                self.focus = self.focus_right();
685                return Ok(false);
686            }
687            _ => {}
688        }
689
690        match self.focus {
691            AdminFocus::Table => {
692                if self.resources.search_focused {
693                    match key.code {
694                        KeyCode::Esc => self.resources.reset_search(),
695                        KeyCode::Enter => self.resources.search_focused = false,
696                        KeyCode::Backspace => self.resources.pop_search(),
697                        KeyCode::Char(ch) => self.resources.push_search(ch),
698                        _ => {}
699                    }
700                    return Ok(false);
701                }
702                match key.code {
703                    KeyCode::Char('e') => {
704                        self.focus = AdminFocus::Detail;
705                        self.input_mode = if self.use_raw_params {
706                            AdminInputMode::EditingRaw
707                        } else {
708                            AdminInputMode::EditingField
709                        };
710                    }
711                    KeyCode::Char('r') => {
712                        self.use_raw_params = !self.use_raw_params;
713                        self.focus = AdminFocus::Detail;
714                        self.input_mode = if self.use_raw_params {
715                            AdminInputMode::EditingRaw
716                        } else {
717                            AdminInputMode::Normal
718                        };
719                    }
720                    KeyCode::Char('/') => {
721                        self.resources.search_focused = true;
722                        if self.resources.search.is_none() {
723                            self.resources.search = Some(String::new());
724                        }
725                    }
726                    KeyCode::Char('R') => {
727                        self.resources.reload(&self.backend);
728                        if let Some(err) = self.resources.last_error.clone() {
729                            self.last_result =
730                                Some(AdminResult::status(format!("Resource load failed: {err}")));
731                        }
732                    }
733                    KeyCode::Up | KeyCode::Char('k') => {
734                        self.resources.move_up();
735                        self.sync_target_from_resource();
736                    }
737                    KeyCode::Down | KeyCode::Char('j') => {
738                        self.resources.move_down();
739                        self.sync_target_from_resource();
740                    }
741                    KeyCode::Char('g') => {
742                        self.resources.move_top();
743                        self.sync_target_from_resource();
744                    }
745                    KeyCode::Char('G') => {
746                        self.resources.move_bottom();
747                        self.sync_target_from_resource();
748                    }
749                    KeyCode::Char('d') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
750                        self.resources.page_down();
751                        self.sync_target_from_resource();
752                    }
753                    KeyCode::Char('u') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
754                        self.resources.page_up();
755                        self.sync_target_from_resource();
756                    }
757                    KeyCode::Enter => {
758                        self.apply_resource_selection()?;
759                    }
760                    _ => {}
761                }
762            }
763            AdminFocus::Detail => match key.code {
764                KeyCode::Char('e') => {
765                    self.input_mode = if self.use_raw_params {
766                        AdminInputMode::EditingRaw
767                    } else {
768                        AdminInputMode::EditingField
769                    };
770                }
771                KeyCode::Char('r') => {
772                    self.use_raw_params = !self.use_raw_params;
773                    self.input_mode = if self.use_raw_params {
774                        AdminInputMode::EditingRaw
775                    } else {
776                        AdminInputMode::Normal
777                    };
778                }
779                KeyCode::Char('o') => {
780                    self.open_selection_for_active_field()?;
781                }
782                KeyCode::Tab => {
783                    if !self.use_raw_params && !self.form_fields.is_empty() {
784                        self.active_field = (self.active_field + 1) % self.form_fields.len();
785                    }
786                }
787                KeyCode::BackTab => {
788                    if !self.use_raw_params && !self.form_fields.is_empty() {
789                        if self.active_field == 0 {
790                            self.active_field = self.form_fields.len() - 1;
791                        } else {
792                            self.active_field -= 1;
793                        }
794                    }
795                }
796                KeyCode::Up | KeyCode::Char('k') => {
797                    if self.selected > 0 {
798                        self.selected -= 1;
799                        self.refresh_form_for_selection();
800                    }
801                }
802                KeyCode::Down | KeyCode::Char('j') => {
803                    if self.selected + 1 < self.items.len() {
804                        self.selected += 1;
805                        self.refresh_form_for_selection();
806                    }
807                }
808                KeyCode::Enter => {
809                    self.execute_selected_action()?;
810                }
811                _ => {}
812            },
813            AdminFocus::Status => match key.code {
814                KeyCode::Up | KeyCode::Char('k') => {
815                    self.preview_scroll = self.preview_scroll.saturating_sub(1);
816                }
817                KeyCode::Down | KeyCode::Char('j') => {
818                    let max = self.preview_line_count().saturating_sub(1);
819                    self.preview_scroll = (self.preview_scroll + 1).min(max);
820                }
821                KeyCode::Char('g') => {
822                    self.preview_scroll = 0;
823                }
824                KeyCode::Char('G') => {
825                    self.preview_scroll = self.preview_line_count().saturating_sub(1);
826                }
827                KeyCode::Char('d') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
828                    self.preview_scroll =
829                        (self.preview_scroll + 5).min(self.preview_line_count().saturating_sub(1));
830                }
831                KeyCode::Char('u') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
832                    self.preview_scroll = self.preview_scroll.saturating_sub(5);
833                }
834                _ => {}
835            },
836        }
837        Ok(false)
838    }
839
840    fn execute_selected_action(&mut self) -> Result<()> {
841        let Some(item) = self.items.get(self.selected) else {
842            return Ok(());
843        };
844        if !item.enabled {
845            self.last_result = Some(AdminResult::status(format!(
846                "Action '{}' is not permitted.",
847                item.title
848            )));
849            return Ok(());
850        }
851
852        let params = if self.use_raw_params {
853            parse_params(&self.params)
854        } else {
855            build_params_from_fields(&self.form_fields)
856        };
857        if let Err(message) = validate_params(item.action, self.target, &params) {
858            self.last_result = Some(AdminResult::status(message));
859            return Ok(());
860        }
861        let command = match build_command_for(item.action, self.target, &params) {
862            Ok(Some(command)) => command,
863            Ok(None) => {
864                self.last_result = Some(AdminResult::status(
865                    "Select target/params to execute".to_string(),
866                ));
867                return Ok(());
868            }
869            Err(err) => {
870                self.last_result = Some(AdminResult::status(err.to_string()));
871                return Ok(());
872            }
873        };
874        let request = AdminRequest {
875            action: item.action,
876            command,
877            limit: self.backend.limit(),
878            quiet: self.backend.quiet(),
879            ui_mode: UiMode::Batch,
880            connection_label: self.connection_label.clone(),
881            output: self.backend.output_format(),
882            data_dir: self.backend.data_dir().map(PathBuf::from),
883        };
884
885        let (formatter, state) = CaptureFormatter::new();
886        let mut sink = io::sink();
887        let result = match &self.backend {
888            AdminBackend::Local { db, batch_mode, .. } => {
889                execute_local_action(db, batch_mode, request, &mut sink, Box::new(formatter))
890            }
891            AdminBackend::Remote {
892                client, batch_mode, ..
893            } => {
894                let runtime = Runtime::new().map_err(|err| {
895                    CliError::InvalidArgument(format!("Failed to start async runtime: {err}"))
896                })?;
897                runtime.block_on(execute_remote_action(
898                    client,
899                    batch_mode,
900                    request,
901                    &mut sink,
902                    Box::new(formatter),
903                ))
904            }
905        };
906
907        let capture = state.lock().expect("admin capture lock");
908        let mut result_state = AdminResult {
909            columns: capture.columns.clone(),
910            rows: capture.rows.clone(),
911            status_message: None,
912        };
913
914        if let Err(err) = result {
915            result_state.status_message = Some(err.to_string());
916        } else if result_state.columns.is_empty() && result_state.rows.is_empty() {
917            result_state.status_message = Some("OK".to_string());
918        }
919
920        self.last_result = Some(result_state);
921        self.preview_scroll = 0;
922        Ok(())
923    }
924
925    fn refresh_form_for_selection(&mut self) {
926        let action = self.items.get(self.selected).map(|item| item.action);
927        if action != self.last_action {
928            self.last_action = action;
929            self.reset_form();
930        }
931    }
932
933    fn reset_form(&mut self) {
934        if let Some(action) = self.items.get(self.selected).map(|item| item.action) {
935            self.form_fields = build_form_fields(self.target, action);
936            self.active_field = 0;
937            self.input_mode = AdminInputMode::Normal;
938            self.use_raw_params = false;
939            self.last_action = Some(action);
940            self.selection = None;
941        }
942    }
943
944    fn focus_left(&self) -> AdminFocus {
945        match self.focus {
946            AdminFocus::Table => AdminFocus::Table,
947            AdminFocus::Detail => AdminFocus::Table,
948            AdminFocus::Status => AdminFocus::Detail,
949        }
950    }
951
952    fn focus_right(&self) -> AdminFocus {
953        match self.focus {
954            AdminFocus::Table => AdminFocus::Detail,
955            AdminFocus::Detail => AdminFocus::Status,
956            AdminFocus::Status => AdminFocus::Status,
957        }
958    }
959
960    fn apply_resource_selection(&mut self) -> Result<()> {
961        let Some(entry) = self.resources.selected_entry() else {
962            return Ok(());
963        };
964        if !entry.selectable {
965            if let Some(target) = target_for_resource(&entry) {
966                self.ensure_target(target);
967            }
968            return Ok(());
969        }
970        match entry.kind {
971            ResourceKind::Section(section) => {
972                if let Some(target) = section.target() {
973                    self.ensure_target(target);
974                }
975            }
976            ResourceKind::Table { name } => {
977                self.ensure_target(AdminTarget::Sql);
978                if self.set_field_value("table", &name, false).is_none() {
979                    let query = format!("SELECT * FROM {name}");
980                    if self.set_field_value("query", &query, false).is_none() {
981                        self.last_result = Some(AdminResult::status(
982                            "No matching field for table.".to_string(),
983                        ));
984                    }
985                }
986            }
987            ResourceKind::Column { table, name } => {
988                self.ensure_target(AdminTarget::Sql);
989                let _ = self.set_field_value("table", &table, false);
990                if self.set_field_value("columns", &name, true).is_none() {
991                    self.last_result = Some(AdminResult::status(
992                        "No matching field for column.".to_string(),
993                    ));
994                }
995            }
996            ResourceKind::KvKey { key } => {
997                self.ensure_target(AdminTarget::Kv);
998                if self.set_field_value("key", &key, false).is_none() {
999                    self.last_result = Some(AdminResult::status(
1000                        "No matching field for key.".to_string(),
1001                    ));
1002                }
1003            }
1004            ResourceKind::ColumnarSegment { id } => {
1005                self.ensure_target(AdminTarget::Columnar);
1006                if self.set_field_value("segment", &id, false).is_none() {
1007                    self.last_result = Some(AdminResult::status(
1008                        "No matching field for segment.".to_string(),
1009                    ));
1010                }
1011            }
1012            ResourceKind::ColumnarColumn { segment_id, name } => {
1013                self.ensure_target(AdminTarget::Columnar);
1014                let _ = self.set_field_value("segment", &segment_id, false);
1015                if self.set_field_value("column", &name, false).is_none() {
1016                    self.last_result = Some(AdminResult::status(
1017                        "No matching field for column.".to_string(),
1018                    ));
1019                }
1020            }
1021            ResourceKind::Info => {}
1022        }
1023        Ok(())
1024    }
1025
1026    fn sync_target_from_resource(&mut self) {
1027        let Some(entry) = self.resources.selected_entry() else {
1028            return;
1029        };
1030        if let Some(target) = target_for_resource(&entry) {
1031            self.ensure_target(target);
1032        }
1033    }
1034
1035    fn ensure_target(&mut self, target: AdminTarget) {
1036        if self.target != target {
1037            self.target = target;
1038            self.reset_form();
1039        }
1040    }
1041
1042    fn set_field_value(&mut self, key: &str, value: &str, append: bool) -> Option<()> {
1043        for (idx, field) in self.form_fields.iter_mut().enumerate() {
1044            if field.key.eq_ignore_ascii_case(key) {
1045                if append && !field.value.trim().is_empty() {
1046                    field.value = format!("{},{}", field.value.trim(), value);
1047                } else {
1048                    field.value = value.to_string();
1049                }
1050                self.active_field = idx;
1051                return Some(());
1052            }
1053        }
1054        None
1055    }
1056
1057    fn open_selection_for_active_field(&mut self) -> Result<()> {
1058        if self.use_raw_params {
1059            self.last_result = Some(AdminResult::status(
1060                "List selection is unavailable while using raw params.".to_string(),
1061            ));
1062            return Ok(());
1063        }
1064        let Some(field) = self.form_fields.get(self.active_field) else {
1065            return Ok(());
1066        };
1067        let Some(source) = field.list_source else {
1068            self.last_result = Some(AdminResult::status(
1069                "No list is available for this field.".to_string(),
1070            ));
1071            return Ok(());
1072        };
1073        let mut items = load_list_options(&self.backend, &self.form_fields, source)?;
1074        if items.is_empty() {
1075            items = self.list_options_from_resources(source);
1076        }
1077        items.retain(|item| !item.trim().is_empty());
1078        if items.is_empty() {
1079            self.last_result = Some(AdminResult::status(
1080                "No matching resources were found.".to_string(),
1081            ));
1082            return Ok(());
1083        }
1084        self.selection = Some(SelectionOverlay::new(
1085            format!("Select {}", field.label),
1086            items,
1087            self.active_field,
1088        ));
1089        Ok(())
1090    }
1091
1092    fn list_options_from_resources(&self, source: ListSource) -> Vec<String> {
1093        let mut items = Vec::new();
1094        match source {
1095            ListSource::KvKeys => {
1096                for entry in &self.resources.entries {
1097                    if let ResourceKind::KvKey { key } = &entry.kind {
1098                        items.push(key.clone());
1099                    }
1100                }
1101            }
1102            ListSource::SqlTables => {
1103                for entry in &self.resources.entries {
1104                    if let ResourceKind::Table { name } = &entry.kind {
1105                        items.push(name.clone());
1106                    }
1107                }
1108            }
1109            ListSource::SqlColumns => {
1110                let Some(table) = field_value(&self.form_fields, "table") else {
1111                    return items;
1112                };
1113                for entry in &self.resources.entries {
1114                    if let ResourceKind::Column {
1115                        table: entry_table,
1116                        name,
1117                    } = &entry.kind
1118                    {
1119                        if entry_table == &table {
1120                            items.push(name.clone());
1121                        }
1122                    }
1123                }
1124            }
1125            ListSource::ColumnarSegments => {
1126                for entry in &self.resources.entries {
1127                    if let ResourceKind::ColumnarSegment { id } = &entry.kind {
1128                        items.push(id.clone());
1129                    }
1130                }
1131            }
1132            ListSource::ColumnarColumns => {
1133                let Some(segment) = field_value(&self.form_fields, "segment") else {
1134                    return items;
1135                };
1136                for entry in &self.resources.entries {
1137                    if let ResourceKind::ColumnarColumn { segment_id, name } = &entry.kind {
1138                        if segment_id == &segment {
1139                            items.push(name.clone());
1140                        }
1141                    }
1142                }
1143            }
1144        }
1145        items.sort();
1146        items.dedup();
1147        items
1148    }
1149}
1150
1151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1152enum AdminInputMode {
1153    Normal,
1154    EditingField,
1155    EditingRaw,
1156}
1157
1158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1159enum AdminFocus {
1160    Table,
1161    Detail,
1162    Status,
1163}
1164
1165#[derive(Debug, Clone)]
1166struct AdminFormField {
1167    key: &'static str,
1168    label: &'static str,
1169    value: String,
1170    placeholder: &'static str,
1171    required: bool,
1172    list_source: Option<ListSource>,
1173}
1174
1175#[derive(Debug, Clone, Copy)]
1176enum ListSource {
1177    KvKeys,
1178    SqlTables,
1179    SqlColumns,
1180    ColumnarSegments,
1181    ColumnarColumns,
1182}
1183
1184#[derive(Debug, Clone)]
1185struct ResourceEntry {
1186    label: String,
1187    kind: ResourceKind,
1188    depth: usize,
1189    selectable: bool,
1190}
1191
1192#[derive(Debug, Clone)]
1193enum ResourceKind {
1194    Section(ResourceSection),
1195    Table { name: String },
1196    Column { table: String, name: String },
1197    KvKey { key: String },
1198    ColumnarSegment { id: String },
1199    ColumnarColumn { segment_id: String, name: String },
1200    Info,
1201}
1202
1203struct ResourceTree {
1204    entries: Vec<ResourceEntry>,
1205    selected: usize,
1206    search: Option<String>,
1207    search_focused: bool,
1208    last_error: Option<String>,
1209}
1210
1211#[derive(Debug, Clone, Copy)]
1212enum ResourceSection {
1213    SqlTables,
1214    ColumnarSegments,
1215    KvKeys,
1216}
1217
1218impl ResourceSection {
1219    fn target(self) -> Option<AdminTarget> {
1220        match self {
1221            ResourceSection::SqlTables => Some(AdminTarget::Sql),
1222            ResourceSection::ColumnarSegments => Some(AdminTarget::Columnar),
1223            ResourceSection::KvKeys => Some(AdminTarget::Kv),
1224        }
1225    }
1226}
1227
1228fn target_for_resource(entry: &ResourceEntry) -> Option<AdminTarget> {
1229    match entry.kind {
1230        ResourceKind::Section(section) => section.target(),
1231        ResourceKind::Table { .. } | ResourceKind::Column { .. } => Some(AdminTarget::Sql),
1232        ResourceKind::KvKey { .. } => Some(AdminTarget::Kv),
1233        ResourceKind::ColumnarSegment { .. } | ResourceKind::ColumnarColumn { .. } => {
1234            Some(AdminTarget::Columnar)
1235        }
1236        ResourceKind::Info => None,
1237    }
1238}
1239
1240impl ResourceTree {
1241    fn new(backend: &AdminBackend<'_>) -> Self {
1242        let (entries, last_error) = match load_resource_entries(backend) {
1243            Ok(entries) => (entries, None),
1244            Err(err) => (Vec::new(), Some(err.to_string())),
1245        };
1246        Self {
1247            entries,
1248            selected: 0,
1249            search: None,
1250            search_focused: false,
1251            last_error,
1252        }
1253    }
1254
1255    fn reload(&mut self, backend: &AdminBackend<'_>) {
1256        match load_resource_entries(backend) {
1257            Ok(entries) => {
1258                self.entries = entries;
1259                self.selected = 0;
1260                self.last_error = None;
1261            }
1262            Err(err) => {
1263                self.entries.clear();
1264                self.selected = 0;
1265                self.last_error = Some(err.to_string());
1266            }
1267        }
1268    }
1269
1270    fn search_term(&self) -> Option<&str> {
1271        self.search
1272            .as_deref()
1273            .filter(|value| !value.trim().is_empty())
1274    }
1275
1276    fn filtered_indices(&self) -> Vec<usize> {
1277        let Some(term) = self.search_term() else {
1278            return (0..self.entries.len()).collect();
1279        };
1280        let term = term.to_lowercase();
1281        let mut include = vec![false; self.entries.len()];
1282        for (idx, entry) in self.entries.iter().enumerate() {
1283            if entry.label.to_lowercase().contains(&term) {
1284                include[idx] = true;
1285                let mut depth = entry.depth;
1286                if depth == 0 {
1287                    continue;
1288                }
1289                for parent_idx in (0..idx).rev() {
1290                    let parent = &self.entries[parent_idx];
1291                    if parent.depth < depth {
1292                        include[parent_idx] = true;
1293                        depth = parent.depth;
1294                        if depth == 0 {
1295                            break;
1296                        }
1297                    }
1298                }
1299            }
1300        }
1301        include
1302            .iter()
1303            .enumerate()
1304            .filter_map(|(idx, keep)| if *keep { Some(idx) } else { None })
1305            .collect()
1306    }
1307
1308    fn filtered_entries(&self) -> Vec<ResourceEntry> {
1309        let indices = self.filtered_indices();
1310        indices
1311            .iter()
1312            .filter_map(|idx| self.entries.get(*idx))
1313            .cloned()
1314            .collect()
1315    }
1316
1317    fn selected_entry(&self) -> Option<ResourceEntry> {
1318        let indices = self.filtered_indices();
1319        let idx = indices.get(self.selected).copied()?;
1320        self.entries.get(idx).cloned()
1321    }
1322
1323    fn ensure_selection_in_range(&mut self) {
1324        let len = self.filtered_indices().len();
1325        if len == 0 {
1326            self.selected = 0;
1327        } else if self.selected >= len {
1328            self.selected = len - 1;
1329        }
1330    }
1331
1332    fn move_up(&mut self) {
1333        if self.selected > 0 {
1334            self.selected -= 1;
1335        }
1336    }
1337
1338    fn move_down(&mut self) {
1339        let len = self.filtered_indices().len();
1340        if self.selected + 1 < len {
1341            self.selected += 1;
1342        }
1343    }
1344
1345    fn move_top(&mut self) {
1346        self.selected = 0;
1347    }
1348
1349    fn move_bottom(&mut self) {
1350        let len = self.filtered_indices().len();
1351        if len > 0 {
1352            self.selected = len - 1;
1353        }
1354    }
1355
1356    fn page_down(&mut self) {
1357        let len = self.filtered_indices().len();
1358        if len == 0 {
1359            return;
1360        }
1361        self.selected = (self.selected + 5).min(len - 1);
1362    }
1363
1364    fn page_up(&mut self) {
1365        self.selected = self.selected.saturating_sub(5);
1366    }
1367
1368    fn push_search(&mut self, ch: char) {
1369        let search = self.search.get_or_insert_with(String::new);
1370        search.push(ch);
1371        self.ensure_selection_in_range();
1372    }
1373
1374    fn pop_search(&mut self) {
1375        if let Some(search) = self.search.as_mut() {
1376            if !search.is_empty() {
1377                search.pop();
1378            } else {
1379                self.reset_search();
1380            }
1381        }
1382        self.ensure_selection_in_range();
1383    }
1384
1385    fn reset_search(&mut self) {
1386        self.search = None;
1387        self.search_focused = false;
1388        self.selected = 0;
1389    }
1390}
1391
1392fn build_form_fields(target: AdminTarget, action: AdminAction) -> Vec<AdminFormField> {
1393    match (target, action) {
1394        (AdminTarget::Sql, AdminAction::Read) => vec![
1395            form_field("query", "Query", "", "SELECT * FROM table", false),
1396            form_field_with_list(
1397                "table",
1398                "Table",
1399                "",
1400                "mytable",
1401                false,
1402                Some(ListSource::SqlTables),
1403            ),
1404            form_field_with_list(
1405                "columns",
1406                "Columns",
1407                "",
1408                "col1,col2",
1409                false,
1410                Some(ListSource::SqlColumns),
1411            ),
1412        ],
1413        (AdminTarget::Sql, _) => vec![form_field(
1414            "query",
1415            "Query",
1416            "",
1417            "SELECT * FROM table",
1418            true,
1419        )],
1420        (AdminTarget::Kv, AdminAction::Read) => vec![
1421            form_field_with_list("key", "Key", "", "mykey", false, Some(ListSource::KvKeys)),
1422            form_field("prefix", "Prefix", "", "app/", false),
1423        ],
1424        (AdminTarget::Kv, AdminAction::Create | AdminAction::Update) => vec![
1425            form_field_with_list("key", "Key", "", "mykey", true, Some(ListSource::KvKeys)),
1426            form_field("value", "Value", "", "hello", true),
1427        ],
1428        (AdminTarget::Kv, AdminAction::Delete) => vec![form_field_with_list(
1429            "key",
1430            "Key",
1431            "",
1432            "mykey",
1433            true,
1434            Some(ListSource::KvKeys),
1435        )],
1436        (AdminTarget::Vector, AdminAction::Read) => vec![
1437            form_field("index", "Index", "", "myindex", true),
1438            form_field("query", "Query", "", "[0.1, 0.2]", true),
1439            form_field("k", "Top K", "10", "10", false),
1440        ],
1441        (AdminTarget::Vector, AdminAction::Create | AdminAction::Update) => vec![
1442            form_field("index", "Index", "", "myindex", true),
1443            form_field("key", "Key", "", "item1", true),
1444            form_field("vector", "Vector", "", "[0.1, 0.2]", true),
1445        ],
1446        (AdminTarget::Vector, AdminAction::Delete) => vec![
1447            form_field("index", "Index", "", "myindex", true),
1448            form_field("key", "Key", "", "item1", true),
1449        ],
1450        (AdminTarget::Hnsw, AdminAction::Read) => {
1451            vec![form_field("name", "Index", "", "myindex", true)]
1452        }
1453        (AdminTarget::Hnsw, AdminAction::Create) => vec![
1454            form_field("name", "Index", "", "myindex", true),
1455            form_field("dim", "Dimensions", "", "128", true),
1456            form_field("metric", "Metric", "cosine", "cosine", false),
1457        ],
1458        (AdminTarget::Hnsw, AdminAction::Delete) => {
1459            vec![form_field("name", "Index", "", "myindex", true)]
1460        }
1461        (AdminTarget::Columnar, AdminAction::Read) => vec![
1462            form_field("mode", "Mode", "list", "list|scan|stats|index_list", true),
1463            form_field_with_list(
1464                "segment",
1465                "Segment",
1466                "",
1467                "segment_id",
1468                false,
1469                Some(ListSource::ColumnarSegments),
1470            ),
1471        ],
1472        (AdminTarget::Columnar, AdminAction::Create) => vec![
1473            form_field("file", "File", "", "data.csv", false),
1474            form_field_with_list(
1475                "table",
1476                "Table",
1477                "",
1478                "mytable",
1479                false,
1480                Some(ListSource::SqlTables),
1481            ),
1482            form_field_with_list(
1483                "segment",
1484                "Segment",
1485                "",
1486                "segment_id",
1487                false,
1488                Some(ListSource::ColumnarSegments),
1489            ),
1490            form_field_with_list(
1491                "column",
1492                "Column",
1493                "",
1494                "column_name",
1495                false,
1496                Some(ListSource::ColumnarColumns),
1497            ),
1498            form_field("index_type", "Index Type", "", "minmax", false),
1499        ],
1500        (AdminTarget::Columnar, AdminAction::Delete) => vec![
1501            form_field_with_list(
1502                "segment",
1503                "Segment",
1504                "",
1505                "segment_id",
1506                true,
1507                Some(ListSource::ColumnarSegments),
1508            ),
1509            form_field_with_list(
1510                "column",
1511                "Column",
1512                "",
1513                "column_name",
1514                true,
1515                Some(ListSource::ColumnarColumns),
1516            ),
1517        ],
1518        _ => Vec::new(),
1519    }
1520}
1521
1522fn form_field(
1523    key: &'static str,
1524    label: &'static str,
1525    value: &str,
1526    placeholder: &'static str,
1527    required: bool,
1528) -> AdminFormField {
1529    form_field_with_list(key, label, value, placeholder, required, None)
1530}
1531
1532fn form_field_with_list(
1533    key: &'static str,
1534    label: &'static str,
1535    value: &str,
1536    placeholder: &'static str,
1537    required: bool,
1538    list_source: Option<ListSource>,
1539) -> AdminFormField {
1540    AdminFormField {
1541        key,
1542        label,
1543        value: value.to_string(),
1544        placeholder,
1545        required,
1546        list_source,
1547    }
1548}
1549
1550fn load_list_options(
1551    backend: &AdminBackend<'_>,
1552    fields: &[AdminFormField],
1553    source: ListSource,
1554) -> Result<Vec<String>> {
1555    match source {
1556        ListSource::KvKeys => {
1557            let prefix = field_value(fields, "prefix");
1558            let command = AdminCommand::Kv(KvCommand::List { prefix });
1559            let capture = capture_admin_command(backend, command, Some(50))?;
1560            Ok(extract_column_values(
1561                &capture.columns,
1562                &capture.rows,
1563                "key",
1564            ))
1565        }
1566        ListSource::ColumnarSegments => {
1567            let command = AdminCommand::Columnar(ColumnarCommand::List);
1568            let capture = capture_admin_command(backend, command, Some(50))?;
1569            Ok(extract_column_values(
1570                &capture.columns,
1571                &capture.rows,
1572                "segment_id",
1573            ))
1574        }
1575        ListSource::SqlTables => {
1576            let Some(db) = backend.local_db() else {
1577                return Err(CliError::InvalidArgument(
1578                    "Table listing is only available for local admin sessions.".to_string(),
1579                ));
1580            };
1581            let mut tables = db
1582                .list_tables_simple()?
1583                .into_iter()
1584                .map(|table| table.name)
1585                .collect::<Vec<_>>();
1586            tables.sort();
1587            tables.dedup();
1588            Ok(tables)
1589        }
1590        ListSource::SqlColumns => {
1591            let table = field_value(fields, "table")
1592                .ok_or_else(|| CliError::InvalidArgument("Select a table first.".to_string()))?;
1593            let Some(db) = backend.local_db() else {
1594                return Err(CliError::InvalidArgument(
1595                    "Column listing is only available for local admin sessions.".to_string(),
1596                ));
1597            };
1598            let mut columns = db
1599                .get_table_info_simple(&table)?
1600                .columns
1601                .into_iter()
1602                .map(|column| column.name)
1603                .collect::<Vec<_>>();
1604            columns.sort();
1605            columns.dedup();
1606            Ok(columns)
1607        }
1608        ListSource::ColumnarColumns => {
1609            let segment = field_value(fields, "segment")
1610                .ok_or_else(|| CliError::InvalidArgument("Select a segment first.".to_string()))?;
1611            let Some(db) = backend.local_db() else {
1612                return Err(CliError::InvalidArgument(
1613                    "Column listing is only available for local admin sessions.".to_string(),
1614                ));
1615            };
1616            let mut columns = list_columnar_columns_from_segment(db, &segment)?;
1617            columns.sort();
1618            columns.dedup();
1619            Ok(columns)
1620        }
1621    }
1622}
1623
1624fn field_value(fields: &[AdminFormField], key: &str) -> Option<String> {
1625    fields
1626        .iter()
1627        .find(|field| field.key.eq_ignore_ascii_case(key))
1628        .map(|field| field.value.trim().to_string())
1629        .filter(|value| !value.is_empty())
1630}
1631
1632fn capture_admin_command(
1633    backend: &AdminBackend<'_>,
1634    command: AdminCommand,
1635    limit: Option<usize>,
1636) -> Result<CaptureState> {
1637    let request = AdminRequest {
1638        action: AdminAction::Read,
1639        command,
1640        limit,
1641        quiet: true,
1642        ui_mode: UiMode::Batch,
1643        connection_label: String::new(),
1644        output: backend.output_format(),
1645        data_dir: backend.data_dir().map(PathBuf::from),
1646    };
1647    let (formatter, state) = CaptureFormatter::new();
1648    let mut sink = io::sink();
1649    let result = match backend {
1650        AdminBackend::Local { db, batch_mode, .. } => {
1651            execute_local_action(db, batch_mode, request, &mut sink, Box::new(formatter))
1652        }
1653        AdminBackend::Remote {
1654            client, batch_mode, ..
1655        } => {
1656            let runtime = Runtime::new().map_err(|err| {
1657                CliError::InvalidArgument(format!("Failed to start async runtime: {err}"))
1658            })?;
1659            runtime.block_on(execute_remote_action(
1660                client,
1661                batch_mode,
1662                request,
1663                &mut sink,
1664                Box::new(formatter),
1665            ))
1666        }
1667    };
1668    result?;
1669    let capture = state.lock().expect("admin capture lock").clone();
1670    Ok(capture)
1671}
1672
1673fn extract_column_values(columns: &[Column], rows: &[Row], column_name: &str) -> Vec<String> {
1674    let index = columns
1675        .iter()
1676        .position(|column| column.name.eq_ignore_ascii_case(column_name))
1677        .unwrap_or(0);
1678    rows.iter()
1679        .filter_map(|row| row.columns.get(index))
1680        .map(value_to_string)
1681        .collect()
1682}
1683
1684fn list_columnar_columns_from_segment(
1685    db: &alopex_embedded::Database,
1686    segment_id: &str,
1687) -> Result<Vec<String>> {
1688    let (table_id, segment_id) = parse_segment_id(segment_id).ok_or_else(|| {
1689        CliError::InvalidArgument(
1690            "Invalid segment id. Expected format: table_id:segment_id.".to_string(),
1691        )
1692    })?;
1693    let tables = db.list_tables_simple()?;
1694    let table = tables
1695        .into_iter()
1696        .find(|table| table.table_id == table_id)
1697        .ok_or_else(|| {
1698            CliError::InvalidArgument("Unable to resolve table for segment.".to_string())
1699        })?;
1700    let batches = db.read_columnar_segment(&table.name, segment_id, None)?;
1701    let batch = batches
1702        .first()
1703        .ok_or_else(|| CliError::InvalidArgument("Columnar segment is empty.".to_string()))?;
1704    Ok(batch
1705        .schema
1706        .columns
1707        .iter()
1708        .map(|column| column.name.clone())
1709        .collect())
1710}
1711
1712fn parse_segment_id(segment_id: &str) -> Option<(u32, u64)> {
1713    let (table_id, segment_id) = segment_id.split_once(':')?;
1714    let table_id = table_id.parse::<u32>().ok()?;
1715    let segment_id = segment_id.parse::<u64>().ok()?;
1716    Some((table_id, segment_id))
1717}
1718
1719const RESOURCE_LIMIT: usize = 50;
1720const COLUMNAR_COLUMN_LIMIT: usize = 20;
1721
1722fn load_resource_entries(backend: &AdminBackend<'_>) -> Result<Vec<ResourceEntry>> {
1723    let mut entries = Vec::new();
1724    entries.extend(load_sql_resources(backend)?);
1725    entries.extend(load_columnar_resources(backend)?);
1726    entries.extend(load_kv_resources(backend)?);
1727    Ok(entries)
1728}
1729
1730fn load_sql_resources(backend: &AdminBackend<'_>) -> Result<Vec<ResourceEntry>> {
1731    let mut entries = Vec::new();
1732    entries.push(ResourceEntry {
1733        label: "SQL Tables".to_string(),
1734        kind: ResourceKind::Section(ResourceSection::SqlTables),
1735        depth: 0,
1736        selectable: false,
1737    });
1738    let Some(db) = backend.local_db() else {
1739        entries.push(ResourceEntry {
1740            label: "Remote listing unavailable".to_string(),
1741            kind: ResourceKind::Info,
1742            depth: 1,
1743            selectable: false,
1744        });
1745        return Ok(entries);
1746    };
1747    let mut tables = match db.list_tables_simple() {
1748        Ok(tables) => tables,
1749        Err(alopex_embedded::Error::CatalogNotFound(_))
1750        | Err(alopex_embedded::Error::NamespaceNotFound(_, _)) => {
1751            let _ = db.create_catalog(CreateCatalogRequest::new("default"));
1752            let _ = db.create_namespace(CreateNamespaceRequest::new("default", "default"));
1753            match db.list_tables_simple() {
1754                Ok(tables) => tables,
1755                Err(err) => {
1756                    entries.push(ResourceEntry {
1757                        label: format!("SQL catalog unavailable: {err}"),
1758                        kind: ResourceKind::Info,
1759                        depth: 1,
1760                        selectable: false,
1761                    });
1762                    return Ok(entries);
1763                }
1764            }
1765        }
1766        Err(err) => return Err(err.into()),
1767    };
1768    tables.sort_by(|a, b| a.name.cmp(&b.name));
1769    for table in tables.into_iter().take(RESOURCE_LIMIT) {
1770        entries.push(ResourceEntry {
1771            label: table.name.clone(),
1772            kind: ResourceKind::Table {
1773                name: table.name.clone(),
1774            },
1775            depth: 1,
1776            selectable: true,
1777        });
1778        for column in table.columns {
1779            entries.push(ResourceEntry {
1780                label: column.name.clone(),
1781                kind: ResourceKind::Column {
1782                    table: table.name.clone(),
1783                    name: column.name,
1784                },
1785                depth: 2,
1786                selectable: true,
1787            });
1788        }
1789    }
1790    Ok(entries)
1791}
1792
1793fn load_columnar_resources(backend: &AdminBackend<'_>) -> Result<Vec<ResourceEntry>> {
1794    let mut entries = Vec::new();
1795    entries.push(ResourceEntry {
1796        label: "Columnar Segments".to_string(),
1797        kind: ResourceKind::Section(ResourceSection::ColumnarSegments),
1798        depth: 0,
1799        selectable: false,
1800    });
1801    let Some(db) = backend.local_db() else {
1802        entries.push(ResourceEntry {
1803            label: "Remote listing unavailable".to_string(),
1804            kind: ResourceKind::Info,
1805            depth: 1,
1806            selectable: false,
1807        });
1808        return Ok(entries);
1809    };
1810    let mut segments = db.list_columnar_segments()?;
1811    segments.sort();
1812    let mut expanded = 0;
1813    for segment in segments.into_iter().take(RESOURCE_LIMIT) {
1814        entries.push(ResourceEntry {
1815            label: segment.clone(),
1816            kind: ResourceKind::ColumnarSegment {
1817                id: segment.clone(),
1818            },
1819            depth: 1,
1820            selectable: true,
1821        });
1822        if expanded < COLUMNAR_COLUMN_LIMIT {
1823            if let Ok(columns) = list_columnar_columns_from_segment(db, &segment) {
1824                for column in columns {
1825                    entries.push(ResourceEntry {
1826                        label: column.clone(),
1827                        kind: ResourceKind::ColumnarColumn {
1828                            segment_id: segment.clone(),
1829                            name: column,
1830                        },
1831                        depth: 2,
1832                        selectable: true,
1833                    });
1834                }
1835            }
1836            expanded += 1;
1837        }
1838    }
1839    Ok(entries)
1840}
1841
1842fn load_kv_resources(backend: &AdminBackend<'_>) -> Result<Vec<ResourceEntry>> {
1843    let mut entries = Vec::new();
1844    entries.push(ResourceEntry {
1845        label: "KV Keys".to_string(),
1846        kind: ResourceKind::Section(ResourceSection::KvKeys),
1847        depth: 0,
1848        selectable: false,
1849    });
1850    let system_prefixes = [
1851        "__catalog__/",
1852        "hnsw:",
1853        "__alopex_",
1854        "__alopex:",
1855        "vector:",
1856        "columnar:",
1857    ];
1858    let command = AdminCommand::Kv(KvCommand::List { prefix: None });
1859    let capture = capture_admin_command(backend, command, Some(RESOURCE_LIMIT))?;
1860    let keys = extract_column_values(&capture.columns, &capture.rows, "key");
1861    for key in keys.into_iter().filter(|key| {
1862        !system_prefixes.iter().any(|prefix| key.starts_with(prefix))
1863            && !key.trim().is_empty()
1864            && !key.chars().any(|ch| ch.is_control())
1865    }) {
1866        entries.push(ResourceEntry {
1867            label: key.clone(),
1868            kind: ResourceKind::KvKey { key },
1869            depth: 1,
1870            selectable: true,
1871        });
1872    }
1873    Ok(entries)
1874}
1875
1876fn build_params_from_fields(
1877    fields: &[AdminFormField],
1878) -> std::collections::HashMap<String, String> {
1879    let mut params = std::collections::HashMap::new();
1880    for field in fields {
1881        if !field.value.trim().is_empty() {
1882            params.insert(field.key.to_lowercase(), field.value.trim().to_string());
1883        }
1884    }
1885    params
1886}
1887
1888fn validate_params(
1889    action: AdminAction,
1890    target: AdminTarget,
1891    params: &std::collections::HashMap<String, String>,
1892) -> std::result::Result<(), String> {
1893    match (target, action) {
1894        (AdminTarget::Sql, AdminAction::Read) => {
1895            if params.contains_key("query") {
1896                return Ok(());
1897            }
1898            if let Some(columns) = params.get("columns") {
1899                if !params.contains_key("table") {
1900                    return Err("Provide table to use columns.".to_string());
1901                }
1902                if columns.trim().is_empty() {
1903                    return Err("Columns cannot be empty when provided.".to_string());
1904                }
1905            }
1906            if params.contains_key("table") {
1907                Ok(())
1908            } else {
1909                Err("Provide query or table.".to_string())
1910            }
1911        }
1912        (AdminTarget::Kv, AdminAction::Read) => {
1913            if params.contains_key("key") || params.contains_key("prefix") {
1914                Ok(())
1915            } else {
1916                Err("Provide either key or prefix.".to_string())
1917            }
1918        }
1919        (AdminTarget::Columnar, AdminAction::Read) => {
1920            let mode = params.get("mode").map(|v| v.as_str()).unwrap_or("list");
1921            if matches!(mode, "scan" | "stats" | "index_list") && !params.contains_key("segment") {
1922                Err("Provide segment for scan/stats/index_list.".to_string())
1923            } else {
1924                Ok(())
1925            }
1926        }
1927        (AdminTarget::Columnar, AdminAction::Create) => {
1928            let has_ingest = params.contains_key("file") && params.contains_key("table");
1929            let has_index = params.contains_key("segment")
1930                && params.contains_key("column")
1931                && params.contains_key("index_type");
1932            if has_ingest || has_index {
1933                Ok(())
1934            } else {
1935                Err("Provide file+table or segment+column+index_type.".to_string())
1936            }
1937        }
1938        _ => {
1939            let missing = required_keys_for(target, action)
1940                .into_iter()
1941                .filter(|key| !params.contains_key(*key))
1942                .collect::<Vec<_>>();
1943            if missing.is_empty() {
1944                Ok(())
1945            } else {
1946                Err(format!("Missing: {}", missing.join(", ")))
1947            }
1948        }
1949    }
1950}
1951
1952fn required_keys_for(target: AdminTarget, action: AdminAction) -> Vec<&'static str> {
1953    match (target, action) {
1954        (AdminTarget::Sql, AdminAction::Read) => Vec::new(),
1955        (AdminTarget::Sql, _) => vec!["query"],
1956        (AdminTarget::Kv, AdminAction::Create | AdminAction::Update) => vec!["key", "value"],
1957        (AdminTarget::Kv, AdminAction::Delete) => vec!["key"],
1958        (AdminTarget::Vector, AdminAction::Read) => vec!["index", "query"],
1959        (AdminTarget::Vector, AdminAction::Create | AdminAction::Update) => {
1960            vec!["index", "key", "vector"]
1961        }
1962        (AdminTarget::Vector, AdminAction::Delete) => vec!["index", "key"],
1963        (AdminTarget::Hnsw, AdminAction::Read) => vec!["name"],
1964        (AdminTarget::Hnsw, AdminAction::Create) => vec!["name", "dim"],
1965        (AdminTarget::Hnsw, AdminAction::Delete) => vec!["name"],
1966        (AdminTarget::Columnar, AdminAction::Delete) => vec!["segment", "column"],
1967        _ => Vec::new(),
1968    }
1969}
1970
1971#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1972pub enum AdminTarget {
1973    Sql,
1974    Kv,
1975    Vector,
1976    Hnsw,
1977    Columnar,
1978}
1979
1980impl AdminTarget {
1981    fn label(self) -> &'static str {
1982        match self {
1983            AdminTarget::Sql => "SQL",
1984            AdminTarget::Kv => "KV",
1985            AdminTarget::Vector => "Vector",
1986            AdminTarget::Hnsw => "HNSW",
1987            AdminTarget::Columnar => "Columnar",
1988        }
1989    }
1990
1991    fn example_for(self, action: AdminAction) -> Option<&'static str> {
1992        match (self, action) {
1993            (AdminTarget::Sql, _) => Some("query=\"SELECT * FROM table\""),
1994            (AdminTarget::Kv, AdminAction::Read) => Some("key=mykey OR prefix=app/"),
1995            (AdminTarget::Kv, AdminAction::Create | AdminAction::Update) => {
1996                Some("key=mykey value=hello")
1997            }
1998            (AdminTarget::Kv, AdminAction::Delete) => Some("key=mykey"),
1999            (AdminTarget::Vector, AdminAction::Read) => {
2000                Some("index=myindex query=\"[0.1, 0.2]\" k=10")
2001            }
2002            (AdminTarget::Vector, AdminAction::Create | AdminAction::Update) => {
2003                Some("index=myindex key=item1 vector=\"[0.1, 0.2]\"")
2004            }
2005            (AdminTarget::Vector, AdminAction::Delete) => Some("index=myindex key=item1"),
2006            (AdminTarget::Hnsw, AdminAction::Read) => Some("name=myindex"),
2007            (AdminTarget::Hnsw, AdminAction::Create) => Some("name=myindex dim=128 metric=cosine"),
2008            (AdminTarget::Hnsw, AdminAction::Delete) => Some("name=myindex"),
2009            (AdminTarget::Columnar, AdminAction::Read) => Some("mode=list"),
2010            (AdminTarget::Columnar, AdminAction::Create) => Some("file=data.csv table=mytable"),
2011            (AdminTarget::Columnar, AdminAction::Delete) => Some("segment=seg1 column=col1"),
2012            _ => None,
2013        }
2014    }
2015}
2016
2017pub enum AdminBackend<'a> {
2018    Local {
2019        db: &'a alopex_embedded::Database,
2020        batch_mode: &'a BatchMode,
2021        output_format: OutputFormat,
2022        limit: Option<usize>,
2023        quiet: bool,
2024        data_dir: Option<PathBuf>,
2025    },
2026    Remote {
2027        client: &'a HttpClient,
2028        batch_mode: &'a BatchMode,
2029        output_format: OutputFormat,
2030        limit: Option<usize>,
2031        quiet: bool,
2032        data_dir: Option<PathBuf>,
2033    },
2034}
2035
2036impl AdminBackend<'_> {
2037    fn local_db(&self) -> Option<&alopex_embedded::Database> {
2038        match self {
2039            AdminBackend::Local { db, .. } => Some(*db),
2040            AdminBackend::Remote { .. } => None,
2041        }
2042    }
2043
2044    fn output_format(&self) -> OutputFormat {
2045        match self {
2046            AdminBackend::Local { output_format, .. } => *output_format,
2047            AdminBackend::Remote { output_format, .. } => *output_format,
2048        }
2049    }
2050
2051    fn data_dir(&self) -> Option<&Path> {
2052        match self {
2053            AdminBackend::Local { data_dir, .. } => data_dir.as_deref(),
2054            AdminBackend::Remote { data_dir, .. } => data_dir.as_deref(),
2055        }
2056    }
2057
2058    fn limit(&self) -> Option<usize> {
2059        match self {
2060            AdminBackend::Local { limit, .. } => *limit,
2061            AdminBackend::Remote { limit, .. } => *limit,
2062        }
2063    }
2064
2065    fn quiet(&self) -> bool {
2066        match self {
2067            AdminBackend::Local { quiet, .. } => *quiet,
2068            AdminBackend::Remote { quiet, .. } => *quiet,
2069        }
2070    }
2071}
2072
2073pub struct AdminContext<'a> {
2074    pub connection_label: String,
2075    pub auth: AuthCapabilities,
2076    pub backend: AdminBackend<'a>,
2077    pub initial_target: Option<AdminTarget>,
2078}
2079
2080pub fn run_admin_ui(context: AdminContext<'_>) -> Result<()> {
2081    if !is_tty() {
2082        let mut writer = io::stdout().lock();
2083        return write_non_tty_fallback(&mut writer, context.backend.output_format());
2084    }
2085
2086    let app = AdminApp::new(
2087        context.connection_label,
2088        context.auth,
2089        context.backend,
2090        context.initial_target,
2091    );
2092    app.run()
2093}
2094
2095pub fn write_non_tty_fallback<W: Write>(writer: &mut W, output_format: OutputFormat) -> Result<()> {
2096    let mut formatter = create_formatter(output_format);
2097    let columns = vec![
2098        Column::new("Status", DataType::Text),
2099        Column::new("Message", DataType::Text),
2100    ];
2101    let rows = vec![Row::new(vec![
2102        Value::Text("Error".to_string()),
2103        Value::Text("Admin UI is unavailable without a TTY.".to_string()),
2104    ])];
2105    formatter.write_header(writer, &columns)?;
2106    for row in &rows {
2107        formatter.write_row(writer, row)?;
2108    }
2109    formatter.write_footer(writer)
2110}
2111
2112fn default_items() -> Vec<AdminItem> {
2113    vec![
2114        AdminItem {
2115            action: AdminAction::Read,
2116            title: "Read / List",
2117            description: "Browse or query data across databases, tables, and indexes.",
2118            enabled: true,
2119        },
2120        AdminItem {
2121            action: AdminAction::Create,
2122            title: "Create",
2123            description: "Create new databases, tables, indexes, or data objects.",
2124            enabled: true,
2125        },
2126        AdminItem {
2127            action: AdminAction::Update,
2128            title: "Update",
2129            description: "Modify existing records, schemas, or index settings.",
2130            enabled: true,
2131        },
2132        AdminItem {
2133            action: AdminAction::Delete,
2134            title: "Delete",
2135            description: "Remove records, tables, indexes, or data sets.",
2136            enabled: true,
2137        },
2138        AdminItem {
2139            action: AdminAction::Archive,
2140            title: "Archive",
2141            description: "Move data into an archived state for long-term retention.",
2142            enabled: true,
2143        },
2144        AdminItem {
2145            action: AdminAction::Restore,
2146            title: "Restore",
2147            description: "Restore archived data into an active state.",
2148            enabled: true,
2149        },
2150        AdminItem {
2151            action: AdminAction::Backup,
2152            title: "Backup",
2153            description: "Create snapshots or backups of data and metadata.",
2154            enabled: true,
2155        },
2156        AdminItem {
2157            action: AdminAction::Export,
2158            title: "Export",
2159            description: "Export data for external systems or offline analysis.",
2160            enabled: true,
2161        },
2162    ]
2163}
2164
2165fn is_not_implemented(action: AdminAction) -> bool {
2166    let _ = action;
2167    false
2168}
2169
2170fn parse_params(input: &str) -> std::collections::HashMap<String, String> {
2171    let mut params = std::collections::HashMap::new();
2172    let mut token = String::new();
2173    let mut in_quotes: Option<char> = None;
2174    for ch in input.chars() {
2175        if let Some(quote) = in_quotes {
2176            if ch == quote {
2177                in_quotes = None;
2178            } else {
2179                token.push(ch);
2180            }
2181            continue;
2182        }
2183        match ch {
2184            '"' | '\'' => {
2185                in_quotes = Some(ch);
2186            }
2187            ' ' | '\t' | '\n' | '\r' => {
2188                push_param_token(&mut params, &token);
2189                token.clear();
2190            }
2191            _ => token.push(ch),
2192        }
2193    }
2194    push_param_token(&mut params, &token);
2195    params
2196}
2197
2198fn push_param_token(params: &mut std::collections::HashMap<String, String>, token: &str) {
2199    if let Some((key, value)) = token.split_once('=') {
2200        if !key.is_empty() && !value.is_empty() {
2201            params.insert(key.to_lowercase(), value.to_string());
2202        }
2203    }
2204}
2205
2206fn build_command_for(
2207    action: AdminAction,
2208    target: AdminTarget,
2209    params: &std::collections::HashMap<String, String>,
2210) -> Result<Option<AdminCommand>> {
2211    if matches!(
2212        action,
2213        AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export
2214    ) {
2215        let command = match action {
2216            AdminAction::Archive => LifecycleCommand::Archive,
2217            AdminAction::Restore => LifecycleCommand::Restore,
2218            AdminAction::Backup => LifecycleCommand::Backup,
2219            AdminAction::Export => LifecycleCommand::Export,
2220            _ => return Ok(None),
2221        };
2222        return Ok(Some(AdminCommand::Lifecycle(command)));
2223    }
2224    match target {
2225        AdminTarget::Sql => build_sql_command(action, params),
2226        AdminTarget::Kv => build_kv_command(action, params),
2227        AdminTarget::Vector => build_vector_command(action, params),
2228        AdminTarget::Hnsw => build_hnsw_command(action, params),
2229        AdminTarget::Columnar => build_columnar_command(action, params),
2230    }
2231}
2232
2233fn build_sql_command(
2234    action: AdminAction,
2235    params: &std::collections::HashMap<String, String>,
2236) -> Result<Option<AdminCommand>> {
2237    let query = if let Some(query) = params.get("query").cloned() {
2238        Some(query)
2239    } else if action == AdminAction::Read {
2240        let table = params.get("table").cloned();
2241        let columns = params.get("columns").cloned();
2242        match table {
2243            Some(table) => {
2244                let columns = columns
2245                    .filter(|value| !value.trim().is_empty())
2246                    .unwrap_or_else(|| "*".to_string());
2247                Some(format!("SELECT {} FROM {}", columns, table))
2248            }
2249            None => None,
2250        }
2251    } else {
2252        None
2253    };
2254    if query.is_none() {
2255        return Ok(None);
2256    }
2257    Ok(Some(AdminCommand::Sql(SqlCommand {
2258        query,
2259        file: None,
2260        fetch_size: None,
2261        max_rows: None,
2262        deadline: None,
2263        tui: false,
2264    })))
2265}
2266
2267fn build_kv_command(
2268    action: AdminAction,
2269    params: &std::collections::HashMap<String, String>,
2270) -> Result<Option<AdminCommand>> {
2271    match action {
2272        AdminAction::Read => {
2273            if let Some(key) = params.get("key") {
2274                return Ok(Some(AdminCommand::Kv(KvCommand::Get { key: key.clone() })));
2275            }
2276            let prefix = params.get("prefix").cloned();
2277            Ok(Some(AdminCommand::Kv(KvCommand::List { prefix })))
2278        }
2279        AdminAction::Create | AdminAction::Update => {
2280            let key = params.get("key").cloned();
2281            let value = params.get("value").cloned();
2282            match (key, value) {
2283                (Some(key), Some(value)) => {
2284                    Ok(Some(AdminCommand::Kv(KvCommand::Put { key, value })))
2285                }
2286                _ => Ok(None),
2287            }
2288        }
2289        AdminAction::Delete => {
2290            let key = params.get("key").cloned();
2291            match key {
2292                Some(key) => Ok(Some(AdminCommand::Kv(KvCommand::Delete { key }))),
2293                None => Ok(None),
2294            }
2295        }
2296        AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2297            Ok(None)
2298        }
2299    }
2300}
2301
2302fn build_vector_command(
2303    action: AdminAction,
2304    params: &std::collections::HashMap<String, String>,
2305) -> Result<Option<AdminCommand>> {
2306    let index = params.get("index").cloned();
2307    match action {
2308        AdminAction::Read => {
2309            let query = params.get("query").cloned();
2310            let index = match index {
2311                Some(index) => index,
2312                None => return Ok(None),
2313            };
2314            let query = match query {
2315                Some(query) => query,
2316                None => return Ok(None),
2317            };
2318            let k = params
2319                .get("k")
2320                .and_then(|value| value.parse::<usize>().ok())
2321                .unwrap_or(10);
2322            Ok(Some(AdminCommand::Vector(VectorCommand::Search {
2323                index,
2324                query,
2325                k,
2326                progress: false,
2327            })))
2328        }
2329        AdminAction::Create | AdminAction::Update => {
2330            let key = params.get("key").cloned();
2331            let vector = params.get("vector").cloned();
2332            match (index, key, vector) {
2333                (Some(index), Some(key), Some(vector)) => {
2334                    Ok(Some(AdminCommand::Vector(VectorCommand::Upsert {
2335                        index,
2336                        key,
2337                        vector,
2338                    })))
2339                }
2340                _ => Ok(None),
2341            }
2342        }
2343        AdminAction::Delete => {
2344            let key = params.get("key").cloned();
2345            match (index, key) {
2346                (Some(index), Some(key)) => Ok(Some(AdminCommand::Vector(VectorCommand::Delete {
2347                    index,
2348                    key,
2349                }))),
2350                _ => Ok(None),
2351            }
2352        }
2353        AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2354            Ok(None)
2355        }
2356    }
2357}
2358
2359fn build_hnsw_command(
2360    action: AdminAction,
2361    params: &std::collections::HashMap<String, String>,
2362) -> Result<Option<AdminCommand>> {
2363    match action {
2364        AdminAction::Read => {
2365            let name = match params.get("name").cloned() {
2366                Some(name) => name,
2367                None => return Ok(None),
2368            };
2369            Ok(Some(AdminCommand::Hnsw(HnswCommand::Stats { name })))
2370        }
2371        AdminAction::Create => {
2372            let name = match params.get("name").cloned() {
2373                Some(name) => name,
2374                None => return Ok(None),
2375            };
2376            let dim = match params
2377                .get("dim")
2378                .and_then(|value| value.parse::<usize>().ok())
2379            {
2380                Some(dim) => dim,
2381                None => return Ok(None),
2382            };
2383            let metric = if let Some(value) = params.get("metric") {
2384                parse_metric(value).ok_or_else(|| {
2385                    CliError::InvalidArgument(
2386                        "Invalid metric. Use metric=cosine|l2|ip.".to_string(),
2387                    )
2388                })?
2389            } else {
2390                DistanceMetric::Cosine
2391            };
2392            Ok(Some(AdminCommand::Hnsw(HnswCommand::Create {
2393                name,
2394                dim,
2395                metric,
2396            })))
2397        }
2398        AdminAction::Delete => {
2399            let name = match params.get("name").cloned() {
2400                Some(name) => name,
2401                None => return Ok(None),
2402            };
2403            Ok(Some(AdminCommand::Hnsw(HnswCommand::Drop { name })))
2404        }
2405        AdminAction::Update => Err(CliError::InvalidArgument(
2406            "Update is not supported for HNSW targets.".to_string(),
2407        )),
2408        AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2409            Ok(None)
2410        }
2411    }
2412}
2413
2414fn build_columnar_command(
2415    action: AdminAction,
2416    params: &std::collections::HashMap<String, String>,
2417) -> Result<Option<AdminCommand>> {
2418    match action {
2419        AdminAction::Read => {
2420            let mode = params
2421                .get("mode")
2422                .map(|value| value.as_str())
2423                .unwrap_or("list");
2424            match mode {
2425                "scan" => {
2426                    let segment = match params.get("segment").cloned() {
2427                        Some(segment) => segment,
2428                        None => return Ok(None),
2429                    };
2430                    Ok(Some(AdminCommand::Columnar(ColumnarCommand::Scan {
2431                        segment,
2432                        progress: false,
2433                    })))
2434                }
2435                "stats" => {
2436                    let segment = match params.get("segment").cloned() {
2437                        Some(segment) => segment,
2438                        None => return Ok(None),
2439                    };
2440                    Ok(Some(AdminCommand::Columnar(ColumnarCommand::Stats {
2441                        segment,
2442                    })))
2443                }
2444                "index_list" => {
2445                    let segment = match params.get("segment").cloned() {
2446                        Some(segment) => segment,
2447                        None => return Ok(None),
2448                    };
2449                    Ok(Some(AdminCommand::Columnar(ColumnarCommand::Index(
2450                        IndexCommand::List { segment },
2451                    ))))
2452                }
2453                "list" => Ok(Some(AdminCommand::Columnar(ColumnarCommand::List))),
2454                _ => Err(CliError::InvalidArgument(
2455                    "Unknown columnar mode. Use mode=list|scan|stats|index_list.".to_string(),
2456                )),
2457            }
2458        }
2459        AdminAction::Create => {
2460            if let (Some(file), Some(table)) =
2461                (params.get("file").cloned(), params.get("table").cloned())
2462            {
2463                return Ok(Some(AdminCommand::Columnar(ColumnarCommand::Ingest {
2464                    file: std::path::PathBuf::from(file),
2465                    table,
2466                    delimiter: ',',
2467                    header: true,
2468                    compression: "lz4".to_string(),
2469                    row_group_size: None,
2470                })));
2471            }
2472            if let (Some(segment), Some(column), Some(index_type)) = (
2473                params.get("segment").cloned(),
2474                params.get("column").cloned(),
2475                params.get("index_type").cloned(),
2476            ) {
2477                return Ok(Some(AdminCommand::Columnar(ColumnarCommand::Index(
2478                    IndexCommand::Create {
2479                        segment,
2480                        column,
2481                        index_type,
2482                    },
2483                ))));
2484            }
2485            Ok(None)
2486        }
2487        AdminAction::Delete => {
2488            if let (Some(segment), Some(column)) = (
2489                params.get("segment").cloned(),
2490                params.get("column").cloned(),
2491            ) {
2492                return Ok(Some(AdminCommand::Columnar(ColumnarCommand::Index(
2493                    IndexCommand::Drop { segment, column },
2494                ))));
2495            }
2496            Ok(None)
2497        }
2498        AdminAction::Update => Err(CliError::InvalidArgument(
2499            "Update is not supported for columnar targets.".to_string(),
2500        )),
2501        AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2502            Ok(None)
2503        }
2504    }
2505}
2506
2507fn parse_metric(value: &str) -> Option<DistanceMetric> {
2508    match value.to_lowercase().as_str() {
2509        "cosine" => Some(DistanceMetric::Cosine),
2510        "l2" => Some(DistanceMetric::L2),
2511        "ip" => Some(DistanceMetric::Ip),
2512        _ => None,
2513    }
2514}
2515
2516fn render_help(frame: &mut ratatui::Frame<'_>, area: Rect) {
2517    let help_width = area.width.saturating_sub(4).min(60);
2518    let help_height = area.height.saturating_sub(4).min(12);
2519    let rect = Rect::new(
2520        area.x + (area.width.saturating_sub(help_width)) / 2,
2521        area.y + (area.height.saturating_sub(help_height)) / 2,
2522        help_width,
2523        help_height,
2524    );
2525
2526    let lines = [
2527        "h/l or Left/Right: move focus",
2528        "Menu: j/k move, / search, e edit, r raw, Enter select, R refresh",
2529        "Input: Up/Down action, Tab field, e edit, o list, r raw, Enter execute",
2530        "Data: j/k scroll",
2531        "a: back",
2532        "?: toggle help",
2533        "q/Esc: quit",
2534    ]
2535    .join("\n");
2536
2537    let help = Paragraph::new(lines)
2538        .block(Block::default().borders(Borders::ALL).title("Help"))
2539        .wrap(Wrap { trim: true });
2540    frame.render_widget(help, rect);
2541}
2542
2543#[derive(Debug, Clone)]
2544struct SelectionOverlay {
2545    title: String,
2546    items: Vec<String>,
2547    selected: usize,
2548    field_index: usize,
2549    search: Option<String>,
2550    search_focused: bool,
2551}
2552
2553impl SelectionOverlay {
2554    fn new(title: String, items: Vec<String>, field_index: usize) -> Self {
2555        Self {
2556            title,
2557            items,
2558            selected: 0,
2559            field_index,
2560            search: None,
2561            search_focused: false,
2562        }
2563    }
2564
2565    fn search_term(&self) -> Option<&str> {
2566        self.search
2567            .as_deref()
2568            .filter(|value| !value.trim().is_empty())
2569    }
2570
2571    fn filtered_indices(&self) -> Vec<usize> {
2572        let Some(term) = self.search_term() else {
2573            return (0..self.items.len()).collect();
2574        };
2575        let term = term.to_lowercase();
2576        self.items
2577            .iter()
2578            .enumerate()
2579            .filter_map(|(idx, item)| {
2580                if item.to_lowercase().contains(&term) {
2581                    Some(idx)
2582                } else {
2583                    None
2584                }
2585            })
2586            .collect()
2587    }
2588
2589    fn selected_value(&self) -> Option<String> {
2590        let indices = self.filtered_indices();
2591        let idx = indices.get(self.selected).copied()?;
2592        self.items.get(idx).cloned()
2593    }
2594
2595    fn ensure_selection_in_range(&mut self) {
2596        let len = self.filtered_indices().len();
2597        if len == 0 {
2598            self.selected = 0;
2599        } else if self.selected >= len {
2600            self.selected = len - 1;
2601        }
2602    }
2603
2604    fn move_up(&mut self) {
2605        if self.selected > 0 {
2606            self.selected -= 1;
2607        }
2608    }
2609
2610    fn move_down(&mut self) {
2611        let len = self.filtered_indices().len();
2612        if self.selected + 1 < len {
2613            self.selected += 1;
2614        }
2615    }
2616
2617    fn move_top(&mut self) {
2618        self.selected = 0;
2619    }
2620
2621    fn move_bottom(&mut self) {
2622        let len = self.filtered_indices().len();
2623        if len > 0 {
2624            self.selected = len - 1;
2625        }
2626    }
2627
2628    fn push_search(&mut self, ch: char) {
2629        let search = self.search.get_or_insert_with(String::new);
2630        search.push(ch);
2631        self.ensure_selection_in_range();
2632    }
2633
2634    fn pop_search(&mut self) {
2635        if let Some(search) = self.search.as_mut() {
2636            if !search.is_empty() {
2637                search.pop();
2638            } else {
2639                self.reset_search();
2640            }
2641        }
2642        self.ensure_selection_in_range();
2643    }
2644
2645    fn reset_search(&mut self) {
2646        self.search = None;
2647        self.search_focused = false;
2648        self.selected = 0;
2649    }
2650}
2651
2652fn render_selection_overlay(
2653    frame: &mut ratatui::Frame<'_>,
2654    area: Rect,
2655    selection: &SelectionOverlay,
2656) {
2657    let overlay_width = area.width.saturating_sub(6).min(60);
2658    let overlay_height = area.height.saturating_sub(6).min(16);
2659    let rect = Rect::new(
2660        area.x + (area.width.saturating_sub(overlay_width)) / 2,
2661        area.y + (area.height.saturating_sub(overlay_height)) / 2,
2662        overlay_width,
2663        overlay_height,
2664    );
2665
2666    let layout = Layout::default()
2667        .direction(Direction::Vertical)
2668        .constraints([
2669            Constraint::Length(1),
2670            Constraint::Length(1),
2671            Constraint::Min(3),
2672        ])
2673        .split(rect);
2674
2675    let search = selection
2676        .search
2677        .as_ref()
2678        .map(|value| format!("/ {value}"))
2679        .unwrap_or_else(|| "/".to_string());
2680    let search_style = if selection.search_focused {
2681        Style::default().fg(Color::Yellow)
2682    } else {
2683        Style::default().fg(Color::Gray)
2684    };
2685    frame.render_widget(
2686        Paragraph::new(search)
2687            .block(
2688                Block::default()
2689                    .borders(Borders::ALL)
2690                    .title(selection.title.as_str()),
2691            )
2692            .style(search_style),
2693        layout[0],
2694    );
2695
2696    frame.render_widget(
2697        Paragraph::new("Enter: choose  Esc: close  /: search  g/G: top/bottom  j/k: move")
2698            .style(Style::default().fg(Color::DarkGray)),
2699        layout[1],
2700    );
2701
2702    let indices = selection.filtered_indices();
2703    let items = if indices.is_empty() {
2704        vec![ListItem::new(Line::from("No options available."))]
2705    } else {
2706        indices
2707            .iter()
2708            .filter_map(|idx| selection.items.get(*idx))
2709            .map(|item| ListItem::new(Line::from(item.clone())))
2710            .collect::<Vec<_>>()
2711    };
2712    let mut state = ListState::default();
2713    state.select(Some(selection.selected));
2714    let list = List::new(items)
2715        .block(Block::default().borders(Borders::ALL))
2716        .highlight_style(
2717            Style::default()
2718                .bg(Color::Blue)
2719                .fg(Color::White)
2720                .add_modifier(Modifier::BOLD),
2721        )
2722        .highlight_symbol("> ");
2723    frame.render_stateful_widget(list, layout[2], &mut state);
2724}
2725
2726fn cleanup_terminal(mut terminal: Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
2727    disable_raw_mode()?;
2728    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
2729    terminal.show_cursor()?;
2730    Ok(())
2731}
2732
2733#[derive(Debug, Default, Clone)]
2734struct AdminResult {
2735    columns: Vec<Column>,
2736    rows: Vec<Row>,
2737    status_message: Option<String>,
2738}
2739
2740impl AdminResult {
2741    fn status(message: String) -> Self {
2742        Self {
2743            columns: Vec::new(),
2744            rows: Vec::new(),
2745            status_message: Some(message),
2746        }
2747    }
2748}
2749
2750#[derive(Default, Clone)]
2751struct CaptureState {
2752    columns: Vec<Column>,
2753    rows: Vec<Row>,
2754}
2755
2756struct CaptureFormatter {
2757    state: Arc<Mutex<CaptureState>>,
2758}
2759
2760impl CaptureFormatter {
2761    fn new() -> (Self, Arc<Mutex<CaptureState>>) {
2762        let state = Arc::new(Mutex::new(CaptureState::default()));
2763        (
2764            Self {
2765                state: Arc::clone(&state),
2766            },
2767            state,
2768        )
2769    }
2770}
2771
2772impl Formatter for CaptureFormatter {
2773    fn write_header(&mut self, _writer: &mut dyn std::io::Write, columns: &[Column]) -> Result<()> {
2774        let mut state = self.state.lock().expect("admin capture lock");
2775        state.columns = columns.to_vec();
2776        Ok(())
2777    }
2778
2779    fn write_row(&mut self, _writer: &mut dyn std::io::Write, row: &Row) -> Result<()> {
2780        self.state
2781            .lock()
2782            .expect("admin capture lock")
2783            .rows
2784            .push(row.clone());
2785        Ok(())
2786    }
2787
2788    fn write_footer(&mut self, _writer: &mut dyn std::io::Write) -> Result<()> {
2789        Ok(())
2790    }
2791
2792    fn supports_streaming(&self) -> bool {
2793        true
2794    }
2795}
2796
2797fn append_result_lines(lines: &mut Vec<Line<'static>>, result: &AdminResult) {
2798    if result.columns.is_empty() && result.rows.is_empty() && result.status_message.is_none() {
2799        return;
2800    }
2801    lines.push(Line::from(""));
2802    lines.push(Line::from(Span::styled(
2803        "Last Result",
2804        Style::default().add_modifier(Modifier::BOLD),
2805    )));
2806    if let Some(message) = &result.status_message {
2807        lines.push(Line::from(message.clone()));
2808    }
2809    if !result.columns.is_empty() {
2810        let header = result
2811            .columns
2812            .iter()
2813            .map(|col| col.name.clone())
2814            .collect::<Vec<_>>()
2815            .join(" | ");
2816        lines.push(Line::from(header));
2817        for row in &result.rows {
2818            let row_text = row
2819                .columns
2820                .iter()
2821                .map(value_to_string)
2822                .collect::<Vec<_>>()
2823                .join(" | ");
2824            lines.push(Line::from(row_text));
2825        }
2826    }
2827}
2828
2829fn value_to_string(value: &Value) -> String {
2830    match value {
2831        Value::Null => "NULL".to_string(),
2832        Value::Bool(b) => b.to_string(),
2833        Value::Int(i) => i.to_string(),
2834        Value::Float(f) => format!("{f:.6}"),
2835        Value::Text(text) => text.clone(),
2836        Value::Bytes(bytes) => format!("{:02x?}", bytes),
2837        Value::Vector(values) => format!(
2838            "[{}]",
2839            values
2840                .iter()
2841                .take(4)
2842                .map(|value| format!("{value:.4}"))
2843                .collect::<Vec<_>>()
2844                .join(", ")
2845        ),
2846    }
2847}
2848
2849#[cfg(test)]
2850mod tests {
2851    use super::*;
2852    use crate::batch::{BatchMode, BatchModeSource};
2853    use alopex_embedded::Database;
2854
2855    fn make_app<'a>(db: &'a Database) -> AdminApp<'a> {
2856        let batch_mode = Box::leak(Box::new(BatchMode {
2857            is_batch: true,
2858            is_tty: true,
2859            source: BatchModeSource::Explicit,
2860        }));
2861        let backend = AdminBackend::Local {
2862            db,
2863            batch_mode,
2864            output_format: OutputFormat::Table,
2865            limit: None,
2866            quiet: true,
2867            data_dir: None,
2868        };
2869        AdminApp::new(
2870            "local",
2871            AuthCapabilities::full(),
2872            backend,
2873            Some(AdminTarget::Kv),
2874        )
2875    }
2876
2877    fn field_value(app: &AdminApp<'_>, key: &str) -> Option<String> {
2878        app.form_fields
2879            .iter()
2880            .find(|field| field.key.eq_ignore_ascii_case(key))
2881            .map(|field| field.value.clone())
2882    }
2883
2884    #[test]
2885    fn resource_tree_filters_keep_parents() {
2886        let entries = vec![
2887            ResourceEntry {
2888                label: "SQL Tables".to_string(),
2889                kind: ResourceKind::Section(ResourceSection::SqlTables),
2890                depth: 0,
2891                selectable: false,
2892            },
2893            ResourceEntry {
2894                label: "users".to_string(),
2895                kind: ResourceKind::Table {
2896                    name: "users".to_string(),
2897                },
2898                depth: 1,
2899                selectable: true,
2900            },
2901            ResourceEntry {
2902                label: "email".to_string(),
2903                kind: ResourceKind::Column {
2904                    table: "users".to_string(),
2905                    name: "email".to_string(),
2906                },
2907                depth: 2,
2908                selectable: true,
2909            },
2910        ];
2911        let tree = ResourceTree {
2912            entries,
2913            selected: 0,
2914            search: Some("email".to_string()),
2915            search_focused: false,
2916            last_error: None,
2917        };
2918        let indices = tree.filtered_indices();
2919        assert_eq!(indices, vec![0, 1, 2]);
2920    }
2921
2922    #[test]
2923    fn resource_tree_paging_clamps() {
2924        let entries = (0..12)
2925            .map(|idx| ResourceEntry {
2926                label: format!("item-{idx}"),
2927                kind: ResourceKind::Info,
2928                depth: 0,
2929                selectable: true,
2930            })
2931            .collect::<Vec<_>>();
2932        let mut tree = ResourceTree {
2933            entries,
2934            selected: 0,
2935            search: None,
2936            search_focused: false,
2937            last_error: None,
2938        };
2939        tree.page_down();
2940        assert_eq!(tree.selected, 5);
2941        tree.page_down();
2942        assert_eq!(tree.selected, 10);
2943        tree.page_down();
2944        assert_eq!(tree.selected, 11);
2945        tree.page_up();
2946        assert_eq!(tree.selected, 6);
2947    }
2948
2949    #[test]
2950    fn selection_overlay_filters_values() {
2951        let mut overlay = SelectionOverlay::new(
2952            "Select".to_string(),
2953            vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()],
2954            0,
2955        );
2956        overlay.search = Some("et".to_string());
2957        overlay.ensure_selection_in_range();
2958        assert_eq!(overlay.selected_value(), Some("beta".to_string()));
2959        overlay.move_down();
2960        assert_eq!(overlay.selected_value(), Some("beta".to_string()));
2961    }
2962
2963    #[test]
2964    fn focus_transitions_follow_table_detail_status() {
2965        let db = Database::open_in_memory().expect("db");
2966        let mut app = make_app(&db);
2967        app.focus = AdminFocus::Table;
2968        assert_eq!(app.focus_right(), AdminFocus::Detail);
2969        app.focus = AdminFocus::Detail;
2970        assert_eq!(app.focus_left(), AdminFocus::Table);
2971        assert_eq!(app.focus_right(), AdminFocus::Status);
2972        app.focus = AdminFocus::Status;
2973        assert_eq!(app.focus_left(), AdminFocus::Detail);
2974        assert_eq!(app.focus_right(), AdminFocus::Status);
2975    }
2976
2977    #[test]
2978    fn resource_selection_sets_sql_fields() {
2979        let db = Database::open_in_memory().expect("db");
2980        let mut app = make_app(&db);
2981        app.resources = ResourceTree {
2982            entries: vec![
2983                ResourceEntry {
2984                    label: "SQL Tables".to_string(),
2985                    kind: ResourceKind::Section(ResourceSection::SqlTables),
2986                    depth: 0,
2987                    selectable: false,
2988                },
2989                ResourceEntry {
2990                    label: "users".to_string(),
2991                    kind: ResourceKind::Table {
2992                        name: "users".to_string(),
2993                    },
2994                    depth: 1,
2995                    selectable: true,
2996                },
2997            ],
2998            selected: 1,
2999            search: None,
3000            search_focused: false,
3001            last_error: None,
3002        };
3003        app.apply_resource_selection().expect("select table");
3004        assert_eq!(app.target, AdminTarget::Sql);
3005        assert_eq!(field_value(&app, "table"), Some("users".to_string()));
3006    }
3007
3008    #[test]
3009    fn resource_selection_sets_kv_key() {
3010        let db = Database::open_in_memory().expect("db");
3011        let mut app = make_app(&db);
3012        app.resources = ResourceTree {
3013            entries: vec![ResourceEntry {
3014                label: "mykey".to_string(),
3015                kind: ResourceKind::KvKey {
3016                    key: "mykey".to_string(),
3017                },
3018                depth: 1,
3019                selectable: true,
3020            }],
3021            selected: 0,
3022            search: None,
3023            search_focused: false,
3024            last_error: None,
3025        };
3026        app.apply_resource_selection().expect("select key");
3027        assert_eq!(app.target, AdminTarget::Kv);
3028        assert_eq!(field_value(&app, "key"), Some("mykey".to_string()));
3029    }
3030}