use std::io::Write;
pub mod ansi {
pub const CLEAR_SCREEN: &str = "\x1b[2J";
pub const CURSOR_HOME: &str = "\x1b[H";
pub const BOLD: &str = "\x1b[1m";
pub const RESET: &str = "\x1b[0m";
pub const GREEN: &str = "\x1b[32m";
pub const RED: &str = "\x1b[31m";
pub const YELLOW: &str = "\x1b[33m";
pub const CYAN: &str = "\x1b[36m";
pub const DIM: &str = "\x1b[2m";
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TuiItem {
pub id: String,
pub description: String,
pub action: String,
pub selected: bool,
}
#[derive(Debug)]
pub struct TuiState {
pub items: Vec<TuiItem>,
pub cursor: usize,
pub title: String,
pub mode: TuiMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiMode {
Browse,
Select,
Confirm,
}
impl TuiState {
pub fn new(title: &str, items: Vec<TuiItem>) -> Self {
TuiState {
cursor: 0,
title: title.to_string(),
mode: TuiMode::Browse,
items,
}
}
pub fn cursor_up(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn cursor_down(&mut self) {
if self.cursor + 1 < self.items.len() {
self.cursor += 1;
}
}
pub fn toggle_select(&mut self) {
if let Some(item) = self.items.get_mut(self.cursor) {
item.selected = !item.selected;
}
}
pub fn select_all(&mut self) {
for item in &mut self.items {
item.selected = true;
}
}
pub fn deselect_all(&mut self) {
for item in &mut self.items {
item.selected = false;
}
}
pub fn selected(&self) -> Vec<&TuiItem> {
self.items.iter().filter(|i| i.selected).collect()
}
pub fn render(&self) -> String {
let mut out = String::new();
out.push_str(&format!(
"{}{} {}{}\n\n",
ansi::BOLD,
ansi::CYAN,
self.title,
ansi::RESET
));
for (i, item) in self.items.iter().enumerate() {
let cursor = if i == self.cursor { ">" } else { " " };
let check = if item.selected { "[x]" } else { "[ ]" };
let color = action_color(&item.action);
out.push_str(&format!(
" {cursor} {check} {color}{}{} — {}{}\n",
item.id,
ansi::RESET,
item.description,
ansi::RESET
));
}
out.push_str(&format!(
"\n{}[j/k] move [space] toggle [a] all [enter] confirm [q] quit{}\n",
ansi::DIM,
ansi::RESET
));
out
}
pub fn display(&self) {
let rendered = self.render();
eprint!("{}{}{}", ansi::CLEAR_SCREEN, ansi::CURSOR_HOME, rendered);
let _ = std::io::stderr().flush();
}
}
fn action_color(action: &str) -> &str {
match action {
"create" => ansi::GREEN,
"update" => ansi::YELLOW,
"destroy" => ansi::RED,
_ => ansi::RESET,
}
}
pub fn plan_to_tui_items(changes: &[(String, String, String)]) -> Vec<TuiItem> {
changes
.iter()
.map(|(id, action, desc)| TuiItem {
id: id.clone(),
description: desc.clone(),
action: action.clone(),
selected: action != "destroy",
})
.collect()
}
#[derive(Debug, serde::Serialize)]
pub struct TuiResult {
pub approved: Vec<String>,
pub rejected: Vec<String>,
pub confirmed: bool,
}
pub fn build_result(state: &TuiState) -> TuiResult {
let approved: Vec<String> = state
.items
.iter()
.filter(|i| i.selected)
.map(|i| i.id.clone())
.collect();
let rejected: Vec<String> = state
.items
.iter()
.filter(|i| !i.selected)
.map(|i| i.id.clone())
.collect();
TuiResult {
confirmed: state.mode == TuiMode::Confirm,
approved,
rejected,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_items() -> Vec<TuiItem> {
vec![
TuiItem {
id: "pkg-nginx".into(),
description: "install nginx".into(),
action: "create".into(),
selected: true,
},
TuiItem {
id: "file-conf".into(),
description: "write config".into(),
action: "update".into(),
selected: true,
},
TuiItem {
id: "svc-old".into(),
description: "remove service".into(),
action: "destroy".into(),
selected: false,
},
]
}
#[test]
fn test_tui_state_new() {
let state = TuiState::new("Plan Review", sample_items());
assert_eq!(state.cursor, 0);
assert_eq!(state.items.len(), 3);
}
#[test]
fn test_cursor_navigation() {
let mut state = TuiState::new("test", sample_items());
state.cursor_down();
assert_eq!(state.cursor, 1);
state.cursor_down();
assert_eq!(state.cursor, 2);
state.cursor_down(); assert_eq!(state.cursor, 2);
state.cursor_up();
assert_eq!(state.cursor, 1);
}
#[test]
fn test_toggle_select() {
let mut state = TuiState::new("test", sample_items());
assert!(state.items[0].selected);
state.toggle_select();
assert!(!state.items[0].selected);
state.toggle_select();
assert!(state.items[0].selected);
}
#[test]
fn test_select_all() {
let mut state = TuiState::new("test", sample_items());
state.select_all();
assert!(state.items.iter().all(|i| i.selected));
}
#[test]
fn test_deselect_all() {
let mut state = TuiState::new("test", sample_items());
state.deselect_all();
assert!(state.items.iter().all(|i| !i.selected));
}
#[test]
fn test_selected() {
let state = TuiState::new("test", sample_items());
let sel = state.selected();
assert_eq!(sel.len(), 2); }
#[test]
fn test_render_contains_items() {
let state = TuiState::new("Plan Review", sample_items());
let rendered = state.render();
assert!(rendered.contains("pkg-nginx"));
assert!(rendered.contains("file-conf"));
assert!(rendered.contains("svc-old"));
}
#[test]
fn test_plan_to_tui_items() {
let changes = vec![
("A".into(), "create".into(), "install A".into()),
("B".into(), "destroy".into(), "remove B".into()),
];
let items = plan_to_tui_items(&changes);
assert_eq!(items.len(), 2);
assert!(items[0].selected); assert!(!items[1].selected); }
#[test]
fn test_build_result() {
let state = TuiState::new("test", sample_items());
let result = build_result(&state);
assert_eq!(result.approved.len(), 2);
assert_eq!(result.rejected.len(), 1);
assert!(!result.confirmed); }
#[test]
fn test_result_serde() {
let result = TuiResult {
approved: vec!["A".into()],
rejected: vec!["B".into()],
confirmed: true,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"confirmed\":true"));
}
}