use crate::tui::model::EditModel;
use crate::tui::model::{EditBody, EditHeader, EditQuery, EditResponse, EditSchema, EditVariable};
use crate::tui::rows::{BodyLoc, CellKind, Expand, Field, RowKind, Section, TableRow, flatten};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::json::{method_all, method_str};
use std::path::Path;
const HINT: &str = "↑↓ select · Enter edit/open · ←→ cell · a add · d delete · Esc back · Ctrl-S save · q quit · ? help";
#[derive(Debug, Clone, PartialEq, Default)]
pub(crate) enum Mode {
#[default]
Normal,
Insert(String),
Example,
Help,
ConfirmQuit,
ConfirmDelete(Field),
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Action {
None,
OpenExample(Field, String),
Save,
Quit,
}
pub(crate) struct UiState {
pub sections: Vec<Section>,
pub sec: usize,
pub row: usize,
pub cell: Option<usize>,
pub mode: Mode,
pub dirty: bool,
pub status: String,
pub expanded: Option<Expand>,
pub original: EditModel,
}
impl UiState {
pub fn new(model: &EditModel) -> Self {
let sections = flatten(model, None);
let mut s = UiState {
sections,
sec: 0,
row: 0,
cell: None,
mode: Mode::Normal,
dirty: false,
status: HINT.to_string(),
expanded: None,
original: model.clone(),
};
s.snap_to_first_row();
s
}
pub fn refresh(&mut self, model: &EditModel) {
self.sections = flatten(model, self.expanded);
if self.sec >= self.sections.len() {
self.sec = self.sections.len().saturating_sub(1);
}
let nrows = self
.sections
.get(self.sec)
.map(|s| s.rows.len())
.unwrap_or(0);
if self.row >= nrows {
self.row = nrows.saturating_sub(1);
}
if let Some(c) = self.cell {
let ncells = self.current_row().map(|r| r.cells.len()).unwrap_or(0);
if c >= ncells {
self.cell = None;
}
}
self.dirty = model != &self.original;
}
fn snap_to_first_row(&mut self) {
for (si, s) in self.sections.iter().enumerate() {
if !s.rows.is_empty() {
self.sec = si;
self.row = 0;
return;
}
}
}
pub fn current_row(&self) -> Option<&TableRow> {
self.sections.get(self.sec)?.rows.get(self.row)
}
fn focused_field(&self) -> Option<Field> {
let c = self.cell?;
self.current_row()?
.cells
.get(c)
.map(|cell| cell.field.clone())
}
pub fn focused_field_pub(&self) -> Option<Field> {
self.focused_field()
}
fn editable_cells(&self) -> Vec<usize> {
self.current_row()
.map(|r| {
r.cells
.iter()
.enumerate()
.filter(|(_, c)| c.kind != CellKind::Label)
.map(|(i, _)| i)
.collect()
})
.unwrap_or_default()
}
fn move_row(&mut self, dir: isize) {
let coords: Vec<(usize, usize)> = self
.sections
.iter()
.enumerate()
.flat_map(|(si, s)| (0..s.rows.len()).map(move |ri| (si, ri)))
.collect();
if coords.is_empty() {
return;
}
let pos = coords
.iter()
.position(|&(si, ri)| si == self.sec && ri == self.row)
.unwrap_or(0);
let np = (pos as isize + dir).clamp(0, coords.len() as isize - 1) as usize;
let (s, r) = coords[np];
self.sec = s;
self.row = r;
self.cell = None;
}
fn move_cell(&mut self, dir: isize) {
let edit = self.editable_cells();
if edit.is_empty() {
return;
}
let cur = self.cell.unwrap_or(edit[0]);
let pos = edit.iter().position(|&i| i == cur).unwrap_or(0);
let np = (pos as isize + dir).clamp(0, edit.len() as isize - 1) as usize;
self.cell = Some(edit[np]);
}
}
fn delete_field(state: &UiState) -> Option<Field> {
let row = state.current_row()?;
row.cells
.iter()
.find(|c| c.kind != CellKind::Label)
.or_else(|| row.cells.first())
.map(|c| c.field.clone())
}
fn is_deletable(field: &Field) -> bool {
matches!(
field,
Field::PathSeg(_)
| Field::QueryName(_)
| Field::QueryValue(_)
| Field::QueryDesc(_)
| Field::QueryRequired(_)
| Field::VarName(_)
| Field::VarType(_)
| Field::VarDesc(_)
| Field::VarRequired(_)
| Field::HeaderName(_)
| Field::HeaderValue(_)
| Field::ResponseCode(_)
| Field::ResponseDesc(_)
| Field::SchemaName(_, _)
| Field::SchemaType(_, _)
| Field::SchemaDesc(_, _)
| Field::SchemaRequired(_, _)
| Field::SchemaAccept(_, _)
)
}
pub(crate) fn handle_normal(state: &mut UiState, model: &mut EditModel, key: KeyEvent) -> Action {
if (key.code, key.modifiers) == (KeyCode::Char('s'), KeyModifiers::CONTROL) {
return Action::Save;
}
if state.cell.is_some() {
return handle_cell(state, model, key);
}
match (key.code, key.modifiers) {
(KeyCode::Esc, _) if state.expanded.is_some() => {
state.expanded = None;
state.refresh(model);
Action::None
}
(KeyCode::Char('q'), _) | (KeyCode::Esc, _) => {
if state.dirty {
state.mode = Mode::ConfirmQuit;
Action::None
} else {
Action::Quit
}
}
(KeyCode::Char('?'), _) => {
state.mode = Mode::Help;
Action::None
}
(KeyCode::Down, _) | (KeyCode::Char('j'), _) => {
state.move_row(1);
Action::None
}
(KeyCode::Up, _) | (KeyCode::Char('k'), _) => {
state.move_row(-1);
Action::None
}
(KeyCode::Enter, _) => begin_row(state, model),
(KeyCode::Char('a'), _) => {
append_here(state, model);
Action::None
}
(KeyCode::Char('g'), _) => {
generate_example_here(state, model);
Action::None
}
(KeyCode::Char('d'), _) => {
if let Some(f) = delete_field(state)
&& is_deletable(&f)
{
state.mode = Mode::ConfirmDelete(f);
}
Action::None
}
_ => Action::None,
}
}
fn generate_example_here(state: &mut UiState, model: &mut EditModel) {
let loc = match state.sections.get(state.sec).and_then(|s| s.expand) {
Some(Expand::Request) => BodyLoc::Request,
Some(Expand::Response(i)) => BodyLoc::Response(i),
_ => return,
};
let body = match &loc {
BodyLoc::Request => model
.request
.as_ref()
.map(|b| (b.schema.clone(), b.dtype.clone())),
BodyLoc::Response(i) => model
.responses
.get(*i)
.map(|r| (r.schema.clone(), r.dtype.clone())),
};
let Some((schema, dtype)) = body else { return };
let mut value = crate::tui::model::example_from_schema(&schema);
if crate::json::parse_type(&dtype).1 {
value = serde_json::Value::Array(vec![value]);
}
let Ok(text) = crate::template::render_pretty(&value) else {
return;
};
match &loc {
BodyLoc::Request => {
if let Some(b) = model.request.as_mut() {
b.example = text;
}
}
BodyLoc::Response(i) => {
if let Some(r) = model.responses.get_mut(*i) {
r.example = text;
}
}
}
state.dirty = true;
state.refresh(model);
}
fn append_here(state: &mut UiState, model: &mut EditModel) {
let target = if let Some((loc, path, is_object)) = focused_schema_target(state) {
if is_object {
Field::SchemaAdd(loc, path) } else {
let mut parent = path;
parent.pop();
Field::SchemaAdd(loc, parent) }
} else if let Some(field) = state.sections.get(state.sec).and_then(|s| s.add.clone()) {
field
} else {
return;
};
add_row(state, model, &target);
if target == Field::ResponseAdd {
if let Some(idx) = model.responses.len().checked_sub(1) {
state.expanded = Some(Expand::Response(idx));
state.refresh(model);
focus_and_insert(state, &Field::ResponseCode(idx));
}
return;
}
if let Some(nf) = new_name_field(model, &target) {
focus_and_insert(state, &nf);
}
}
fn new_name_field(model: &EditModel, target: &Field) -> Option<Field> {
match target {
Field::QueryAdd => model.url.query.len().checked_sub(1).map(Field::QueryName),
Field::VarAdd => model.url.variable.len().checked_sub(1).map(Field::VarName),
Field::HeaderAdd => model.headers.len().checked_sub(1).map(Field::HeaderName),
Field::PathAdd => model.url.path.len().checked_sub(1).map(Field::PathSeg),
Field::ResponseAdd => model
.responses
.len()
.checked_sub(1)
.map(Field::ResponseCode),
Field::SchemaAdd(loc, path) => {
let len = schema_children_len(model, loc, path)?;
let new_index = len.checked_sub(1)?;
let mut p = path.clone();
p.push(new_index);
Some(Field::SchemaName(loc.clone(), p))
}
_ => None,
}
}
fn schema_children_len(model: &EditModel, loc: &BodyLoc, path: &[usize]) -> Option<usize> {
let root = match loc {
BodyLoc::Request => model.request.as_ref()?.schema.as_slice(),
BodyLoc::Response(r) => model.responses.get(*r)?.schema.as_slice(),
};
schema_children_len_at(root, path)
}
fn schema_children_len_at(fields: &[EditSchema], path: &[usize]) -> Option<usize> {
match path.split_first() {
None => Some(fields.len()),
Some((&first, rest)) => schema_children_len_at(&fields.get(first)?.properties, rest),
}
}
fn focus_and_insert(state: &mut UiState, name_field: &Field) {
for (si, sec) in state.sections.iter().enumerate() {
for (ri, row) in sec.rows.iter().enumerate() {
if let Some(ci) = row.cells.iter().position(|c| &c.field == name_field) {
state.sec = si;
state.row = ri;
state.cell = Some(ci);
state.mode = Mode::Insert(row.cells[ci].value.clone());
return;
}
}
}
}
fn focused_schema_target(state: &UiState) -> Option<(BodyLoc, Vec<usize>, bool)> {
let row = state.current_row()?;
let (loc, path) = row.cells.iter().find_map(|c| match &c.field {
Field::SchemaName(l, p)
| Field::SchemaType(l, p)
| Field::SchemaRequired(l, p)
| Field::SchemaAccept(l, p)
| Field::SchemaDesc(l, p) => Some((l.clone(), p.clone())),
_ => None,
})?;
let dtype = row
.cells
.iter()
.find_map(|c| matches!(&c.field, Field::SchemaType(_, _)).then(|| c.value.clone()))
.unwrap_or_default();
let is_object = crate::json::parse_type(&dtype).0 == "object";
Some((loc, path, is_object))
}
fn handle_cell(state: &mut UiState, model: &mut EditModel, key: KeyEvent) -> Action {
match key.code {
KeyCode::Esc => {
state.cell = None;
Action::None
}
KeyCode::Left => {
state.move_cell(-1);
Action::None
}
KeyCode::Right => {
state.move_cell(1);
Action::None
}
KeyCode::Char('h') => {
state.move_cell(-1);
Action::None
}
KeyCode::Char('l') => {
state.move_cell(1);
Action::None
}
KeyCode::Char(' ') => {
if let (Some(c), Some(field)) = (state.cell, state.focused_field()) {
let is_bool = state
.current_row()
.and_then(|r| r.cells.get(c))
.map(|cell| cell.kind == CellKind::Bool)
.unwrap_or(false);
if is_bool {
toggle_bool(model, &field);
state.dirty = true;
state.refresh(model);
}
}
Action::None
}
KeyCode::Char('i') => {
if let Some(cell) = state
.cell
.and_then(|c| state.current_row().and_then(|r| r.cells.get(c)))
&& cell.kind == CellKind::Text
{
state.mode = Mode::Insert(cell.value.clone());
}
Action::None
}
KeyCode::Enter => begin_cell_edit(state, model),
_ => Action::None,
}
}
fn begin_row(state: &mut UiState, model: &mut EditModel) -> Action {
let Some(row) = state.current_row().cloned() else {
return Action::None;
};
match row.kind {
RowKind::UrlLine | RowKind::Title => {
let Some(tgt) = state.sections[state.sec].expand else {
return Action::None;
};
state.expanded = if state.expanded == Some(tgt) {
None
} else {
Some(tgt)
};
state.cell = None;
state.refresh(model);
Action::None
}
RowKind::Example => Action::OpenExample(row.cells[0].field.clone(), String::new()),
RowKind::Name | RowKind::Desc | RowKind::Field => {
if let Some(&first) = state.editable_cells().first() {
state.cell = Some(first);
return begin_cell_edit(state, model);
}
Action::None
}
}
}
fn begin_cell_edit(state: &mut UiState, model: &mut EditModel) -> Action {
let Some(c) = state.cell else {
return Action::None;
};
let Some(cell) = state.current_row().and_then(|r| r.cells.get(c)).cloned() else {
return Action::None;
};
match cell.kind {
CellKind::Text => {
state.mode = Mode::Insert(cell.value.clone());
Action::None
}
CellKind::Enum => {
match &cell.field {
Field::BodyDtype(loc) => toggle_body_type(state, model, loc),
_ => cycle_method(state, model, true),
}
Action::None
}
CellKind::Bool => {
toggle_bool(model, &cell.field);
state.dirty = true;
state.refresh(model);
Action::None
}
CellKind::Label => Action::None,
}
}
fn cycle_method(state: &mut UiState, model: &mut EditModel, forward: bool) {
let all = method_all();
let cur = method_str(&model.method);
let idx = all.iter().position(|m| method_str(m) == cur).unwrap_or(0);
let next = if forward {
(idx + 1) % all.len()
} else {
(idx + all.len() - 1) % all.len()
};
model.method = all[next].clone();
state.dirty = true;
state.refresh(model);
}
fn toggle_body_type(state: &mut UiState, model: &mut EditModel, loc: &BodyLoc) {
let cur = match loc {
BodyLoc::Request => model.request.as_ref().map(|b| b.dtype.clone()),
BodyLoc::Response(i) => model.responses.get(*i).map(|r| r.dtype.clone()),
};
let next = if cur.as_deref() == Some("object[]") {
"object"
} else {
"object[]"
};
set_field(model, &Field::BodyDtype(loc.clone()), next.to_string());
state.dirty = true;
state.refresh(model);
}
fn add_row(state: &mut UiState, model: &mut EditModel, field: &Field) {
match field {
Field::PathAdd => model.url.path.push(String::new()),
Field::QueryAdd => model.url.query.push(EditQuery {
name: String::new(),
value: String::new(),
description: String::new(),
required: false,
}),
Field::VarAdd => model.url.variable.push(EditVariable {
name: String::new(),
dtype: "string".to_string(),
description: String::new(),
required: false,
}),
Field::HeaderAdd => model.headers.push(EditHeader {
name: String::new(),
value: String::new(),
}),
Field::ResponseAdd => model.responses.push(EditResponse::blank()),
Field::RequestToggle => {
model.request = if model.request.is_some() {
None
} else {
Some(EditBody::empty())
};
}
Field::SchemaAdd(BodyLoc::Request, path) => {
if let Some(children) = model.schema_children_mut_request(path) {
children.push(EditSchema::blank());
}
}
Field::SchemaAdd(BodyLoc::Response(r), path) => {
if let Some(children) = model.schema_children_mut_response(*r, path) {
children.push(EditSchema::blank());
}
}
_ => return,
}
state.dirty = true;
state.cell = None;
state.refresh(model);
}
fn delete_row(state: &mut UiState, model: &mut EditModel, field: &Field) {
let mut changed = true;
match field {
Field::PathSeg(i) => drop_at(&mut model.url.path, *i),
Field::QueryName(i)
| Field::QueryValue(i)
| Field::QueryDesc(i)
| Field::QueryRequired(i) => drop_at(&mut model.url.query, *i),
Field::VarName(i) | Field::VarType(i) | Field::VarDesc(i) | Field::VarRequired(i) => {
drop_at(&mut model.url.variable, *i)
}
Field::HeaderName(i) | Field::HeaderValue(i) => drop_at(&mut model.headers, *i),
Field::ResponseCode(i) | Field::ResponseDesc(i) => drop_at(&mut model.responses, *i),
Field::SchemaName(loc, path)
| Field::SchemaType(loc, path)
| Field::SchemaDesc(loc, path)
| Field::SchemaRequired(loc, path)
| Field::SchemaAccept(loc, path) => {
if let Some((last, parent)) = path.split_last() {
let children = match loc {
BodyLoc::Request => model.schema_children_mut_request(parent),
BodyLoc::Response(r) => model.schema_children_mut_response(*r, parent),
};
if let Some(c) = children {
drop_at(c, *last);
}
}
}
_ => changed = false,
}
if changed {
state.dirty = true;
state.cell = None;
state.refresh(model);
}
}
fn drop_at<T>(v: &mut Vec<T>, i: usize) {
if i < v.len() {
v.remove(i);
}
}
pub(crate) fn handle_insert(state: &mut UiState, model: &mut EditModel, key: KeyEvent) -> Action {
let Mode::Insert(buf) = &mut state.mode else {
return Action::None;
};
match key.code {
KeyCode::Char(c) => {
buf.push(c);
Action::None
}
KeyCode::Backspace => {
buf.pop();
Action::None
}
KeyCode::Enter => {
let value = buf.clone();
let field = state.focused_field_pub();
if let Some(f) = &field {
set_field(model, f, value);
state.dirty = true;
}
state.mode = Mode::Normal;
if matches!(field, Some(Field::Name | Field::Description)) {
state.cell = None;
}
state.refresh(model);
Action::None
}
KeyCode::Tab | KeyCode::BackTab => {
let value = buf.clone();
let dir = if key.code == KeyCode::BackTab { -1 } else { 1 };
if let Some(field) = state.focused_field_pub() {
set_field(model, &field, value);
state.dirty = true;
}
state.mode = Mode::Normal;
state.refresh(model);
state.move_cell(dir);
if let Some(c) = state.cell
&& let Some(cell) = state.current_row().and_then(|r| r.cells.get(c))
&& cell.kind == CellKind::Text
{
state.mode = Mode::Insert(cell.value.clone());
}
Action::None
}
KeyCode::Esc => {
if matches!(
state.focused_field_pub(),
Some(Field::Name | Field::Description)
) {
state.cell = None;
}
state.mode = Mode::Normal;
Action::None
}
_ => Action::None,
}
}
fn toggle_bool(model: &mut EditModel, field: &Field) {
match field {
Field::QueryRequired(i) => {
if let Some(q) = model.url.query.get_mut(*i) {
q.required = !q.required;
}
}
Field::VarRequired(i) => {
if let Some(v) = model.url.variable.get_mut(*i) {
v.required = !v.required;
}
}
Field::SchemaRequired(BodyLoc::Request, path) => {
if let Some(n) = model.schema_at_mut_request(path) {
n.required = !n.required;
}
}
Field::SchemaRequired(BodyLoc::Response(r), path) => {
if let Some(n) = model.schema_at_mut_response(*r, path) {
n.required = !n.required;
}
}
_ => {}
}
}
fn set_field(model: &mut EditModel, field: &Field, value: String) {
match field {
Field::Name => model.name = value,
Field::Description => model.description = value,
Field::Protocol => model.url.protocol = value,
Field::Host => model.url.host = value,
Field::PathSeg(i) => {
if let Some(s) = model.url.path.get_mut(*i) {
*s = value;
}
}
Field::QueryName(i) => set_query(model, *i, |q| q.name = value.clone()),
Field::QueryValue(i) => set_query(model, *i, |q| q.value = value.clone()),
Field::QueryDesc(i) => set_query(model, *i, |q| q.description = value.clone()),
Field::VarName(i) => set_var(model, *i, |v| v.name = value.clone()),
Field::VarType(i) => set_var(model, *i, |v| v.dtype = value.clone()),
Field::VarDesc(i) => set_var(model, *i, |v| v.description = value.clone()),
Field::HeaderName(i) => {
if let Some(h) = model.headers.get_mut(*i) {
h.name = value;
}
}
Field::HeaderValue(i) => {
if let Some(h) = model.headers.get_mut(*i) {
h.value = value;
}
}
Field::BodyDtype(BodyLoc::Request) => {
if let Some(b) = model.request.as_mut() {
b.dtype = value;
}
}
Field::BodyDtype(BodyLoc::Response(r)) => {
if let Some(b) = model.responses.get_mut(*r) {
b.dtype = value;
}
}
Field::ResponseCode(i) => {
if let Some(r) = model.responses.get_mut(*i) {
r.code = value;
}
}
Field::ResponseDesc(i) => {
if let Some(r) = model.responses.get_mut(*i) {
r.description = value;
}
}
Field::SchemaName(loc, p) => set_schema(model, loc, p, |s| s.name = value.clone()),
Field::SchemaType(loc, p) => set_schema(model, loc, p, |s| s.dtype = value.clone()),
Field::SchemaDesc(loc, p) => set_schema(model, loc, p, |s| s.description = value.clone()),
Field::SchemaAccept(loc, p) => set_schema(model, loc, p, |s| s.accept = value.clone()),
_ => {}
}
}
fn set_query(model: &mut EditModel, i: usize, f: impl FnOnce(&mut crate::tui::model::EditQuery)) {
if let Some(q) = model.url.query.get_mut(i) {
f(q);
}
}
fn set_var(model: &mut EditModel, i: usize, f: impl FnOnce(&mut crate::tui::model::EditVariable)) {
if let Some(v) = model.url.variable.get_mut(i) {
f(v);
}
}
fn set_schema(
model: &mut EditModel,
loc: &BodyLoc,
path: &[usize],
f: impl FnOnce(&mut crate::tui::model::EditSchema),
) {
let node = match loc {
BodyLoc::Request => model.schema_at_mut_request(path),
BodyLoc::Response(r) => model.schema_at_mut_response(*r, path),
};
if let Some(n) = node {
f(n);
}
}
pub(crate) fn apply_save(state: &mut UiState, model: &EditModel, path: &Path) {
match model.save(path) {
Ok(()) => {
state.original = model.clone();
state.dirty = false;
state.status = format!("saved {}", path.display());
}
Err(err) => {
state.status = format!("save error: {err}");
}
}
}
pub(crate) fn handle_confirm_quit(state: &mut UiState, key: KeyEvent) -> Action {
match key.code {
KeyCode::Char('y') => Action::Save, KeyCode::Char('n') => Action::Quit,
KeyCode::Esc => {
state.mode = Mode::Normal;
state.status = "Ctrl-S save · q quit · ? help".into();
Action::None
}
_ => Action::None,
}
}
pub(crate) fn handle_confirm_delete(
state: &mut UiState,
model: &mut EditModel,
key: KeyEvent,
) -> Action {
match key.code {
KeyCode::Char('y') => {
if let Mode::ConfirmDelete(f) = state.mode.clone() {
delete_row(state, model, &f);
}
state.mode = Mode::Normal;
Action::None
}
KeyCode::Char('n') | KeyCode::Esc => {
state.mode = Mode::Normal;
Action::None
}
_ => Action::None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::json::json_get;
fn model() -> EditModel {
let c = json_get(
r#"{ "name":"t","description":"d","method":"GET",
"url":{"protocol":"https","host":"h","path":["x"],
"query":[{"name":"page","value":"1","description":"d","required":false}]},
"headers":[{"name":"A","value":"B"}],
"responses":[{"code":200,"description":"ok","schema":[]}] }"#,
None,
)
.unwrap();
EditModel::from_contract(c)
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn goto(s: &mut UiState, pred: impl Fn(&Field) -> bool) {
for (si, sec) in s.sections.iter().enumerate() {
for (ri, row) in sec.rows.iter().enumerate() {
if row.cells.iter().any(|c| pred(&c.field)) {
s.sec = si;
s.row = ri;
s.cell = None;
return;
}
}
}
panic!("no matching row");
}
#[test]
fn enter_on_url_expands_then_esc_collapses() {
let mut m = model();
let mut s = UiState::new(&m);
goto(&mut s, |f| matches!(f, Field::Method)); handle_normal(&mut s, &mut m, key(KeyCode::Enter));
assert_eq!(s.expanded, Some(Expand::Url));
handle_normal(&mut s, &mut m, key(KeyCode::Esc));
assert_eq!(s.expanded, None);
}
#[test]
fn enter_on_name_row_goes_straight_to_insert() {
let mut m = model();
let mut s = UiState::new(&m);
goto(&mut s, |f| matches!(f, Field::Name));
handle_normal(&mut s, &mut m, key(KeyCode::Enter)); assert!(s.cell.is_some(), "first cell is selected");
assert!(matches!(s.mode, Mode::Insert(_)), "no second Enter needed");
}
#[test]
fn name_commit_returns_to_row_focus() {
let mut m = model();
let mut s = UiState::new(&m);
goto(&mut s, |f| matches!(f, Field::Name));
handle_normal(&mut s, &mut m, key(KeyCode::Enter)); handle_insert(&mut s, &mut m, key(KeyCode::Char('x')));
handle_insert(&mut s, &mut m, key(KeyCode::Enter)); assert_eq!(m.name, "tx");
assert!(s.cell.is_none(), "Name drops back to row focus on commit");
}
#[test]
fn name_esc_returns_to_row_focus() {
let mut m = model();
let mut s = UiState::new(&m);
goto(&mut s, |f| matches!(f, Field::Name));
handle_normal(&mut s, &mut m, key(KeyCode::Enter)); handle_insert(&mut s, &mut m, key(KeyCode::Esc)); assert_eq!(s.mode, Mode::Normal);
assert!(s.cell.is_none(), "Name drops back to row focus on Esc too");
}
#[test]
fn field_row_esc_keeps_cell_focus() {
let mut m = model();
let mut s = UiState::new(&m);
goto(&mut s, |f| matches!(f, Field::QueryName(_)));
handle_normal(&mut s, &mut m, key(KeyCode::Enter)); handle_insert(&mut s, &mut m, key(KeyCode::Esc)); assert!(
s.cell.is_some(),
"multi-cell row falls back to cell focus on Esc"
);
}
#[test]
fn field_row_commit_keeps_cell_focus() {
let mut m = model();
let mut s = UiState::new(&m);
goto(&mut s, |f| matches!(f, Field::QueryName(_)));
handle_normal(&mut s, &mut m, key(KeyCode::Enter)); assert!(matches!(s.mode, Mode::Insert(_)));
handle_insert(&mut s, &mut m, key(KeyCode::Char('X')));
handle_insert(&mut s, &mut m, key(KeyCode::Enter)); assert!(
s.cell.is_some(),
"multi-cell row stays in cell focus after commit"
);
}
#[test]
fn i_enters_insert_on_text_cell() {
let mut m = model();
let mut s = UiState::new(&m);
goto(&mut s, |f| matches!(f, Field::QueryName(_)));
handle_normal(&mut s, &mut m, key(KeyCode::Enter)); handle_insert(&mut s, &mut m, key(KeyCode::Esc)); assert!(s.cell.is_some());
assert_eq!(s.mode, Mode::Normal);
handle_normal(&mut s, &mut m, key(KeyCode::Char('i'))); assert!(matches!(s.mode, Mode::Insert(_)));
}
#[test]
fn response_title_expands_and_code_is_editable() {
let mut m = model();
let mut s = UiState::new(&m);
let (si, ri) = s
.sections
.iter()
.enumerate()
.find_map(|(si, sec)| {
sec.rows
.iter()
.position(|r| r.kind == RowKind::Title)
.filter(|_| matches!(sec.expand, Some(Expand::Response(_))))
.map(|ri| (si, ri))
})
.unwrap();
s.sec = si;
s.row = ri;
s.cell = None;
handle_normal(&mut s, &mut m, key(KeyCode::Enter)); assert!(matches!(s.expanded, Some(Expand::Response(_))));
s.refresh(&m);
goto(&mut s, |f| matches!(f, Field::ResponseCode(_)));
handle_normal(&mut s, &mut m, key(KeyCode::Enter)); handle_insert(&mut s, &mut m, key(KeyCode::Backspace));
handle_insert(&mut s, &mut m, key(KeyCode::Char('1')));
handle_insert(&mut s, &mut m, key(KeyCode::Enter));
assert_eq!(m.responses[0].code, "201");
handle_normal(&mut s, &mut m, key(KeyCode::Esc));
handle_normal(&mut s, &mut m, key(KeyCode::Esc));
assert_eq!(s.expanded, None);
}
#[test]
fn a_appends_to_current_section() {
let mut m = model();
let mut s = UiState::new(&m);
goto(&mut s, |f| matches!(f, Field::HeaderName(_)));
handle_normal(&mut s, &mut m, key(KeyCode::Char('a')));
assert_eq!(m.headers.len(), 2);
}
#[test]
fn a_auto_enters_insert_on_new_name() {
let mut m = model();
let mut s = UiState::new(&m);
goto(&mut s, |f| matches!(f, Field::HeaderName(_)));
handle_normal(&mut s, &mut m, key(KeyCode::Char('a')));
assert_eq!(m.headers.len(), 2);
assert!(matches!(s.mode, Mode::Insert(_)));
assert!(matches!(s.focused_field_pub(), Some(Field::HeaderName(1))));
handle_insert(&mut s, &mut m, key(KeyCode::Char('X')));
handle_insert(&mut s, &mut m, key(KeyCode::Enter));
assert_eq!(m.headers[1].name, "X");
assert!(s.cell.is_some());
}
#[test]
fn d_deletes_focused_row() {
let mut m = model();
let mut s = UiState::new(&m);
goto(&mut s, |f| matches!(f, Field::QueryName(_)));
handle_normal(&mut s, &mut m, key(KeyCode::Char('d')));
handle_confirm_delete(&mut s, &mut m, key(KeyCode::Char('y')));
assert_eq!(m.url.query.len(), 0);
}
#[test]
fn delete_requires_confirmation() {
let mut m = model();
let mut s = UiState::new(&m);
goto(&mut s, |f| matches!(f, Field::HeaderName(_)));
handle_normal(&mut s, &mut m, key(KeyCode::Char('d')));
assert!(matches!(s.mode, Mode::ConfirmDelete(_)));
assert_eq!(m.headers.len(), 1);
handle_confirm_delete(&mut s, &mut m, key(KeyCode::Char('n')));
assert_eq!(s.mode, Mode::Normal);
assert_eq!(m.headers.len(), 1);
handle_normal(&mut s, &mut m, key(KeyCode::Char('d')));
handle_confirm_delete(&mut s, &mut m, key(KeyCode::Char('y')));
assert_eq!(m.headers.len(), 0);
}
#[test]
fn h_and_l_move_cells() {
let mut m = model();
let mut s = UiState::new(&m);
goto(&mut s, |f| matches!(f, Field::QueryName(_)));
handle_normal(&mut s, &mut m, key(KeyCode::Enter)); handle_insert(&mut s, &mut m, key(KeyCode::Esc)); let first = s.cell.unwrap();
handle_normal(&mut s, &mut m, key(KeyCode::Char('l')));
assert!(s.cell.unwrap() > first);
handle_normal(&mut s, &mut m, key(KeyCode::Char('h')));
assert_eq!(s.cell.unwrap(), first);
}
#[test]
fn edit_text_cell_commits() {
let mut m = model();
let mut s = UiState::new(&m);
goto(&mut s, |f| matches!(f, Field::Name));
handle_normal(&mut s, &mut m, key(KeyCode::Enter)); assert!(matches!(s.mode, Mode::Insert(_)));
handle_insert(&mut s, &mut m, key(KeyCode::Char('x')));
handle_insert(&mut s, &mut m, key(KeyCode::Enter));
assert_eq!(m.name, "tx");
}
#[test]
fn tab_commits_and_jumps_to_next_text_cell_in_insert() {
let c = json_get(
r#"{ "name":"t","method":"GET",
"url":{"protocol":"https","host":"h","path":["x"],
"query":[{"name":"page","value":"1","description":"d","required":false}]},
"headers":[],"responses":[] }"#,
None,
)
.unwrap();
let mut m = EditModel::from_contract(c);
let mut s = UiState::new(&m);
goto(&mut s, |f| matches!(f, Field::QueryName(_)));
handle_normal(&mut s, &mut m, key(KeyCode::Enter)); assert!(matches!(s.mode, Mode::Insert(_)));
handle_insert(&mut s, &mut m, key(KeyCode::Tab)); assert_eq!(m.url.query[0].name, "page"); assert!(matches!(s.focused_field_pub(), Some(Field::QueryValue(_))));
assert!(
matches!(s.mode, Mode::Insert(_)),
"stays in insert on the next text cell"
);
handle_insert(&mut s, &mut m, key(KeyCode::Char('2')));
handle_insert(&mut s, &mut m, key(KeyCode::Enter));
assert_eq!(m.url.query[0].value, "12");
}
#[test]
fn method_cycles_when_url_expanded() {
let mut m = model();
let mut s = UiState::new(&m);
s.expanded = Some(Expand::Url);
s.refresh(&m);
goto(&mut s, |f| matches!(f, Field::Method));
let mi = s
.current_row()
.unwrap()
.cells
.iter()
.position(|c| matches!(c.field, Field::Method))
.unwrap();
s.cell = Some(mi);
handle_normal(&mut s, &mut m, key(KeyCode::Enter));
assert_ne!(method_str(&m.method), "GET");
}
#[test]
fn quit_clean_and_dirty() {
let mut m = model();
let mut s = UiState::new(&m);
s.dirty = false;
assert_eq!(
handle_normal(&mut s, &mut m, key(KeyCode::Char('q'))),
Action::Quit
);
s.dirty = true;
assert_eq!(
handle_normal(&mut s, &mut m, key(KeyCode::Char('q'))),
Action::None
);
}
#[test]
fn save_clears_dirty() {
let dir = std::env::temp_dir().join("apic_tui_ri_save");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("c.json");
let m = model();
let mut s = UiState::new(&m);
s.dirty = true;
apply_save(&mut s, &m, &path);
assert!(!s.dirty);
assert!(path.exists());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_adds_child_under_object_field() {
let c = json_get(
r#"{ "name":"t","method":"POST",
"url":{"protocol":"https","host":"h","path":["x"]},"headers":[],
"request":{"type":"object","schema":[
{"name":"wrap","type":"object","default":null,"description":"d","required":true,
"properties":[{"name":"a","type":"string","default":null,"description":"d","required":false}]}
]},
"responses":[] }"#,
None,
).unwrap();
let mut m = EditModel::from_contract(c);
let mut s = UiState::new(&m);
goto(
&mut s,
|f| matches!(f, Field::SchemaName(BodyLoc::Request, p) if p == &vec![0]),
);
let before = m.request.as_ref().unwrap().schema[0].properties.len();
handle_normal(&mut s, &mut m, key(KeyCode::Char('a')));
assert_eq!(
m.request.as_ref().unwrap().schema[0].properties.len(),
before + 1
);
}
#[test]
fn g_generates_example_for_request_body() {
let c = json_get(
r#"{ "name":"t","method":"POST",
"url":{"protocol":"h","host":"h","path":["x"]},"headers":[],
"request":{"type":"object","schema":[
{"name":"status","type":"int","default":null,"description":"d","required":true}
]},
"responses":[] }"#,
None,
)
.unwrap();
let mut m = EditModel::from_contract(c);
let mut s = UiState::new(&m);
goto(&mut s, |f| {
matches!(f, Field::SchemaName(BodyLoc::Request, _))
});
s.cell = None;
handle_normal(&mut s, &mut m, key(KeyCode::Char('g')));
assert!(m.request.as_ref().unwrap().example.contains("\"status\""));
}
#[test]
fn g_wraps_array_body_example_in_an_array() {
let c = json_get(
r#"{ "name":"t","method":"POST",
"url":{"protocol":"h","host":"h","path":["x"]},"headers":[],
"request":{"type":"object[]","schema":[
{"name":"id","type":"int","default":null,"description":"d","required":true}
]},
"responses":[] }"#,
None,
)
.unwrap();
let mut m = EditModel::from_contract(c);
let mut s = UiState::new(&m);
goto(&mut s, |f| {
matches!(f, Field::SchemaName(BodyLoc::Request, _))
});
s.cell = None;
handle_normal(&mut s, &mut m, key(KeyCode::Char('g')));
let ex = m.request.as_ref().unwrap().example.clone();
let v: serde_json::Value = serde_json::from_str(&ex).unwrap();
assert!(
v.is_array(),
"object[] body should generate an array example"
);
assert_eq!(v[0]["id"], serde_json::json!(0));
}
#[test]
fn body_type_toggles_between_object_and_array() {
let c = json_get(
r#"{ "name":"t","method":"POST",
"url":{"protocol":"h","host":"h","path":["x"]},"headers":[],
"request":{"type":"object","schema":[]},
"responses":[] }"#,
None,
)
.unwrap();
let mut m = EditModel::from_contract(c);
let mut s = UiState::new(&m);
s.expanded = Some(Expand::Request);
s.refresh(&m);
goto(&mut s, |f| matches!(f, Field::BodyDtype(BodyLoc::Request)));
let ti = s
.current_row()
.unwrap()
.cells
.iter()
.position(|c| matches!(c.field, Field::BodyDtype(_)))
.unwrap();
s.cell = Some(ti);
handle_normal(&mut s, &mut m, key(KeyCode::Enter)); assert_eq!(m.request.as_ref().unwrap().dtype, "object[]");
handle_normal(&mut s, &mut m, key(KeyCode::Enter)); assert_eq!(m.request.as_ref().unwrap().dtype, "object");
}
#[test]
fn a_on_empty_query_title_adds_first_item() {
let c = json_get(
r#"{ "name":"t","method":"GET",
"url":{"protocol":"https","host":"h","path":["x"]},
"headers":[],"responses":[] }"#,
None,
)
.unwrap();
let mut m = EditModel::from_contract(c);
let mut s = UiState::new(&m);
let (si, ri) = s
.sections
.iter()
.enumerate()
.find_map(|(si, sec)| {
(sec.title == "QUERY").then(|| {
(
si,
sec.rows
.iter()
.position(|r| r.kind == RowKind::Title)
.unwrap(),
)
})
})
.unwrap();
s.sec = si;
s.row = ri;
s.cell = None;
handle_normal(&mut s, &mut m, key(KeyCode::Enter));
assert_eq!(m.url.query.len(), 0);
handle_normal(&mut s, &mut m, key(KeyCode::Char('a')));
assert_eq!(m.url.query.len(), 1);
}
#[test]
fn add_response_expands_and_focuses_code() {
let c = json_get(
r#"{ "name":"t","method":"GET",
"url":{"protocol":"https","host":"h","path":["x"]},"headers":[],
"responses":[{"code":200,"description":"ok","schema":[]}] }"#,
None,
)
.unwrap();
let mut m = EditModel::from_contract(c);
let mut s = UiState::new(&m);
let (si, ri) = s
.sections
.iter()
.enumerate()
.find_map(|(si, sec)| {
(sec.add == Some(Field::ResponseAdd)
&& sec
.rows
.iter()
.any(|r| r.cells.iter().any(|c| c.value == "+ add response")))
.then(|| {
(
si,
sec.rows
.iter()
.position(|r| r.cells.iter().any(|c| c.value == "+ add response"))
.unwrap(),
)
})
})
.unwrap();
s.sec = si;
s.row = ri;
s.cell = None;
handle_normal(&mut s, &mut m, key(KeyCode::Char('a')));
assert_eq!(m.responses.len(), 2);
assert!(matches!(s.mode, Mode::Insert(_)));
assert!(matches!(
s.focused_field_pub(),
Some(Field::ResponseCode(1))
));
handle_insert(&mut s, &mut m, key(KeyCode::Char('4')));
handle_insert(&mut s, &mut m, key(KeyCode::Char('2')));
handle_insert(&mut s, &mut m, key(KeyCode::Char('9')));
handle_insert(&mut s, &mut m, key(KeyCode::Enter));
assert_eq!(m.responses[1].code, "429");
}
#[test]
fn dirty_tracks_real_changes_against_baseline() {
let mut m = model(); let mut s = UiState::new(&m);
assert!(!s.dirty);
goto(&mut s, |f| matches!(f, Field::Name));
handle_normal(&mut s, &mut m, key(KeyCode::Enter)); handle_insert(&mut s, &mut m, key(KeyCode::Char('x')));
handle_insert(&mut s, &mut m, key(KeyCode::Enter));
assert!(s.dirty);
handle_normal(&mut s, &mut m, key(KeyCode::Enter)); handle_insert(&mut s, &mut m, key(KeyCode::Backspace));
handle_insert(&mut s, &mut m, key(KeyCode::Enter));
assert!(!s.dirty, "reverting to the original value clears dirty");
}
#[test]
fn save_updates_baseline() {
let dir = std::env::temp_dir().join("apic_tui_baseline");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("c.json");
let mut m = model();
let mut s = UiState::new(&m);
goto(&mut s, |f| matches!(f, Field::Name));
handle_normal(&mut s, &mut m, key(KeyCode::Enter));
handle_insert(&mut s, &mut m, key(KeyCode::Char('z')));
handle_insert(&mut s, &mut m, key(KeyCode::Enter));
assert!(s.dirty);
apply_save(&mut s, &m, &path);
assert!(!s.dirty);
s.refresh(&m);
assert!(!s.dirty);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_adds_sibling_for_non_object_field() {
let c = json_get(
r#"{ "name":"t","method":"POST",
"url":{"protocol":"https","host":"h","path":["x"]},"headers":[],
"request":{"type":"object","schema":[
{"name":"s","type":"string","default":null,"description":"d","required":false}
]},
"responses":[] }"#,
None,
)
.unwrap();
let mut m = EditModel::from_contract(c);
let mut s = UiState::new(&m);
goto(
&mut s,
|f| matches!(f, Field::SchemaName(BodyLoc::Request, p) if p == &vec![0]),
);
let before = m.request.as_ref().unwrap().schema.len();
handle_normal(&mut s, &mut m, key(KeyCode::Char('a')));
assert_eq!(m.request.as_ref().unwrap().schema.len(), before + 1); }
}