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 state = CaptureFormatter::shared_state();
909        let mut make_formatter = CaptureFormatter::factory(&state);
910        let mut sink = io::sink();
911        let result = match &self.backend {
912            AdminBackend::Local { db, batch_mode, .. } => {
913                execute_local_action(db, batch_mode, request, &mut sink, &mut make_formatter)
914            }
915            AdminBackend::Remote {
916                client, batch_mode, ..
917            } => block_on_with_runtime(execute_remote_action(
918                client,
919                batch_mode,
920                request,
921                &mut sink,
922                &mut make_formatter,
923            ))?,
924        };
925
926        let capture = state.lock().expect("admin capture lock");
927        let mut result_state = AdminResult {
928            columns: capture.columns.clone(),
929            rows: capture.rows.clone(),
930            status_message: None,
931        };
932
933        if let Err(err) = result {
934            result_state.status_message = Some(err.to_string());
935        } else if result_state.columns.is_empty() && result_state.rows.is_empty() {
936            result_state.status_message = Some("OK".to_string());
937        }
938
939        self.last_result = Some(result_state);
940        self.preview_scroll = 0;
941        Ok(())
942    }
943
944    fn refresh_form_for_selection(&mut self) {
945        let action = self.items.get(self.selected).map(|item| item.action);
946        if action != self.last_action {
947            self.last_action = action;
948            self.reset_form();
949        }
950    }
951
952    fn reset_form(&mut self) {
953        if let Some(action) = self.items.get(self.selected).map(|item| item.action) {
954            self.form_fields = build_form_fields(self.target, action);
955            self.active_field = 0;
956            self.input_mode = AdminInputMode::Normal;
957            self.use_raw_params = false;
958            self.last_action = Some(action);
959            self.selection = None;
960        }
961    }
962
963    fn focus_left(&self) -> AdminFocus {
964        match self.focus {
965            AdminFocus::Table => AdminFocus::Table,
966            AdminFocus::Detail => AdminFocus::Table,
967            AdminFocus::Status => AdminFocus::Detail,
968        }
969    }
970
971    fn focus_right(&self) -> AdminFocus {
972        match self.focus {
973            AdminFocus::Table => AdminFocus::Detail,
974            AdminFocus::Detail => AdminFocus::Status,
975            AdminFocus::Status => AdminFocus::Status,
976        }
977    }
978
979    fn apply_resource_selection(&mut self) -> Result<()> {
980        let Some(entry) = self.resources.selected_entry() else {
981            return Ok(());
982        };
983        if !entry.selectable {
984            if let Some(target) = target_for_resource(&entry) {
985                self.ensure_target(target);
986            }
987            return Ok(());
988        }
989        match entry.kind {
990            ResourceKind::Section(section) => {
991                if let Some(target) = section.target() {
992                    self.ensure_target(target);
993                }
994            }
995            ResourceKind::Table { name } => {
996                self.ensure_target(AdminTarget::Sql);
997                if self.set_field_value("table", &name, false).is_none() {
998                    let query = format!("SELECT * FROM {name}");
999                    if self.set_field_value("query", &query, false).is_none() {
1000                        self.last_result = Some(AdminResult::status(
1001                            "No matching field for table.".to_string(),
1002                        ));
1003                    }
1004                }
1005            }
1006            ResourceKind::Column { table, name } => {
1007                self.ensure_target(AdminTarget::Sql);
1008                let _ = self.set_field_value("table", &table, false);
1009                if self.set_field_value("columns", &name, true).is_none() {
1010                    self.last_result = Some(AdminResult::status(
1011                        "No matching field for column.".to_string(),
1012                    ));
1013                }
1014            }
1015            ResourceKind::KvKey { key } => {
1016                self.ensure_target(AdminTarget::Kv);
1017                if self.set_field_value("key", &key, false).is_none() {
1018                    self.last_result = Some(AdminResult::status(
1019                        "No matching field for key.".to_string(),
1020                    ));
1021                }
1022            }
1023            ResourceKind::ColumnarSegment { id } => {
1024                self.ensure_target(AdminTarget::Columnar);
1025                if self.set_field_value("segment", &id, false).is_none() {
1026                    self.last_result = Some(AdminResult::status(
1027                        "No matching field for segment.".to_string(),
1028                    ));
1029                }
1030            }
1031            ResourceKind::ColumnarColumn { segment_id, name } => {
1032                self.ensure_target(AdminTarget::Columnar);
1033                let _ = self.set_field_value("segment", &segment_id, false);
1034                if self.set_field_value("column", &name, false).is_none() {
1035                    self.last_result = Some(AdminResult::status(
1036                        "No matching field for column.".to_string(),
1037                    ));
1038                }
1039            }
1040            ResourceKind::Info => {}
1041        }
1042        Ok(())
1043    }
1044
1045    fn sync_target_from_resource(&mut self) {
1046        let Some(entry) = self.resources.selected_entry() else {
1047            return;
1048        };
1049        if let Some(target) = target_for_resource(&entry) {
1050            self.ensure_target(target);
1051        }
1052    }
1053
1054    fn ensure_target(&mut self, target: AdminTarget) {
1055        if self.target != target {
1056            self.target = target;
1057            self.selected = 0;
1058            self.last_action = None;
1059            self.reset_form();
1060        }
1061    }
1062
1063    fn set_field_value(&mut self, key: &str, value: &str, append: bool) -> Option<()> {
1064        for (idx, field) in self.form_fields.iter_mut().enumerate() {
1065            if field.key.eq_ignore_ascii_case(key) {
1066                if append && !field.value.trim().is_empty() {
1067                    field.value = format!("{},{}", field.value.trim(), value);
1068                } else {
1069                    field.value = value.to_string();
1070                }
1071                self.active_field = idx;
1072                return Some(());
1073            }
1074        }
1075        None
1076    }
1077
1078    fn open_selection_for_active_field(&mut self) -> Result<()> {
1079        if self.use_raw_params {
1080            self.last_result = Some(AdminResult::status(
1081                "List selection is unavailable while using raw params.".to_string(),
1082            ));
1083            return Ok(());
1084        }
1085        let Some(field) = self.form_fields.get(self.active_field) else {
1086            return Ok(());
1087        };
1088        let Some(source) = field.list_source else {
1089            self.last_result = Some(AdminResult::status(
1090                "No list is available for this field.".to_string(),
1091            ));
1092            return Ok(());
1093        };
1094        let mut items = load_list_options(&self.backend, &self.form_fields, source)?;
1095        if items.is_empty() {
1096            items = self.list_options_from_resources(source);
1097        }
1098        items.retain(|item| !item.trim().is_empty());
1099        if items.is_empty() {
1100            self.last_result = Some(AdminResult::status(
1101                "No matching resources were found.".to_string(),
1102            ));
1103            return Ok(());
1104        }
1105        self.selection = Some(SelectionOverlay::new(
1106            format!("Select {}", field.label),
1107            items,
1108            self.active_field,
1109        ));
1110        Ok(())
1111    }
1112
1113    fn list_options_from_resources(&self, source: ListSource) -> Vec<String> {
1114        let mut items = Vec::new();
1115        match source {
1116            ListSource::KvKeys => {
1117                for entry in &self.resources.entries {
1118                    if let ResourceKind::KvKey { key } = &entry.kind {
1119                        items.push(key.clone());
1120                    }
1121                }
1122            }
1123            ListSource::SqlTables => {
1124                for entry in &self.resources.entries {
1125                    if let ResourceKind::Table { name } = &entry.kind {
1126                        items.push(name.clone());
1127                    }
1128                }
1129            }
1130            ListSource::SqlColumns => {
1131                let Some(table) = field_value(&self.form_fields, "table") else {
1132                    return items;
1133                };
1134                for entry in &self.resources.entries {
1135                    if let ResourceKind::Column {
1136                        table: entry_table,
1137                        name,
1138                    } = &entry.kind
1139                    {
1140                        if entry_table == &table {
1141                            items.push(name.clone());
1142                        }
1143                    }
1144                }
1145            }
1146            ListSource::ColumnarSegments => {
1147                for entry in &self.resources.entries {
1148                    if let ResourceKind::ColumnarSegment { id } = &entry.kind {
1149                        items.push(id.clone());
1150                    }
1151                }
1152            }
1153            ListSource::ColumnarColumns => {
1154                let Some(segment) = field_value(&self.form_fields, "segment") else {
1155                    return items;
1156                };
1157                for entry in &self.resources.entries {
1158                    if let ResourceKind::ColumnarColumn { segment_id, name } = &entry.kind {
1159                        if segment_id == &segment {
1160                            items.push(name.clone());
1161                        }
1162                    }
1163                }
1164            }
1165        }
1166        items.sort();
1167        items.dedup();
1168        items
1169    }
1170}
1171
1172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1173enum AdminInputMode {
1174    Normal,
1175    EditingField,
1176    EditingRaw,
1177}
1178
1179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1180enum AdminFocus {
1181    Table,
1182    Detail,
1183    Status,
1184}
1185
1186#[derive(Debug, Clone)]
1187struct AdminFormField {
1188    key: &'static str,
1189    label: &'static str,
1190    value: String,
1191    placeholder: &'static str,
1192    required: bool,
1193    list_source: Option<ListSource>,
1194}
1195
1196#[derive(Debug, Clone, Copy)]
1197enum ListSource {
1198    KvKeys,
1199    SqlTables,
1200    SqlColumns,
1201    ColumnarSegments,
1202    ColumnarColumns,
1203}
1204
1205#[derive(Debug, Clone)]
1206struct ResourceEntry {
1207    label: String,
1208    kind: ResourceKind,
1209    depth: usize,
1210    selectable: bool,
1211}
1212
1213#[derive(Debug, Clone)]
1214enum ResourceKind {
1215    Section(ResourceSection),
1216    Table { name: String },
1217    Column { table: String, name: String },
1218    KvKey { key: String },
1219    ColumnarSegment { id: String },
1220    ColumnarColumn { segment_id: String, name: String },
1221    Info,
1222}
1223
1224struct ResourceTree {
1225    entries: Vec<ResourceEntry>,
1226    selected: usize,
1227    search: Option<String>,
1228    search_focused: bool,
1229    last_error: Option<String>,
1230    last_status: Option<String>,
1231}
1232
1233#[derive(Debug, Clone, Copy)]
1234enum ResourceSection {
1235    SqlTables,
1236    ColumnarSegments,
1237    KvKeys,
1238}
1239
1240impl ResourceSection {
1241    fn target(self) -> Option<AdminTarget> {
1242        match self {
1243            ResourceSection::SqlTables => Some(AdminTarget::Sql),
1244            ResourceSection::ColumnarSegments => Some(AdminTarget::Columnar),
1245            ResourceSection::KvKeys => Some(AdminTarget::Kv),
1246        }
1247    }
1248}
1249
1250fn target_for_resource(entry: &ResourceEntry) -> Option<AdminTarget> {
1251    match entry.kind {
1252        ResourceKind::Section(section) => section.target(),
1253        ResourceKind::Table { .. } | ResourceKind::Column { .. } => Some(AdminTarget::Sql),
1254        ResourceKind::KvKey { .. } => Some(AdminTarget::Kv),
1255        ResourceKind::ColumnarSegment { .. } | ResourceKind::ColumnarColumn { .. } => {
1256            Some(AdminTarget::Columnar)
1257        }
1258        ResourceKind::Info => None,
1259    }
1260}
1261
1262impl ResourceTree {
1263    fn new(backend: &AdminBackend<'_>) -> Self {
1264        let (entries, last_error, last_status) = match load_resource_entries(backend) {
1265            Ok((entries, status)) => (entries, None, status),
1266            Err(err) => (Vec::new(), Some(err.to_string()), None),
1267        };
1268        Self {
1269            entries,
1270            selected: 0,
1271            search: None,
1272            search_focused: false,
1273            last_error,
1274            last_status,
1275        }
1276    }
1277
1278    fn reload(&mut self, backend: &AdminBackend<'_>) {
1279        match load_resource_entries(backend) {
1280            Ok((entries, status)) => {
1281                self.entries = entries;
1282                self.selected = 0;
1283                self.last_error = None;
1284                self.last_status = status;
1285            }
1286            Err(err) => {
1287                self.entries.clear();
1288                self.selected = 0;
1289                self.last_error = Some(err.to_string());
1290                self.last_status = None;
1291            }
1292        }
1293    }
1294
1295    fn search_term(&self) -> Option<&str> {
1296        self.search
1297            .as_deref()
1298            .filter(|value| !value.trim().is_empty())
1299    }
1300
1301    fn filtered_indices(&self) -> Vec<usize> {
1302        let Some(term) = self.search_term() else {
1303            return (0..self.entries.len()).collect();
1304        };
1305        let term = term.to_lowercase();
1306        let mut include = vec![false; self.entries.len()];
1307        for (idx, entry) in self.entries.iter().enumerate() {
1308            if entry.label.to_lowercase().contains(&term) {
1309                include[idx] = true;
1310                let mut depth = entry.depth;
1311                if depth == 0 {
1312                    continue;
1313                }
1314                for parent_idx in (0..idx).rev() {
1315                    let parent = &self.entries[parent_idx];
1316                    if parent.depth < depth {
1317                        include[parent_idx] = true;
1318                        depth = parent.depth;
1319                        if depth == 0 {
1320                            break;
1321                        }
1322                    }
1323                }
1324            }
1325        }
1326        include
1327            .iter()
1328            .enumerate()
1329            .filter_map(|(idx, keep)| if *keep { Some(idx) } else { None })
1330            .collect()
1331    }
1332
1333    fn filtered_entries(&self) -> Vec<ResourceEntry> {
1334        let indices = self.filtered_indices();
1335        indices
1336            .iter()
1337            .filter_map(|idx| self.entries.get(*idx))
1338            .cloned()
1339            .collect()
1340    }
1341
1342    fn selected_entry(&self) -> Option<ResourceEntry> {
1343        let indices = self.filtered_indices();
1344        let idx = indices.get(self.selected).copied()?;
1345        self.entries.get(idx).cloned()
1346    }
1347
1348    fn ensure_selection_in_range(&mut self) {
1349        let len = self.filtered_indices().len();
1350        if len == 0 {
1351            self.selected = 0;
1352        } else if self.selected >= len {
1353            self.selected = len - 1;
1354        }
1355    }
1356
1357    fn move_up(&mut self) {
1358        if self.selected > 0 {
1359            self.selected -= 1;
1360        }
1361    }
1362
1363    fn move_down(&mut self) {
1364        let len = self.filtered_indices().len();
1365        if self.selected + 1 < len {
1366            self.selected += 1;
1367        }
1368    }
1369
1370    fn move_top(&mut self) {
1371        self.selected = 0;
1372    }
1373
1374    fn move_bottom(&mut self) {
1375        let len = self.filtered_indices().len();
1376        if len > 0 {
1377            self.selected = len - 1;
1378        }
1379    }
1380
1381    fn page_down(&mut self) {
1382        let len = self.filtered_indices().len();
1383        if len == 0 {
1384            return;
1385        }
1386        self.selected = (self.selected + 5).min(len - 1);
1387    }
1388
1389    fn page_up(&mut self) {
1390        self.selected = self.selected.saturating_sub(5);
1391    }
1392
1393    fn push_search(&mut self, ch: char) {
1394        let search = self.search.get_or_insert_with(String::new);
1395        search.push(ch);
1396        self.ensure_selection_in_range();
1397    }
1398
1399    fn pop_search(&mut self) {
1400        if let Some(search) = self.search.as_mut() {
1401            if !search.is_empty() {
1402                search.pop();
1403            } else {
1404                self.reset_search();
1405            }
1406        }
1407        self.ensure_selection_in_range();
1408    }
1409
1410    fn reset_search(&mut self) {
1411        self.search = None;
1412        self.search_focused = false;
1413        self.selected = 0;
1414    }
1415}
1416
1417fn build_form_fields(target: AdminTarget, action: AdminAction) -> Vec<AdminFormField> {
1418    match (target, action) {
1419        (_, AdminAction::Backup) => vec![form_field(
1420            "handle",
1421            "Handle (status)",
1422            "",
1423            "backup-handle",
1424            false,
1425        )],
1426        (_, AdminAction::Restore) => vec![
1427            form_field("source", "Source", "", "s3://bucket/path", false),
1428            form_field("handle", "Handle (status)", "", "restore-handle", false),
1429        ],
1430        (AdminTarget::Sql, AdminAction::Read) => vec![
1431            form_field("query", "Query", "", "SELECT * FROM table", false),
1432            form_field_with_list(
1433                "table",
1434                "Table",
1435                "",
1436                "mytable",
1437                false,
1438                Some(ListSource::SqlTables),
1439            ),
1440            form_field_with_list(
1441                "columns",
1442                "Columns",
1443                "",
1444                "col1,col2",
1445                false,
1446                Some(ListSource::SqlColumns),
1447            ),
1448        ],
1449        (AdminTarget::Sql, _) => vec![form_field(
1450            "query",
1451            "Query",
1452            "",
1453            "SELECT * FROM table",
1454            true,
1455        )],
1456        (AdminTarget::Kv, AdminAction::Read) => vec![
1457            form_field_with_list("key", "Key", "", "mykey", false, Some(ListSource::KvKeys)),
1458            form_field("prefix", "Prefix", "", "app/", false),
1459        ],
1460        (AdminTarget::Kv, AdminAction::Create | AdminAction::Update) => vec![
1461            form_field_with_list("key", "Key", "", "mykey", true, Some(ListSource::KvKeys)),
1462            form_field("value", "Value", "", "hello", true),
1463        ],
1464        (AdminTarget::Kv, AdminAction::Delete) => vec![form_field_with_list(
1465            "key",
1466            "Key",
1467            "",
1468            "mykey",
1469            true,
1470            Some(ListSource::KvKeys),
1471        )],
1472        (AdminTarget::Vector, AdminAction::Read) => vec![
1473            form_field("index", "Index", "", "myindex", true),
1474            form_field("query", "Query", "", "[0.1, 0.2]", true),
1475            form_field("k", "Top K", "10", "10", false),
1476        ],
1477        (AdminTarget::Vector, AdminAction::Create | AdminAction::Update) => vec![
1478            form_field("index", "Index", "", "myindex", true),
1479            form_field("key", "Key", "", "item1", true),
1480            form_field("vector", "Vector", "", "[0.1, 0.2]", true),
1481        ],
1482        (AdminTarget::Vector, AdminAction::Delete) => vec![
1483            form_field("index", "Index", "", "myindex", true),
1484            form_field("key", "Key", "", "item1", true),
1485        ],
1486        (AdminTarget::Hnsw, AdminAction::Read) => {
1487            vec![form_field("name", "Index", "", "myindex", true)]
1488        }
1489        (AdminTarget::Hnsw, AdminAction::Create) => vec![
1490            form_field("name", "Index", "", "myindex", true),
1491            form_field("dim", "Dimensions", "", "128", true),
1492            form_field("metric", "Metric", "cosine", "cosine", false),
1493        ],
1494        (AdminTarget::Hnsw, AdminAction::Delete) => {
1495            vec![form_field("name", "Index", "", "myindex", true)]
1496        }
1497        (AdminTarget::Columnar, AdminAction::Read) => vec![
1498            form_field("mode", "Mode", "list", "list|scan|stats|index_list", true),
1499            form_field_with_list(
1500                "segment",
1501                "Segment",
1502                "",
1503                "segment_id",
1504                false,
1505                Some(ListSource::ColumnarSegments),
1506            ),
1507        ],
1508        (AdminTarget::Columnar, AdminAction::Create) => vec![
1509            form_field("file", "File", "", "data.csv", false),
1510            form_field_with_list(
1511                "table",
1512                "Table",
1513                "",
1514                "mytable",
1515                false,
1516                Some(ListSource::SqlTables),
1517            ),
1518            form_field_with_list(
1519                "segment",
1520                "Segment",
1521                "",
1522                "segment_id",
1523                false,
1524                Some(ListSource::ColumnarSegments),
1525            ),
1526            form_field_with_list(
1527                "column",
1528                "Column",
1529                "",
1530                "column_name",
1531                false,
1532                Some(ListSource::ColumnarColumns),
1533            ),
1534            form_field("index_type", "Index Type", "", "minmax", false),
1535        ],
1536        (AdminTarget::Columnar, AdminAction::Delete) => vec![
1537            form_field_with_list(
1538                "segment",
1539                "Segment",
1540                "",
1541                "segment_id",
1542                true,
1543                Some(ListSource::ColumnarSegments),
1544            ),
1545            form_field_with_list(
1546                "column",
1547                "Column",
1548                "",
1549                "column_name",
1550                true,
1551                Some(ListSource::ColumnarColumns),
1552            ),
1553        ],
1554        _ => Vec::new(),
1555    }
1556}
1557
1558fn form_field(
1559    key: &'static str,
1560    label: &'static str,
1561    value: &str,
1562    placeholder: &'static str,
1563    required: bool,
1564) -> AdminFormField {
1565    form_field_with_list(key, label, value, placeholder, required, None)
1566}
1567
1568fn form_field_with_list(
1569    key: &'static str,
1570    label: &'static str,
1571    value: &str,
1572    placeholder: &'static str,
1573    required: bool,
1574    list_source: Option<ListSource>,
1575) -> AdminFormField {
1576    AdminFormField {
1577        key,
1578        label,
1579        value: value.to_string(),
1580        placeholder,
1581        required,
1582        list_source,
1583    }
1584}
1585
1586fn load_list_options(
1587    backend: &AdminBackend<'_>,
1588    fields: &[AdminFormField],
1589    source: ListSource,
1590) -> Result<Vec<String>> {
1591    match source {
1592        ListSource::KvKeys => {
1593            let prefix = field_value(fields, "prefix");
1594            let command = AdminCommand::Kv(KvCommand::List { prefix });
1595            let capture = capture_admin_command(backend, command, Some(50))?;
1596            Ok(extract_column_values(
1597                &capture.columns,
1598                &capture.rows,
1599                "key",
1600            ))
1601        }
1602        ListSource::ColumnarSegments => {
1603            let command = AdminCommand::Columnar(ColumnarCommand::List);
1604            let capture = capture_admin_command(backend, command, Some(50))?;
1605            Ok(extract_column_values(
1606                &capture.columns,
1607                &capture.rows,
1608                "segment_id",
1609            ))
1610        }
1611        ListSource::SqlTables => {
1612            let Some(db) = backend.local_db() else {
1613                return Err(CliError::InvalidArgument(
1614                    "Table listing is only available for local admin sessions.".to_string(),
1615                ));
1616            };
1617            let mut tables = db
1618                .list_tables_simple()?
1619                .into_iter()
1620                .map(|table| table.name)
1621                .collect::<Vec<_>>();
1622            tables.sort();
1623            tables.dedup();
1624            Ok(tables)
1625        }
1626        ListSource::SqlColumns => {
1627            let table = field_value(fields, "table")
1628                .ok_or_else(|| CliError::InvalidArgument("Select a table first.".to_string()))?;
1629            let Some(db) = backend.local_db() else {
1630                return Err(CliError::InvalidArgument(
1631                    "Column listing is only available for local admin sessions.".to_string(),
1632                ));
1633            };
1634            let mut columns = db
1635                .get_table_info_simple(&table)?
1636                .columns
1637                .into_iter()
1638                .map(|column| column.name)
1639                .collect::<Vec<_>>();
1640            columns.sort();
1641            columns.dedup();
1642            Ok(columns)
1643        }
1644        ListSource::ColumnarColumns => {
1645            let segment = field_value(fields, "segment")
1646                .ok_or_else(|| CliError::InvalidArgument("Select a segment first.".to_string()))?;
1647            let Some(db) = backend.local_db() else {
1648                return Err(CliError::InvalidArgument(
1649                    "Column listing is only available for local admin sessions.".to_string(),
1650                ));
1651            };
1652            let mut columns = list_columnar_columns_from_segment(db, &segment)?;
1653            columns.sort();
1654            columns.dedup();
1655            Ok(columns)
1656        }
1657    }
1658}
1659
1660fn field_value(fields: &[AdminFormField], key: &str) -> Option<String> {
1661    fields
1662        .iter()
1663        .find(|field| field.key.eq_ignore_ascii_case(key))
1664        .map(|field| field.value.trim().to_string())
1665        .filter(|value| !value.is_empty())
1666}
1667
1668fn capture_admin_command(
1669    backend: &AdminBackend<'_>,
1670    command: AdminCommand,
1671    limit: Option<usize>,
1672) -> Result<CaptureState> {
1673    let request = AdminRequest {
1674        action: AdminAction::Read,
1675        command,
1676        limit,
1677        quiet: true,
1678        ui_mode: UiMode::Batch,
1679        connection_label: String::new(),
1680        output: backend.output_format(),
1681        data_dir: backend.data_dir().map(PathBuf::from),
1682    };
1683    let state = CaptureFormatter::shared_state();
1684    let mut make_formatter = CaptureFormatter::factory(&state);
1685    let mut sink = io::sink();
1686    let result = match backend {
1687        AdminBackend::Local { db, batch_mode, .. } => {
1688            execute_local_action(db, batch_mode, request, &mut sink, &mut make_formatter)
1689        }
1690        AdminBackend::Remote {
1691            client, batch_mode, ..
1692        } => {
1693            let runtime = Runtime::new().map_err(|err| {
1694                CliError::InvalidArgument(format!("Failed to start async runtime: {err}"))
1695            })?;
1696            runtime.block_on(execute_remote_action(
1697                client,
1698                batch_mode,
1699                request,
1700                &mut sink,
1701                &mut make_formatter,
1702            ))
1703        }
1704    };
1705    result?;
1706    let capture = state.lock().expect("admin capture lock").clone();
1707    Ok(capture)
1708}
1709
1710fn extract_column_values(columns: &[Column], rows: &[Row], column_name: &str) -> Vec<String> {
1711    let index = columns
1712        .iter()
1713        .position(|column| column.name.eq_ignore_ascii_case(column_name))
1714        .unwrap_or(0);
1715    rows.iter()
1716        .filter_map(|row| row.columns.get(index))
1717        .map(value_to_string)
1718        .collect()
1719}
1720
1721fn list_columnar_columns_from_segment(
1722    db: &alopex_embedded::Database,
1723    segment_id: &str,
1724) -> Result<Vec<String>> {
1725    let (table_id, segment_id) = parse_segment_id(segment_id).ok_or_else(|| {
1726        CliError::InvalidArgument(
1727            "Invalid segment id. Expected format: table_id:segment_id.".to_string(),
1728        )
1729    })?;
1730    let tables = db.list_tables_simple()?;
1731    let table = tables
1732        .into_iter()
1733        .find(|table| table.table_id == table_id)
1734        .ok_or_else(|| {
1735            CliError::InvalidArgument("Unable to resolve table for segment.".to_string())
1736        })?;
1737    let batches = db.read_columnar_segment(&table.name, segment_id, None)?;
1738    let batch = batches
1739        .first()
1740        .ok_or_else(|| CliError::InvalidArgument("Columnar segment is empty.".to_string()))?;
1741    Ok(batch
1742        .schema
1743        .columns
1744        .iter()
1745        .map(|column| column.name.clone())
1746        .collect())
1747}
1748
1749fn parse_segment_id(segment_id: &str) -> Option<(u32, u64)> {
1750    let (table_id, segment_id) = segment_id.split_once(':')?;
1751    let table_id = table_id.parse::<u32>().ok()?;
1752    let segment_id = segment_id.parse::<u64>().ok()?;
1753    Some((table_id, segment_id))
1754}
1755
1756const RESOURCE_LIMIT: usize = 50;
1757const COLUMNAR_COLUMN_LIMIT: usize = 20;
1758
1759fn block_on_with_runtime<F, T>(future: F) -> Result<T>
1760where
1761    F: std::future::Future<Output = T>,
1762{
1763    match Handle::try_current() {
1764        Ok(handle) => Ok(tokio::task::block_in_place(|| handle.block_on(future))),
1765        Err(_) => {
1766            let runtime = Runtime::new().map_err(|err| {
1767                CliError::InvalidArgument(format!("Failed to start async runtime: {err}"))
1768            })?;
1769            Ok(runtime.block_on(future))
1770        }
1771    }
1772}
1773
1774fn load_resource_entries(
1775    backend: &AdminBackend<'_>,
1776) -> Result<(Vec<ResourceEntry>, Option<String>)> {
1777    match backend {
1778        AdminBackend::Remote { client, .. } => load_remote_resources(client),
1779        _ => {
1780            let mut entries = Vec::new();
1781            entries.extend(load_sql_resources(backend)?);
1782            entries.extend(load_columnar_resources(backend)?);
1783            entries.extend(load_kv_resources(backend)?);
1784            Ok((entries, None))
1785        }
1786    }
1787}
1788
1789fn load_remote_resources(client: &HttpClient) -> Result<(Vec<ResourceEntry>, Option<String>)> {
1790    let request = AdminResourcesRequest {
1791        limit: Some(RESOURCE_LIMIT),
1792        include_columnar_columns: Some(true),
1793        columnar_column_limit: Some(COLUMNAR_COLUMN_LIMIT),
1794        kv_prefix: None,
1795    };
1796    let response = block_on_with_runtime(fetch_admin_resources(client, &request))?;
1797    match response {
1798        Ok(response) => {
1799            let status = truncated_status(&response.truncated);
1800            Ok((build_remote_entries(response), status))
1801        }
1802        Err(ClientError::HttpStatus { status, .. })
1803            if status == reqwest::StatusCode::FORBIDDEN
1804                || status == reqwest::StatusCode::UNAUTHORIZED =>
1805        {
1806            Ok((remote_listing_denied_entries(), None))
1807        }
1808        Err(err) => Err(map_client_error(err)),
1809    }
1810}
1811
1812fn truncated_status(
1813    truncated: &crate::client::admin_resources::TruncatedSections,
1814) -> Option<String> {
1815    let mut sections = Vec::new();
1816    if truncated.sql_tables {
1817        sections.push("SQL tables");
1818    }
1819    if truncated.columnar_segments {
1820        sections.push("columnar segments");
1821    }
1822    if truncated.kv_keys {
1823        sections.push("KV keys");
1824    }
1825    if sections.is_empty() {
1826        None
1827    } else {
1828        Some(format!(
1829            "Resources truncated (limit {RESOURCE_LIMIT}): {}.",
1830            sections.join(", ")
1831        ))
1832    }
1833}
1834
1835fn build_remote_entries(
1836    response: crate::client::admin_resources::AdminResourcesResponse,
1837) -> Vec<ResourceEntry> {
1838    let mut entries = Vec::new();
1839
1840    entries.push(ResourceEntry {
1841        label: "SQL Tables".to_string(),
1842        kind: ResourceKind::Section(ResourceSection::SqlTables),
1843        depth: 0,
1844        selectable: false,
1845    });
1846    for table in response.sql_tables {
1847        let table_name = table.name.clone();
1848        entries.push(ResourceEntry {
1849            label: table_name.clone(),
1850            kind: ResourceKind::Table {
1851                name: table_name.clone(),
1852            },
1853            depth: 1,
1854            selectable: true,
1855        });
1856        for column in table.columns {
1857            entries.push(ResourceEntry {
1858                label: column.name.clone(),
1859                kind: ResourceKind::Column {
1860                    table: table_name.clone(),
1861                    name: column.name,
1862                },
1863                depth: 2,
1864                selectable: true,
1865            });
1866        }
1867    }
1868    if response.truncated.sql_tables {
1869        let label = format!("Truncated: showing first {RESOURCE_LIMIT} tables.");
1870        entries.push(truncated_entry(&label, 1));
1871    }
1872
1873    entries.push(ResourceEntry {
1874        label: "Columnar Segments".to_string(),
1875        kind: ResourceKind::Section(ResourceSection::ColumnarSegments),
1876        depth: 0,
1877        selectable: false,
1878    });
1879    for segment in response.columnar_segments {
1880        let segment_id = segment.id.clone();
1881        entries.push(ResourceEntry {
1882            label: segment_id.clone(),
1883            kind: ResourceKind::ColumnarSegment { id: segment_id },
1884            depth: 1,
1885            selectable: true,
1886        });
1887        if let Some(columns) = segment.columns {
1888            for column in columns {
1889                entries.push(ResourceEntry {
1890                    label: column.clone(),
1891                    kind: ResourceKind::ColumnarColumn {
1892                        segment_id: segment.id.clone(),
1893                        name: column,
1894                    },
1895                    depth: 2,
1896                    selectable: true,
1897                });
1898            }
1899        }
1900    }
1901    if response.truncated.columnar_segments {
1902        let label = format!("Truncated: showing first {RESOURCE_LIMIT} segments.");
1903        entries.push(truncated_entry(&label, 1));
1904    }
1905
1906    entries.push(ResourceEntry {
1907        label: "KV Keys".to_string(),
1908        kind: ResourceKind::Section(ResourceSection::KvKeys),
1909        depth: 0,
1910        selectable: false,
1911    });
1912    for key in response.kv_keys {
1913        entries.push(ResourceEntry {
1914            label: key.clone(),
1915            kind: ResourceKind::KvKey { key },
1916            depth: 1,
1917            selectable: true,
1918        });
1919    }
1920    if response.truncated.kv_keys {
1921        let label = format!("Truncated: showing first {RESOURCE_LIMIT} keys.");
1922        entries.push(truncated_entry(&label, 1));
1923    }
1924
1925    entries
1926}
1927
1928fn remote_listing_denied_entries() -> Vec<ResourceEntry> {
1929    let mut entries = Vec::new();
1930    for section in [
1931        ResourceSection::SqlTables,
1932        ResourceSection::ColumnarSegments,
1933        ResourceSection::KvKeys,
1934    ] {
1935        let label = match section {
1936            ResourceSection::SqlTables => "SQL Tables",
1937            ResourceSection::ColumnarSegments => "Columnar Segments",
1938            ResourceSection::KvKeys => "KV Keys",
1939        };
1940        entries.push(ResourceEntry {
1941            label: label.to_string(),
1942            kind: ResourceKind::Section(section),
1943            depth: 0,
1944            selectable: false,
1945        });
1946        entries.push(truncated_entry("Remote listing denied.", 1));
1947    }
1948    entries
1949}
1950
1951fn truncated_entry(label: &str, depth: usize) -> ResourceEntry {
1952    ResourceEntry {
1953        label: label.to_string(),
1954        kind: ResourceKind::Info,
1955        depth,
1956        selectable: false,
1957    }
1958}
1959
1960fn map_client_error(err: ClientError) -> CliError {
1961    match err {
1962        ClientError::Request { source, .. } => {
1963            CliError::ServerConnection(format!("request failed: {source}"))
1964        }
1965        ClientError::InvalidUrl(message) => CliError::InvalidArgument(message),
1966        ClientError::Build(message) => CliError::InvalidArgument(message),
1967        ClientError::Auth(err) => CliError::InvalidArgument(err.to_string()),
1968        ClientError::HttpStatus { status, body } => {
1969            CliError::ServerConnection(format!("server error {status}: {body}"))
1970        }
1971    }
1972}
1973
1974fn load_sql_resources(backend: &AdminBackend<'_>) -> Result<Vec<ResourceEntry>> {
1975    let mut entries = Vec::new();
1976    entries.push(ResourceEntry {
1977        label: "SQL Tables".to_string(),
1978        kind: ResourceKind::Section(ResourceSection::SqlTables),
1979        depth: 0,
1980        selectable: false,
1981    });
1982    let Some(db) = backend.local_db() else {
1983        entries.push(ResourceEntry {
1984            label: "Remote listing unavailable".to_string(),
1985            kind: ResourceKind::Info,
1986            depth: 1,
1987            selectable: false,
1988        });
1989        return Ok(entries);
1990    };
1991    let mut tables = match db.list_tables_simple() {
1992        Ok(tables) => tables,
1993        Err(alopex_embedded::Error::CatalogNotFound(_))
1994        | Err(alopex_embedded::Error::NamespaceNotFound(_, _)) => {
1995            let _ = db.create_catalog(CreateCatalogRequest::new("default"));
1996            let _ = db.create_namespace(CreateNamespaceRequest::new("default", "default"));
1997            match db.list_tables_simple() {
1998                Ok(tables) => tables,
1999                Err(err) => {
2000                    entries.push(ResourceEntry {
2001                        label: format!("SQL catalog unavailable: {err}"),
2002                        kind: ResourceKind::Info,
2003                        depth: 1,
2004                        selectable: false,
2005                    });
2006                    return Ok(entries);
2007                }
2008            }
2009        }
2010        Err(err) => return Err(err.into()),
2011    };
2012    tables.sort_by(|a, b| a.name.cmp(&b.name));
2013    for table in tables.into_iter().take(RESOURCE_LIMIT) {
2014        entries.push(ResourceEntry {
2015            label: table.name.clone(),
2016            kind: ResourceKind::Table {
2017                name: table.name.clone(),
2018            },
2019            depth: 1,
2020            selectable: true,
2021        });
2022        for column in table.columns {
2023            entries.push(ResourceEntry {
2024                label: column.name.clone(),
2025                kind: ResourceKind::Column {
2026                    table: table.name.clone(),
2027                    name: column.name,
2028                },
2029                depth: 2,
2030                selectable: true,
2031            });
2032        }
2033    }
2034    Ok(entries)
2035}
2036
2037fn load_columnar_resources(backend: &AdminBackend<'_>) -> Result<Vec<ResourceEntry>> {
2038    let mut entries = Vec::new();
2039    entries.push(ResourceEntry {
2040        label: "Columnar Segments".to_string(),
2041        kind: ResourceKind::Section(ResourceSection::ColumnarSegments),
2042        depth: 0,
2043        selectable: false,
2044    });
2045    let Some(db) = backend.local_db() else {
2046        entries.push(ResourceEntry {
2047            label: "Remote listing unavailable".to_string(),
2048            kind: ResourceKind::Info,
2049            depth: 1,
2050            selectable: false,
2051        });
2052        return Ok(entries);
2053    };
2054    let mut segments = db.list_columnar_segments()?;
2055    segments.sort();
2056    let mut expanded = 0;
2057    for segment in segments.into_iter().take(RESOURCE_LIMIT) {
2058        entries.push(ResourceEntry {
2059            label: segment.clone(),
2060            kind: ResourceKind::ColumnarSegment {
2061                id: segment.clone(),
2062            },
2063            depth: 1,
2064            selectable: true,
2065        });
2066        if expanded < COLUMNAR_COLUMN_LIMIT {
2067            if let Ok(columns) = list_columnar_columns_from_segment(db, &segment) {
2068                for column in columns {
2069                    entries.push(ResourceEntry {
2070                        label: column.clone(),
2071                        kind: ResourceKind::ColumnarColumn {
2072                            segment_id: segment.clone(),
2073                            name: column,
2074                        },
2075                        depth: 2,
2076                        selectable: true,
2077                    });
2078                }
2079            }
2080            expanded += 1;
2081        }
2082    }
2083    Ok(entries)
2084}
2085
2086fn load_kv_resources(backend: &AdminBackend<'_>) -> Result<Vec<ResourceEntry>> {
2087    let mut entries = Vec::new();
2088    entries.push(ResourceEntry {
2089        label: "KV Keys".to_string(),
2090        kind: ResourceKind::Section(ResourceSection::KvKeys),
2091        depth: 0,
2092        selectable: false,
2093    });
2094    let system_prefixes = [
2095        "__catalog__/",
2096        "hnsw:",
2097        "__alopex_",
2098        "__alopex:",
2099        "vector:",
2100        "columnar:",
2101    ];
2102    let command = AdminCommand::Kv(KvCommand::List { prefix: None });
2103    let capture = capture_admin_command(backend, command, Some(RESOURCE_LIMIT))?;
2104    let keys = extract_column_values(&capture.columns, &capture.rows, "key");
2105    for key in keys.into_iter().filter(|key| {
2106        !system_prefixes.iter().any(|prefix| key.starts_with(prefix))
2107            && !key.trim().is_empty()
2108            && !key.chars().any(|ch| ch.is_control())
2109    }) {
2110        entries.push(ResourceEntry {
2111            label: key.clone(),
2112            kind: ResourceKind::KvKey { key },
2113            depth: 1,
2114            selectable: true,
2115        });
2116    }
2117    Ok(entries)
2118}
2119
2120fn build_params_from_fields(
2121    fields: &[AdminFormField],
2122) -> std::collections::HashMap<String, String> {
2123    let mut params = std::collections::HashMap::new();
2124    for field in fields {
2125        if !field.value.trim().is_empty() {
2126            params.insert(field.key.to_lowercase(), field.value.trim().to_string());
2127        }
2128    }
2129    params
2130}
2131
2132fn validate_params(
2133    action: AdminAction,
2134    target: AdminTarget,
2135    params: &std::collections::HashMap<String, String>,
2136) -> std::result::Result<(), String> {
2137    if matches!(
2138        action,
2139        AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export
2140    ) {
2141        return Ok(());
2142    }
2143    match (target, action) {
2144        (AdminTarget::Sql, AdminAction::Read) => {
2145            if params.contains_key("query") {
2146                return Ok(());
2147            }
2148            if let Some(columns) = params.get("columns") {
2149                if !params.contains_key("table") {
2150                    return Err("Provide table to use columns.".to_string());
2151                }
2152                if columns.trim().is_empty() {
2153                    return Err("Columns cannot be empty when provided.".to_string());
2154                }
2155            }
2156            if params.contains_key("table") {
2157                Ok(())
2158            } else {
2159                Err("Provide query or table.".to_string())
2160            }
2161        }
2162        (AdminTarget::Kv, AdminAction::Read) => {
2163            if params.contains_key("key") || params.contains_key("prefix") {
2164                Ok(())
2165            } else {
2166                Err("Provide either key or prefix.".to_string())
2167            }
2168        }
2169        (AdminTarget::Columnar, AdminAction::Read) => {
2170            let mode = params.get("mode").map(|v| v.as_str()).unwrap_or("list");
2171            if matches!(mode, "scan" | "stats" | "index_list") && !params.contains_key("segment") {
2172                Err("Provide segment for scan/stats/index_list.".to_string())
2173            } else {
2174                Ok(())
2175            }
2176        }
2177        (AdminTarget::Columnar, AdminAction::Create) => {
2178            let has_ingest = params.contains_key("file") && params.contains_key("table");
2179            let has_index = params.contains_key("segment")
2180                && params.contains_key("column")
2181                && params.contains_key("index_type");
2182            if has_ingest || has_index {
2183                Ok(())
2184            } else {
2185                Err("Provide file+table or segment+column+index_type.".to_string())
2186            }
2187        }
2188        _ => {
2189            let missing = required_keys_for(target, action)
2190                .into_iter()
2191                .filter(|key| !params.contains_key(*key))
2192                .collect::<Vec<_>>();
2193            if missing.is_empty() {
2194                Ok(())
2195            } else {
2196                Err(format!("Missing: {}", missing.join(", ")))
2197            }
2198        }
2199    }
2200}
2201
2202fn required_keys_for(target: AdminTarget, action: AdminAction) -> Vec<&'static str> {
2203    match (target, action) {
2204        (AdminTarget::Sql, AdminAction::Read) => Vec::new(),
2205        (AdminTarget::Sql, _) => vec!["query"],
2206        (AdminTarget::Kv, AdminAction::Create | AdminAction::Update) => vec!["key", "value"],
2207        (AdminTarget::Kv, AdminAction::Delete) => vec!["key"],
2208        (AdminTarget::Vector, AdminAction::Read) => vec!["index", "query"],
2209        (AdminTarget::Vector, AdminAction::Create | AdminAction::Update) => {
2210            vec!["index", "key", "vector"]
2211        }
2212        (AdminTarget::Vector, AdminAction::Delete) => vec!["index", "key"],
2213        (AdminTarget::Hnsw, AdminAction::Read) => vec!["name"],
2214        (AdminTarget::Hnsw, AdminAction::Create) => vec!["name", "dim"],
2215        (AdminTarget::Hnsw, AdminAction::Delete) => vec!["name"],
2216        (AdminTarget::Columnar, AdminAction::Delete) => vec!["segment", "column"],
2217        _ => Vec::new(),
2218    }
2219}
2220
2221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2222pub enum AdminTarget {
2223    Sql,
2224    Kv,
2225    Vector,
2226    Hnsw,
2227    Columnar,
2228}
2229
2230impl AdminTarget {
2231    fn label(self) -> &'static str {
2232        match self {
2233            AdminTarget::Sql => "SQL",
2234            AdminTarget::Kv => "KV",
2235            AdminTarget::Vector => "Vector",
2236            AdminTarget::Hnsw => "HNSW",
2237            AdminTarget::Columnar => "Columnar",
2238        }
2239    }
2240
2241    fn example_for(self, action: AdminAction) -> Option<&'static str> {
2242        match (self, action) {
2243            (AdminTarget::Sql, _) => Some("query=\"SELECT * FROM table\""),
2244            (AdminTarget::Kv, AdminAction::Read) => Some("key=mykey OR prefix=app/"),
2245            (AdminTarget::Kv, AdminAction::Create | AdminAction::Update) => {
2246                Some("key=mykey value=hello")
2247            }
2248            (AdminTarget::Kv, AdminAction::Delete) => Some("key=mykey"),
2249            (AdminTarget::Vector, AdminAction::Read) => {
2250                Some("index=myindex query=\"[0.1, 0.2]\" k=10")
2251            }
2252            (AdminTarget::Vector, AdminAction::Create | AdminAction::Update) => {
2253                Some("index=myindex key=item1 vector=\"[0.1, 0.2]\"")
2254            }
2255            (AdminTarget::Vector, AdminAction::Delete) => Some("index=myindex key=item1"),
2256            (AdminTarget::Hnsw, AdminAction::Read) => Some("name=myindex"),
2257            (AdminTarget::Hnsw, AdminAction::Create) => Some("name=myindex dim=128 metric=cosine"),
2258            (AdminTarget::Hnsw, AdminAction::Delete) => Some("name=myindex"),
2259            (AdminTarget::Columnar, AdminAction::Read) => Some("mode=list"),
2260            (AdminTarget::Columnar, AdminAction::Create) => Some("file=data.csv table=mytable"),
2261            (AdminTarget::Columnar, AdminAction::Delete) => Some("segment=seg1 column=col1"),
2262            _ => None,
2263        }
2264    }
2265}
2266
2267pub enum AdminBackend<'a> {
2268    Local {
2269        db: &'a alopex_embedded::Database,
2270        batch_mode: &'a BatchMode,
2271        output_format: OutputFormat,
2272        limit: Option<usize>,
2273        quiet: bool,
2274        data_dir: Option<PathBuf>,
2275    },
2276    Remote {
2277        client: &'a HttpClient,
2278        batch_mode: &'a BatchMode,
2279        output_format: OutputFormat,
2280        limit: Option<usize>,
2281        quiet: bool,
2282        data_dir: Option<PathBuf>,
2283    },
2284}
2285
2286impl AdminBackend<'_> {
2287    fn local_db(&self) -> Option<&alopex_embedded::Database> {
2288        match self {
2289            AdminBackend::Local { db, .. } => Some(*db),
2290            AdminBackend::Remote { .. } => None,
2291        }
2292    }
2293
2294    fn output_format(&self) -> OutputFormat {
2295        match self {
2296            AdminBackend::Local { output_format, .. } => *output_format,
2297            AdminBackend::Remote { output_format, .. } => *output_format,
2298        }
2299    }
2300
2301    fn data_dir(&self) -> Option<&Path> {
2302        match self {
2303            AdminBackend::Local { data_dir, .. } => data_dir.as_deref(),
2304            AdminBackend::Remote { data_dir, .. } => data_dir.as_deref(),
2305        }
2306    }
2307
2308    fn limit(&self) -> Option<usize> {
2309        match self {
2310            AdminBackend::Local { limit, .. } => *limit,
2311            AdminBackend::Remote { limit, .. } => *limit,
2312        }
2313    }
2314
2315    fn quiet(&self) -> bool {
2316        match self {
2317            AdminBackend::Local { quiet, .. } => *quiet,
2318            AdminBackend::Remote { quiet, .. } => *quiet,
2319        }
2320    }
2321}
2322
2323pub struct AdminContext<'a> {
2324    pub connection_label: String,
2325    pub auth: AuthCapabilities,
2326    pub backend: AdminBackend<'a>,
2327    pub initial_target: Option<AdminTarget>,
2328}
2329
2330pub fn run_admin_ui(context: AdminContext<'_>) -> Result<()> {
2331    if !is_tty() {
2332        let mut writer = io::stdout().lock();
2333        return write_non_tty_fallback(&mut writer, context.backend.output_format());
2334    }
2335
2336    let app = AdminApp::new(
2337        context.connection_label,
2338        context.auth,
2339        context.backend,
2340        context.initial_target,
2341    );
2342    app.run()
2343}
2344
2345pub fn write_non_tty_fallback<W: Write>(writer: &mut W, output_format: OutputFormat) -> Result<()> {
2346    let mut formatter = create_formatter(output_format);
2347    let columns = vec![
2348        Column::new("Status", DataType::Text),
2349        Column::new("Message", DataType::Text),
2350    ];
2351    let rows = vec![Row::new(vec![
2352        Value::Text("Error".to_string()),
2353        Value::Text("Admin UI is unavailable without a TTY.".to_string()),
2354    ])];
2355    formatter.write_header(writer, &columns)?;
2356    for row in &rows {
2357        formatter.write_row(writer, row)?;
2358    }
2359    formatter.write_footer(writer)
2360}
2361
2362fn default_items() -> Vec<AdminItem> {
2363    vec![
2364        AdminItem {
2365            action: AdminAction::Read,
2366            title: "Read / List",
2367            description: "Browse or query data across databases, tables, and indexes.",
2368            enabled: true,
2369        },
2370        AdminItem {
2371            action: AdminAction::Create,
2372            title: "Create",
2373            description: "Create new databases, tables, indexes, or data objects.",
2374            enabled: true,
2375        },
2376        AdminItem {
2377            action: AdminAction::Update,
2378            title: "Update",
2379            description: "Modify existing records, schemas, or index settings.",
2380            enabled: true,
2381        },
2382        AdminItem {
2383            action: AdminAction::Delete,
2384            title: "Delete",
2385            description: "Remove records, tables, indexes, or data sets.",
2386            enabled: true,
2387        },
2388        AdminItem {
2389            action: AdminAction::Archive,
2390            title: "Archive",
2391            description: "Move data into an archived state for long-term retention.",
2392            enabled: true,
2393        },
2394        AdminItem {
2395            action: AdminAction::Restore,
2396            title: "Restore",
2397            description: "Restore archived data into an active state.",
2398            enabled: true,
2399        },
2400        AdminItem {
2401            action: AdminAction::Backup,
2402            title: "Backup",
2403            description: "Create snapshots or backups of data and metadata.",
2404            enabled: true,
2405        },
2406        AdminItem {
2407            action: AdminAction::Export,
2408            title: "Export",
2409            description: "Export data for external systems or offline analysis.",
2410            enabled: true,
2411        },
2412    ]
2413}
2414
2415fn is_not_implemented(action: AdminAction) -> bool {
2416    let _ = action;
2417    false
2418}
2419
2420fn parse_params(input: &str) -> std::collections::HashMap<String, String> {
2421    let mut params = std::collections::HashMap::new();
2422    let mut token = String::new();
2423    let mut in_quotes: Option<char> = None;
2424    for ch in input.chars() {
2425        if let Some(quote) = in_quotes {
2426            if ch == quote {
2427                in_quotes = None;
2428            } else {
2429                token.push(ch);
2430            }
2431            continue;
2432        }
2433        match ch {
2434            '"' | '\'' => {
2435                in_quotes = Some(ch);
2436            }
2437            ' ' | '\t' | '\n' | '\r' => {
2438                push_param_token(&mut params, &token);
2439                token.clear();
2440            }
2441            _ => token.push(ch),
2442        }
2443    }
2444    push_param_token(&mut params, &token);
2445    params
2446}
2447
2448fn push_param_token(params: &mut std::collections::HashMap<String, String>, token: &str) {
2449    if let Some((key, value)) = token.split_once('=') {
2450        if !key.is_empty() && !value.is_empty() {
2451            params.insert(key.to_lowercase(), value.to_string());
2452        }
2453    }
2454}
2455
2456fn build_command_for(
2457    action: AdminAction,
2458    target: AdminTarget,
2459    params: &std::collections::HashMap<String, String>,
2460) -> Result<Option<AdminCommand>> {
2461    if matches!(
2462        action,
2463        AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export
2464    ) {
2465        let handle = params
2466            .get("handle")
2467            .map(|value| value.trim())
2468            .filter(|value| !value.is_empty())
2469            .map(|value| value.to_string());
2470        let source = params
2471            .get("source")
2472            .map(|value| value.trim())
2473            .filter(|value| !value.is_empty())
2474            .map(|value| value.to_string());
2475
2476        let command = match action {
2477            AdminAction::Archive => LifecycleCommand::Archive,
2478            AdminAction::Restore => {
2479                if let Some(handle) = handle {
2480                    LifecycleCommand::Restore {
2481                        source: None,
2482                        command: Some(LifecycleRestoreCommand::Status { handle }),
2483                    }
2484                } else {
2485                    LifecycleCommand::Restore {
2486                        source,
2487                        command: None,
2488                    }
2489                }
2490            }
2491            AdminAction::Backup => {
2492                if let Some(handle) = handle {
2493                    LifecycleCommand::Backup {
2494                        command: Some(LifecycleBackupCommand::Status { handle }),
2495                    }
2496                } else {
2497                    LifecycleCommand::Backup { command: None }
2498                }
2499            }
2500            AdminAction::Export => LifecycleCommand::Export,
2501            _ => return Ok(None),
2502        };
2503        return Ok(Some(AdminCommand::Lifecycle(command)));
2504    }
2505    match target {
2506        AdminTarget::Sql => build_sql_command(action, params),
2507        AdminTarget::Kv => build_kv_command(action, params),
2508        AdminTarget::Vector => build_vector_command(action, params),
2509        AdminTarget::Hnsw => build_hnsw_command(action, params),
2510        AdminTarget::Columnar => build_columnar_command(action, params),
2511    }
2512}
2513
2514fn build_sql_command(
2515    action: AdminAction,
2516    params: &std::collections::HashMap<String, String>,
2517) -> Result<Option<AdminCommand>> {
2518    let query = if let Some(query) = params.get("query").cloned() {
2519        Some(query)
2520    } else if action == AdminAction::Read {
2521        let table = params.get("table").cloned();
2522        let columns = params.get("columns").cloned();
2523        match table {
2524            Some(table) => {
2525                let columns = columns
2526                    .filter(|value| !value.trim().is_empty())
2527                    .unwrap_or_else(|| "*".to_string());
2528                Some(format!("SELECT {} FROM {}", columns, table))
2529            }
2530            None => None,
2531        }
2532    } else {
2533        None
2534    };
2535    if query.is_none() {
2536        return Ok(None);
2537    }
2538    Ok(Some(AdminCommand::Sql(SqlCommand {
2539        query,
2540        file: None,
2541        fetch_size: None,
2542        max_rows: None,
2543        deadline: None,
2544        tui: false,
2545    })))
2546}
2547
2548fn build_kv_command(
2549    action: AdminAction,
2550    params: &std::collections::HashMap<String, String>,
2551) -> Result<Option<AdminCommand>> {
2552    match action {
2553        AdminAction::Read => {
2554            if let Some(key) = params.get("key") {
2555                return Ok(Some(AdminCommand::Kv(KvCommand::Get { key: key.clone() })));
2556            }
2557            let prefix = params.get("prefix").cloned();
2558            Ok(Some(AdminCommand::Kv(KvCommand::List { prefix })))
2559        }
2560        AdminAction::Create | AdminAction::Update => {
2561            let key = params.get("key").cloned();
2562            let value = params.get("value").cloned();
2563            match (key, value) {
2564                (Some(key), Some(value)) => {
2565                    Ok(Some(AdminCommand::Kv(KvCommand::Put { key, value })))
2566                }
2567                _ => Ok(None),
2568            }
2569        }
2570        AdminAction::Delete => {
2571            let key = params.get("key").cloned();
2572            match key {
2573                Some(key) => Ok(Some(AdminCommand::Kv(KvCommand::Delete { key }))),
2574                None => Ok(None),
2575            }
2576        }
2577        AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2578            Ok(None)
2579        }
2580    }
2581}
2582
2583fn build_vector_command(
2584    action: AdminAction,
2585    params: &std::collections::HashMap<String, String>,
2586) -> Result<Option<AdminCommand>> {
2587    let index = params.get("index").cloned();
2588    match action {
2589        AdminAction::Read => {
2590            let query = params.get("query").cloned();
2591            let index = match index {
2592                Some(index) => index,
2593                None => return Ok(None),
2594            };
2595            let query = match query {
2596                Some(query) => query,
2597                None => return Ok(None),
2598            };
2599            let k = params
2600                .get("k")
2601                .and_then(|value| value.parse::<usize>().ok())
2602                .unwrap_or(10);
2603            Ok(Some(AdminCommand::Vector(VectorCommand::Search {
2604                index,
2605                query,
2606                k,
2607                progress: false,
2608            })))
2609        }
2610        AdminAction::Create | AdminAction::Update => {
2611            let key = params.get("key").cloned();
2612            let vector = params.get("vector").cloned();
2613            match (index, key, vector) {
2614                (Some(index), Some(key), Some(vector)) => {
2615                    Ok(Some(AdminCommand::Vector(VectorCommand::Upsert {
2616                        index,
2617                        key,
2618                        vector,
2619                    })))
2620                }
2621                _ => Ok(None),
2622            }
2623        }
2624        AdminAction::Delete => {
2625            let key = params.get("key").cloned();
2626            match (index, key) {
2627                (Some(index), Some(key)) => Ok(Some(AdminCommand::Vector(VectorCommand::Delete {
2628                    index,
2629                    key,
2630                }))),
2631                _ => Ok(None),
2632            }
2633        }
2634        AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2635            Ok(None)
2636        }
2637    }
2638}
2639
2640fn build_hnsw_command(
2641    action: AdminAction,
2642    params: &std::collections::HashMap<String, String>,
2643) -> Result<Option<AdminCommand>> {
2644    match action {
2645        AdminAction::Read => {
2646            let name = match params.get("name").cloned() {
2647                Some(name) => name,
2648                None => return Ok(None),
2649            };
2650            Ok(Some(AdminCommand::Hnsw(HnswCommand::Stats { name })))
2651        }
2652        AdminAction::Create => {
2653            let name = match params.get("name").cloned() {
2654                Some(name) => name,
2655                None => return Ok(None),
2656            };
2657            let dim = match params
2658                .get("dim")
2659                .and_then(|value| value.parse::<usize>().ok())
2660            {
2661                Some(dim) => dim,
2662                None => return Ok(None),
2663            };
2664            let metric = if let Some(value) = params.get("metric") {
2665                parse_metric(value).ok_or_else(|| {
2666                    CliError::InvalidArgument(
2667                        "Invalid metric. Use metric=cosine|l2|ip.".to_string(),
2668                    )
2669                })?
2670            } else {
2671                DistanceMetric::Cosine
2672            };
2673            Ok(Some(AdminCommand::Hnsw(HnswCommand::Create {
2674                name,
2675                dim,
2676                metric,
2677            })))
2678        }
2679        AdminAction::Delete => {
2680            let name = match params.get("name").cloned() {
2681                Some(name) => name,
2682                None => return Ok(None),
2683            };
2684            Ok(Some(AdminCommand::Hnsw(HnswCommand::Drop { name })))
2685        }
2686        AdminAction::Update => Err(CliError::InvalidArgument(
2687            "Update is not supported for HNSW targets.".to_string(),
2688        )),
2689        AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2690            Ok(None)
2691        }
2692    }
2693}
2694
2695fn build_columnar_command(
2696    action: AdminAction,
2697    params: &std::collections::HashMap<String, String>,
2698) -> Result<Option<AdminCommand>> {
2699    match action {
2700        AdminAction::Read => {
2701            let mode = params
2702                .get("mode")
2703                .map(|value| value.as_str())
2704                .unwrap_or("list");
2705            match mode {
2706                "scan" => {
2707                    let segment = match params.get("segment").cloned() {
2708                        Some(segment) => segment,
2709                        None => return Ok(None),
2710                    };
2711                    Ok(Some(AdminCommand::Columnar(ColumnarCommand::Scan {
2712                        segment,
2713                        progress: false,
2714                    })))
2715                }
2716                "stats" => {
2717                    let segment = match params.get("segment").cloned() {
2718                        Some(segment) => segment,
2719                        None => return Ok(None),
2720                    };
2721                    Ok(Some(AdminCommand::Columnar(ColumnarCommand::Stats {
2722                        segment,
2723                    })))
2724                }
2725                "index_list" => {
2726                    let segment = match params.get("segment").cloned() {
2727                        Some(segment) => segment,
2728                        None => return Ok(None),
2729                    };
2730                    Ok(Some(AdminCommand::Columnar(ColumnarCommand::Index(
2731                        IndexCommand::List { segment },
2732                    ))))
2733                }
2734                "list" => Ok(Some(AdminCommand::Columnar(ColumnarCommand::List))),
2735                _ => Err(CliError::InvalidArgument(
2736                    "Unknown columnar mode. Use mode=list|scan|stats|index_list.".to_string(),
2737                )),
2738            }
2739        }
2740        AdminAction::Create => {
2741            if let (Some(file), Some(table)) =
2742                (params.get("file").cloned(), params.get("table").cloned())
2743            {
2744                return Ok(Some(AdminCommand::Columnar(ColumnarCommand::Ingest {
2745                    file: std::path::PathBuf::from(file),
2746                    table,
2747                    delimiter: ',',
2748                    header: true,
2749                    compression: "lz4".to_string(),
2750                    row_group_size: None,
2751                })));
2752            }
2753            if let (Some(segment), Some(column), Some(index_type)) = (
2754                params.get("segment").cloned(),
2755                params.get("column").cloned(),
2756                params.get("index_type").cloned(),
2757            ) {
2758                return Ok(Some(AdminCommand::Columnar(ColumnarCommand::Index(
2759                    IndexCommand::Create {
2760                        segment,
2761                        column,
2762                        index_type,
2763                    },
2764                ))));
2765            }
2766            Ok(None)
2767        }
2768        AdminAction::Delete => {
2769            if let (Some(segment), Some(column)) = (
2770                params.get("segment").cloned(),
2771                params.get("column").cloned(),
2772            ) {
2773                return Ok(Some(AdminCommand::Columnar(ColumnarCommand::Index(
2774                    IndexCommand::Drop { segment, column },
2775                ))));
2776            }
2777            Ok(None)
2778        }
2779        AdminAction::Update => Err(CliError::InvalidArgument(
2780            "Update is not supported for columnar targets.".to_string(),
2781        )),
2782        AdminAction::Archive | AdminAction::Restore | AdminAction::Backup | AdminAction::Export => {
2783            Ok(None)
2784        }
2785    }
2786}
2787
2788fn parse_metric(value: &str) -> Option<DistanceMetric> {
2789    match value.to_lowercase().as_str() {
2790        "cosine" => Some(DistanceMetric::Cosine),
2791        "l2" => Some(DistanceMetric::L2),
2792        "ip" => Some(DistanceMetric::Ip),
2793        _ => None,
2794    }
2795}
2796
2797fn render_help(frame: &mut ratatui::Frame<'_>, area: Rect) {
2798    let help_width = area.width.saturating_sub(4).min(60);
2799    let help_height = area.height.saturating_sub(4).min(12);
2800    let rect = Rect::new(
2801        area.x + (area.width.saturating_sub(help_width)) / 2,
2802        area.y + (area.height.saturating_sub(help_height)) / 2,
2803        help_width,
2804        help_height,
2805    );
2806
2807    let lines = [
2808        "h/l or Left/Right: move focus",
2809        "Menu: j/k move, / search, e edit, r raw, Enter select, R refresh",
2810        "Input: Up/Down action, Tab field, e edit, o list, r raw, Enter execute",
2811        "Data: j/k scroll",
2812        "a: back",
2813        "?: toggle help",
2814        "q/Esc: quit",
2815    ]
2816    .join("\n");
2817
2818    let help = Paragraph::new(lines)
2819        .block(Block::default().borders(Borders::ALL).title("Help"))
2820        .wrap(Wrap { trim: true });
2821    frame.render_widget(help, rect);
2822}
2823
2824#[derive(Debug, Clone)]
2825struct SelectionOverlay {
2826    title: String,
2827    items: Vec<String>,
2828    selected: usize,
2829    field_index: usize,
2830    search: Option<String>,
2831    search_focused: bool,
2832}
2833
2834impl SelectionOverlay {
2835    fn new(title: String, items: Vec<String>, field_index: usize) -> Self {
2836        Self {
2837            title,
2838            items,
2839            selected: 0,
2840            field_index,
2841            search: None,
2842            search_focused: false,
2843        }
2844    }
2845
2846    fn search_term(&self) -> Option<&str> {
2847        self.search
2848            .as_deref()
2849            .filter(|value| !value.trim().is_empty())
2850    }
2851
2852    fn filtered_indices(&self) -> Vec<usize> {
2853        let Some(term) = self.search_term() else {
2854            return (0..self.items.len()).collect();
2855        };
2856        let term = term.to_lowercase();
2857        self.items
2858            .iter()
2859            .enumerate()
2860            .filter_map(|(idx, item)| {
2861                if item.to_lowercase().contains(&term) {
2862                    Some(idx)
2863                } else {
2864                    None
2865                }
2866            })
2867            .collect()
2868    }
2869
2870    fn selected_value(&self) -> Option<String> {
2871        let indices = self.filtered_indices();
2872        let idx = indices.get(self.selected).copied()?;
2873        self.items.get(idx).cloned()
2874    }
2875
2876    fn ensure_selection_in_range(&mut self) {
2877        let len = self.filtered_indices().len();
2878        if len == 0 {
2879            self.selected = 0;
2880        } else if self.selected >= len {
2881            self.selected = len - 1;
2882        }
2883    }
2884
2885    fn move_up(&mut self) {
2886        if self.selected > 0 {
2887            self.selected -= 1;
2888        }
2889    }
2890
2891    fn move_down(&mut self) {
2892        let len = self.filtered_indices().len();
2893        if self.selected + 1 < len {
2894            self.selected += 1;
2895        }
2896    }
2897
2898    fn move_top(&mut self) {
2899        self.selected = 0;
2900    }
2901
2902    fn move_bottom(&mut self) {
2903        let len = self.filtered_indices().len();
2904        if len > 0 {
2905            self.selected = len - 1;
2906        }
2907    }
2908
2909    fn push_search(&mut self, ch: char) {
2910        let search = self.search.get_or_insert_with(String::new);
2911        search.push(ch);
2912        self.ensure_selection_in_range();
2913    }
2914
2915    fn pop_search(&mut self) {
2916        if let Some(search) = self.search.as_mut() {
2917            if !search.is_empty() {
2918                search.pop();
2919            } else {
2920                self.reset_search();
2921            }
2922        }
2923        self.ensure_selection_in_range();
2924    }
2925
2926    fn reset_search(&mut self) {
2927        self.search = None;
2928        self.search_focused = false;
2929        self.selected = 0;
2930    }
2931}
2932
2933fn render_selection_overlay(
2934    frame: &mut ratatui::Frame<'_>,
2935    area: Rect,
2936    selection: &SelectionOverlay,
2937) {
2938    let overlay_width = area.width.saturating_sub(6).min(60);
2939    let overlay_height = area.height.saturating_sub(6).min(16);
2940    let rect = Rect::new(
2941        area.x + (area.width.saturating_sub(overlay_width)) / 2,
2942        area.y + (area.height.saturating_sub(overlay_height)) / 2,
2943        overlay_width,
2944        overlay_height,
2945    );
2946
2947    let layout = Layout::default()
2948        .direction(Direction::Vertical)
2949        .constraints([
2950            Constraint::Length(1),
2951            Constraint::Length(1),
2952            Constraint::Min(3),
2953        ])
2954        .split(rect);
2955
2956    let search = selection
2957        .search
2958        .as_ref()
2959        .map(|value| format!("/ {value}"))
2960        .unwrap_or_else(|| "/".to_string());
2961    let search_style = if selection.search_focused {
2962        Style::default().fg(Color::Yellow)
2963    } else {
2964        Style::default().fg(Color::Gray)
2965    };
2966    frame.render_widget(
2967        Paragraph::new(search)
2968            .block(
2969                Block::default()
2970                    .borders(Borders::ALL)
2971                    .title(selection.title.as_str()),
2972            )
2973            .style(search_style),
2974        layout[0],
2975    );
2976
2977    frame.render_widget(
2978        Paragraph::new("Enter: choose  Esc: close  /: search  g/G: top/bottom  j/k: move")
2979            .style(Style::default().fg(Color::DarkGray)),
2980        layout[1],
2981    );
2982
2983    let indices = selection.filtered_indices();
2984    let items = if indices.is_empty() {
2985        vec![ListItem::new(Line::from("No options available."))]
2986    } else {
2987        indices
2988            .iter()
2989            .filter_map(|idx| selection.items.get(*idx))
2990            .map(|item| ListItem::new(Line::from(item.clone())))
2991            .collect::<Vec<_>>()
2992    };
2993    let mut state = ListState::default();
2994    state.select(Some(selection.selected));
2995    let list = List::new(items)
2996        .block(Block::default().borders(Borders::ALL))
2997        .highlight_style(
2998            Style::default()
2999                .bg(Color::Blue)
3000                .fg(Color::White)
3001                .add_modifier(Modifier::BOLD),
3002        )
3003        .highlight_symbol("> ");
3004    frame.render_stateful_widget(list, layout[2], &mut state);
3005}
3006
3007fn cleanup_terminal(mut terminal: Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
3008    disable_raw_mode()?;
3009    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
3010    terminal.show_cursor()?;
3011    Ok(())
3012}
3013
3014#[derive(Debug, Default, Clone)]
3015struct AdminResult {
3016    columns: Vec<Column>,
3017    rows: Vec<Row>,
3018    status_message: Option<String>,
3019}
3020
3021impl AdminResult {
3022    fn status(message: String) -> Self {
3023        Self {
3024            columns: Vec::new(),
3025            rows: Vec::new(),
3026            status_message: Some(message),
3027        }
3028    }
3029}
3030
3031#[derive(Default, Clone)]
3032struct CaptureState {
3033    columns: Vec<Column>,
3034    rows: Vec<Row>,
3035}
3036
3037struct CaptureFormatter {
3038    state: Arc<Mutex<CaptureState>>,
3039}
3040
3041impl CaptureFormatter {
3042    /// Create a fresh shared capture state.
3043    fn shared_state() -> Arc<Mutex<CaptureState>> {
3044        Arc::new(Mutex::new(CaptureState::default()))
3045    }
3046
3047    /// Formatter factory producing capture formatters bound to `state`.
3048    ///
3049    /// The `sql` command creates one formatter per statement result block, so
3050    /// admin capture hands out multiple formatters sharing one state.
3051    fn factory(state: &Arc<Mutex<CaptureState>>) -> impl FnMut() -> Box<dyn Formatter> {
3052        let state = Arc::clone(state);
3053        move || {
3054            Box::new(CaptureFormatter {
3055                state: Arc::clone(&state),
3056            }) as Box<dyn Formatter>
3057        }
3058    }
3059}
3060
3061impl Formatter for CaptureFormatter {
3062    fn write_header(&mut self, _writer: &mut dyn std::io::Write, columns: &[Column]) -> Result<()> {
3063        let mut state = self.state.lock().expect("admin capture lock");
3064        state.columns = columns.to_vec();
3065        Ok(())
3066    }
3067
3068    fn write_row(&mut self, _writer: &mut dyn std::io::Write, row: &Row) -> Result<()> {
3069        self.state
3070            .lock()
3071            .expect("admin capture lock")
3072            .rows
3073            .push(row.clone());
3074        Ok(())
3075    }
3076
3077    fn write_footer(&mut self, _writer: &mut dyn std::io::Write) -> Result<()> {
3078        Ok(())
3079    }
3080
3081    fn supports_streaming(&self) -> bool {
3082        true
3083    }
3084}
3085
3086fn append_result_lines(lines: &mut Vec<Line<'static>>, result: &AdminResult) {
3087    if result.columns.is_empty() && result.rows.is_empty() && result.status_message.is_none() {
3088        return;
3089    }
3090    lines.push(Line::from(""));
3091    lines.push(Line::from(Span::styled(
3092        "Last Result",
3093        Style::default().add_modifier(Modifier::BOLD),
3094    )));
3095    if let Some(message) = &result.status_message {
3096        lines.push(Line::from(message.clone()));
3097    }
3098    if !result.columns.is_empty() {
3099        let header = result
3100            .columns
3101            .iter()
3102            .map(|col| col.name.clone())
3103            .collect::<Vec<_>>()
3104            .join(" | ");
3105        lines.push(Line::from(header));
3106        for row in &result.rows {
3107            let row_text = row
3108                .columns
3109                .iter()
3110                .map(value_to_string)
3111                .collect::<Vec<_>>()
3112                .join(" | ");
3113            lines.push(Line::from(row_text));
3114        }
3115    }
3116}
3117
3118fn value_to_string(value: &Value) -> String {
3119    match value {
3120        Value::Null => "NULL".to_string(),
3121        Value::Bool(b) => b.to_string(),
3122        Value::Int(i) => i.to_string(),
3123        Value::Float(f) => format!("{f:.6}"),
3124        Value::Text(text) => text.clone(),
3125        Value::Bytes(bytes) => format!("{:02x?}", bytes),
3126        Value::Vector(values) => format!(
3127            "[{}]",
3128            values
3129                .iter()
3130                .take(4)
3131                .map(|value| format!("{value:.4}"))
3132                .collect::<Vec<_>>()
3133                .join(", ")
3134        ),
3135    }
3136}
3137
3138#[cfg(test)]
3139mod tests {
3140    use super::*;
3141    use crate::batch::{BatchMode, BatchModeSource};
3142    use alopex_embedded::Database;
3143
3144    fn make_app<'a>(db: &'a Database) -> AdminApp<'a> {
3145        let batch_mode = Box::leak(Box::new(BatchMode {
3146            is_batch: true,
3147            is_tty: true,
3148            source: BatchModeSource::Explicit,
3149        }));
3150        let backend = AdminBackend::Local {
3151            db,
3152            batch_mode,
3153            output_format: OutputFormat::Table,
3154            limit: None,
3155            quiet: true,
3156            data_dir: None,
3157        };
3158        AdminApp::new(
3159            "local",
3160            AuthCapabilities::full(),
3161            backend,
3162            Some(AdminTarget::Kv),
3163        )
3164    }
3165
3166    fn field_value(app: &AdminApp<'_>, key: &str) -> Option<String> {
3167        app.form_fields
3168            .iter()
3169            .find(|field| field.key.eq_ignore_ascii_case(key))
3170            .map(|field| field.value.clone())
3171    }
3172
3173    #[test]
3174    fn resource_tree_filters_keep_parents() {
3175        let entries = vec![
3176            ResourceEntry {
3177                label: "SQL Tables".to_string(),
3178                kind: ResourceKind::Section(ResourceSection::SqlTables),
3179                depth: 0,
3180                selectable: false,
3181            },
3182            ResourceEntry {
3183                label: "users".to_string(),
3184                kind: ResourceKind::Table {
3185                    name: "users".to_string(),
3186                },
3187                depth: 1,
3188                selectable: true,
3189            },
3190            ResourceEntry {
3191                label: "email".to_string(),
3192                kind: ResourceKind::Column {
3193                    table: "users".to_string(),
3194                    name: "email".to_string(),
3195                },
3196                depth: 2,
3197                selectable: true,
3198            },
3199        ];
3200        let tree = ResourceTree {
3201            entries,
3202            selected: 0,
3203            search: Some("email".to_string()),
3204            search_focused: false,
3205            last_error: None,
3206            last_status: None,
3207        };
3208        let indices = tree.filtered_indices();
3209        assert_eq!(indices, vec![0, 1, 2]);
3210    }
3211
3212    #[test]
3213    fn resource_tree_paging_clamps() {
3214        let entries = (0..12)
3215            .map(|idx| ResourceEntry {
3216                label: format!("item-{idx}"),
3217                kind: ResourceKind::Info,
3218                depth: 0,
3219                selectable: true,
3220            })
3221            .collect::<Vec<_>>();
3222        let mut tree = ResourceTree {
3223            entries,
3224            selected: 0,
3225            search: None,
3226            search_focused: false,
3227            last_error: None,
3228            last_status: None,
3229        };
3230        tree.page_down();
3231        assert_eq!(tree.selected, 5);
3232        tree.page_down();
3233        assert_eq!(tree.selected, 10);
3234        tree.page_down();
3235        assert_eq!(tree.selected, 11);
3236        tree.page_up();
3237        assert_eq!(tree.selected, 6);
3238    }
3239
3240    #[test]
3241    fn selection_overlay_filters_values() {
3242        let mut overlay = SelectionOverlay::new(
3243            "Select".to_string(),
3244            vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()],
3245            0,
3246        );
3247        overlay.search = Some("et".to_string());
3248        overlay.ensure_selection_in_range();
3249        assert_eq!(overlay.selected_value(), Some("beta".to_string()));
3250        overlay.move_down();
3251        assert_eq!(overlay.selected_value(), Some("beta".to_string()));
3252    }
3253
3254    #[test]
3255    fn focus_transitions_follow_table_detail_status() {
3256        let db = Database::open_in_memory().expect("db");
3257        let mut app = make_app(&db);
3258        app.focus = AdminFocus::Table;
3259        assert_eq!(app.focus_right(), AdminFocus::Detail);
3260        app.focus = AdminFocus::Detail;
3261        assert_eq!(app.focus_left(), AdminFocus::Table);
3262        assert_eq!(app.focus_right(), AdminFocus::Status);
3263        app.focus = AdminFocus::Status;
3264        assert_eq!(app.focus_left(), AdminFocus::Detail);
3265        assert_eq!(app.focus_right(), AdminFocus::Status);
3266    }
3267
3268    #[test]
3269    fn resource_selection_sets_sql_fields() {
3270        let db = Database::open_in_memory().expect("db");
3271        let mut app = make_app(&db);
3272        app.resources = ResourceTree {
3273            entries: vec![
3274                ResourceEntry {
3275                    label: "SQL Tables".to_string(),
3276                    kind: ResourceKind::Section(ResourceSection::SqlTables),
3277                    depth: 0,
3278                    selectable: false,
3279                },
3280                ResourceEntry {
3281                    label: "users".to_string(),
3282                    kind: ResourceKind::Table {
3283                        name: "users".to_string(),
3284                    },
3285                    depth: 1,
3286                    selectable: true,
3287                },
3288            ],
3289            selected: 1,
3290            search: None,
3291            search_focused: false,
3292            last_error: None,
3293            last_status: None,
3294        };
3295        app.apply_resource_selection().expect("select table");
3296        assert_eq!(app.target, AdminTarget::Sql);
3297        assert_eq!(field_value(&app, "table"), Some("users".to_string()));
3298    }
3299
3300    #[test]
3301    fn resource_selection_sets_kv_key() {
3302        let db = Database::open_in_memory().expect("db");
3303        let mut app = make_app(&db);
3304        app.resources = ResourceTree {
3305            entries: vec![ResourceEntry {
3306                label: "mykey".to_string(),
3307                kind: ResourceKind::KvKey {
3308                    key: "mykey".to_string(),
3309                },
3310                depth: 1,
3311                selectable: true,
3312            }],
3313            selected: 0,
3314            search: None,
3315            search_focused: false,
3316            last_error: None,
3317            last_status: None,
3318        };
3319        app.apply_resource_selection().expect("select key");
3320        assert_eq!(app.target, AdminTarget::Kv);
3321        assert_eq!(field_value(&app, "key"), Some("mykey".to_string()));
3322    }
3323}