Skip to main content

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