use crate::tui::model::{EditModel, EditSchema, EditUrl};
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum BodyLoc {
Request,
Response(usize),
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Field {
Name,
Description,
Method,
Protocol,
Host,
PathSeg(usize),
PathAdd,
QueryName(usize),
QueryValue(usize),
QueryDesc(usize),
QueryRequired(usize),
QueryAdd,
VarName(usize),
VarType(usize),
VarDesc(usize),
VarRequired(usize),
VarAdd,
HeaderName(usize),
HeaderValue(usize),
HeaderAdd,
RequestToggle,
BodyDtype(BodyLoc),
ResponseCode(usize),
ResponseDesc(usize),
BodyExample(BodyLoc),
SchemaName(BodyLoc, Vec<usize>),
SchemaType(BodyLoc, Vec<usize>),
SchemaDesc(BodyLoc, Vec<usize>),
SchemaRequired(BodyLoc, Vec<usize>),
SchemaAccept(BodyLoc, Vec<usize>),
SchemaAdd(BodyLoc, Vec<usize>),
ResponseAdd,
SectionHeader,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum CellKind {
Label, Text,
Enum,
Bool,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Cell {
pub field: Field,
pub kind: CellKind,
pub value: String,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum SectionKind {
Header,
Table,
Body,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Expand {
Url,
Request,
Response(usize),
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum RowKind {
Name, Desc, UrlLine, Title, Field, Example, }
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct TableRow {
pub kind: RowKind,
pub indent: u16,
pub cells: Vec<Cell>,
pub raw: String, pub prefix: String, }
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Section {
pub title: String,
pub kind: SectionKind,
pub headers: Option<Vec<&'static str>>,
pub rows: Vec<TableRow>,
pub add: Option<Field>,
pub expand: Option<Expand>,
}
fn label(text: &str) -> Cell {
Cell {
field: Field::SectionHeader,
kind: CellKind::Label,
value: text.to_string(),
}
}
fn text(field: Field, value: String) -> Cell {
Cell {
field,
kind: CellKind::Text,
value,
}
}
fn enum_cell(field: Field, value: String) -> Cell {
Cell {
field,
kind: CellKind::Enum,
value,
}
}
fn bool_cell(field: Field, v: bool) -> Cell {
Cell {
field,
kind: CellKind::Bool,
value: if v { "✓".to_string() } else { String::new() },
}
}
fn field_row(cells: Vec<Cell>) -> TableRow {
TableRow {
kind: RowKind::Field,
indent: 0,
cells,
raw: String::new(),
prefix: String::new(),
}
}
fn field_row_i_prefixed(indent: u16, cells: Vec<Cell>, prefix: String) -> TableRow {
TableRow {
kind: RowKind::Field,
indent,
cells,
raw: String::new(),
prefix,
}
}
fn title_row(title: String) -> TableRow {
TableRow {
kind: RowKind::Title,
indent: 0,
cells: vec![Cell {
field: Field::SectionHeader,
kind: CellKind::Label,
value: title,
}],
raw: String::new(),
prefix: String::new(),
}
}
fn example_row(loc: BodyLoc, raw: String) -> TableRow {
TableRow {
kind: RowKind::Example,
indent: 0,
cells: vec![Cell {
field: Field::BodyExample(loc),
kind: CellKind::Label,
value: String::new(),
}],
raw,
prefix: String::new(),
}
}
fn built_url(u: &EditUrl) -> String {
let path = u.path.join("/");
let authority = if u.host.is_empty() {
String::new()
} else if u.protocol.is_empty() {
u.host.clone()
} else {
format!("{}://{}", u.protocol, u.host)
};
match (authority.is_empty(), path.is_empty()) {
(true, _) => format!("/{path}"),
(false, true) => authority,
(false, false) => format!("{}/{path}", authority.trim_end_matches('/')),
}
}
fn any_accept(fields: &[EditSchema]) -> bool {
fields
.iter()
.any(|f| !f.accept.trim().is_empty() || any_accept(&f.properties))
}
fn schema_columns(has_accept: bool) -> Vec<&'static str> {
if has_accept {
vec!["NAME", "TYPE", "REQ", "ACCEPT", "DESCRIPTION"]
} else {
vec!["NAME", "TYPE", "REQ", "DESCRIPTION"]
}
}
fn push_schema(
rows: &mut Vec<TableRow>,
loc: &BodyLoc,
fields: &[EditSchema],
path: &mut Vec<usize>,
depth: usize,
has_accept: bool,
) {
for (i, f) in fields.iter().enumerate() {
path.push(i);
let prefix = if depth == 0 {
String::new()
} else {
let branch = if i + 1 == fields.len() {
"└─ "
} else {
"├─ "
};
format!("{}{branch}", " ".repeat(depth - 1))
};
let mut cells = vec![
text(Field::SchemaName(loc.clone(), path.clone()), f.name.clone()),
text(
Field::SchemaType(loc.clone(), path.clone()),
f.dtype.clone(),
),
bool_cell(Field::SchemaRequired(loc.clone(), path.clone()), f.required),
];
if has_accept {
cells.push(text(
Field::SchemaAccept(loc.clone(), path.clone()),
f.accept.clone(),
));
}
cells.push(text(
Field::SchemaDesc(loc.clone(), path.clone()),
f.description.clone(),
));
rows.push(field_row_i_prefixed(depth as u16, cells, prefix));
push_schema(rows, loc, &f.properties, path, depth + 1, has_accept);
path.pop();
}
}
fn body_rows(
lead: Vec<TableRow>,
loc: BodyLoc,
fields: &[EditSchema],
example: &str,
) -> Vec<TableRow> {
let has_accept = any_accept(fields);
let mut rows = lead;
let mut path = Vec::new();
push_schema(&mut rows, &loc, fields, &mut path, 0, has_accept);
rows.push(example_row(loc, example.to_string()));
rows
}
pub(crate) fn flatten(m: &EditModel, expanded: Option<Expand>) -> Vec<Section> {
let mut out = Vec::new();
let method_s = crate::json::method_str(&m.method);
let url_expanded = expanded == Some(Expand::Url);
let mut head_rows = vec![
TableRow {
kind: RowKind::Name,
indent: 0,
cells: vec![text(Field::Name, m.name.clone())],
raw: String::new(),
prefix: String::new(),
},
TableRow {
kind: RowKind::Desc,
indent: 0,
cells: vec![text(Field::Description, m.description.clone())],
raw: String::new(),
prefix: String::new(),
},
];
let mut head_add = None;
if url_expanded {
head_rows.push(field_row(vec![
label("method"),
enum_cell(Field::Method, method_s.clone()),
]));
head_rows.push(field_row(vec![
label("protocol"),
text(Field::Protocol, m.url.protocol.clone()),
]));
head_rows.push(field_row(vec![
label("host"),
text(Field::Host, m.url.host.clone()),
]));
for (i, seg) in m.url.path.iter().enumerate() {
head_rows.push(field_row(vec![
label("path"),
text(Field::PathSeg(i), seg.clone()),
]));
}
head_add = Some(Field::PathAdd);
} else {
head_rows.push(TableRow {
kind: RowKind::UrlLine,
indent: 0,
cells: vec![
enum_cell(Field::Method, method_s),
Cell {
field: Field::Protocol,
kind: CellKind::Label,
value: built_url(&m.url),
},
],
raw: String::new(),
prefix: String::new(),
});
}
out.push(Section {
title: String::new(),
kind: SectionKind::Header,
headers: None,
rows: head_rows,
add: head_add,
expand: Some(Expand::Url),
});
let mut v_rows = vec![title_row("VARIABLE".to_string())];
for (i, v) in m.url.variable.iter().enumerate() {
v_rows.push(field_row(vec![
text(Field::VarName(i), v.name.clone()),
text(Field::VarType(i), v.dtype.clone()),
bool_cell(Field::VarRequired(i), v.required),
text(Field::VarDesc(i), v.description.clone()),
]));
}
out.push(Section {
title: "VARIABLE".into(),
kind: SectionKind::Table,
headers: Some(vec!["NAME", "TYPE", "REQ", "DESCRIPTION"]),
rows: v_rows,
add: Some(Field::VarAdd),
expand: None,
});
let mut q_rows = vec![title_row("QUERY".to_string())];
for (i, q) in m.url.query.iter().enumerate() {
q_rows.push(field_row(vec![
text(Field::QueryName(i), q.name.clone()),
text(Field::QueryValue(i), q.value.clone()),
bool_cell(Field::QueryRequired(i), q.required),
text(Field::QueryDesc(i), q.description.clone()),
]));
}
out.push(Section {
title: "QUERY".into(),
kind: SectionKind::Table,
headers: Some(vec!["NAME", "VALUE", "REQ", "DESCRIPTION"]),
rows: q_rows,
add: Some(Field::QueryAdd),
expand: None,
});
let mut h_rows = vec![title_row("HEADERS".to_string())];
for (i, h) in m.headers.iter().enumerate() {
h_rows.push(field_row(vec![
text(Field::HeaderName(i), h.name.clone()),
text(Field::HeaderValue(i), h.value.clone()),
]));
}
out.push(Section {
title: "HEADERS".into(),
kind: SectionKind::Table,
headers: None,
rows: h_rows,
add: Some(Field::HeaderAdd),
expand: None,
});
match &m.request {
Some(req) => {
let title = format!("REQUEST{}", crate::render::array_marker(&req.dtype));
let mut lead = vec![title_row(title)];
if expanded == Some(Expand::Request) {
lead.push(field_row(vec![
label("type"),
enum_cell(Field::BodyDtype(BodyLoc::Request), req.dtype.clone()),
]));
}
out.push(Section {
title: String::new(),
kind: SectionKind::Body,
headers: Some(schema_columns(any_accept(&req.schema))),
rows: body_rows(lead, BodyLoc::Request, &req.schema, &req.example),
add: Some(Field::SchemaAdd(BodyLoc::Request, Vec::new())),
expand: Some(Expand::Request),
});
}
None => out.push(Section {
title: "REQUEST".to_string(),
kind: SectionKind::Body,
headers: None,
rows: vec![title_row("REQUEST".to_string())],
add: Some(Field::RequestToggle),
expand: Some(Expand::Request),
}),
}
if m.responses.is_empty() {
out.push(Section {
title: "RESPONSE".into(),
kind: SectionKind::Table,
headers: None,
rows: vec![title_row("RESPONSE".to_string())],
add: Some(Field::ResponseAdd),
expand: None,
});
} else {
for (i, r) in m.responses.iter().enumerate() {
let marker = crate::render::array_marker(&r.dtype);
let title = format!("RESPONSE {} — {}{marker}", r.code, r.description);
let mut lead = vec![title_row(title)];
if expanded == Some(Expand::Response(i)) {
lead.push(field_row(vec![
label("code"),
text(Field::ResponseCode(i), r.code.clone()),
]));
lead.push(field_row(vec![
label("description"),
text(Field::ResponseDesc(i), r.description.clone()),
]));
lead.push(field_row(vec![
label("type"),
enum_cell(Field::BodyDtype(BodyLoc::Response(i)), r.dtype.clone()),
]));
}
out.push(Section {
title: String::new(),
kind: SectionKind::Body,
headers: Some(schema_columns(any_accept(&r.schema))),
rows: body_rows(lead, BodyLoc::Response(i), &r.schema, &r.example),
add: Some(Field::SchemaAdd(BodyLoc::Response(i), Vec::new())),
expand: Some(Expand::Response(i)),
});
}
}
out.push(Section {
title: String::new(),
kind: SectionKind::Table,
headers: None,
rows: vec![field_row(vec![label("+ add response")])],
add: Some(Field::ResponseAdd),
expand: None,
});
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::json::json_get;
fn model() -> EditModel {
let c = json_get(
r#"{ "name":"user","description":"User management","method":"GET",
"url":{"protocol":"https","host":"api.example.com","path":["user"],
"variable":[{"name":"id","type":"int","description":"User ID","required":false}]},
"headers":[{"name":"Content-Type","value":"application/json"}],
"responses":[{"code":200,"description":"ok","schema":[
{"name":"data","type":"object","default":null,"description":"d","required":false,
"properties":[{"name":"id","type":"int","default":null,"description":"d","required":true}]}
],"example":{"status":200}}] }"#,
None,
)
.unwrap();
EditModel::from_contract(c)
}
fn shown_title(s: &Section) -> String {
if let Some(row) = s.rows.iter().find(|r| r.kind == RowKind::Title) {
row.cells[0].value.clone()
} else {
s.title.clone()
}
}
#[test]
fn header_block_has_name_desc_and_collapsed_url() {
let secs = flatten(&model(), None);
let head = &secs[0];
assert_eq!(head.kind, SectionKind::Header);
assert!(head.rows.iter().any(|r| r.kind == RowKind::Name));
assert!(head.rows.iter().any(|r| r.kind == RowKind::Desc));
let url = head
.rows
.iter()
.find(|r| r.kind == RowKind::UrlLine)
.unwrap();
assert!(
url.cells
.iter()
.any(|c| c.value.contains("https://api.example.com/user"))
);
}
#[test]
fn url_expands_to_editable_parts() {
let secs = flatten(&model(), Some(Expand::Url));
let head = &secs[0];
assert!(head.rows.iter().all(|r| r.kind != RowKind::UrlLine));
assert!(
head.rows
.iter()
.any(|r| matches!(r.cells.last().map(|c| &c.field), Some(Field::Protocol)))
);
assert!(
head.rows
.iter()
.any(|r| matches!(r.cells.last().map(|c| &c.field), Some(Field::Host)))
);
assert_eq!(head.add, Some(Field::PathAdd));
}
#[test]
fn section_titles_match_read() {
let secs = flatten(&model(), None);
let titles: Vec<String> = secs.iter().map(shown_title).collect();
assert!(titles.iter().any(|t| t == "VARIABLE"));
assert!(titles.iter().any(|t| t == "QUERY"));
assert!(titles.iter().any(|t| t == "HEADERS"));
assert!(titles.iter().any(|t| t == "REQUEST"));
assert!(titles.iter().any(|t| t.starts_with("RESPONSE 200 — ok")));
}
#[test]
fn response_title_expands_to_editable_code_desc_type() {
let secs = flatten(&model(), Some(Expand::Response(0)));
let resp = secs
.iter()
.find(|s| s.expand == Some(Expand::Response(0)))
.unwrap();
assert!(resp.rows.iter().any(|r| matches!(
r.cells.last().map(|c| &c.field),
Some(Field::ResponseCode(0))
)));
assert!(resp.rows.iter().any(|r| matches!(
r.cells.last().map(|c| &c.field),
Some(Field::ResponseDesc(0))
)));
assert!(resp.rows.iter().any(|r| matches!(
r.cells.last().map(|c| &c.field),
Some(Field::BodyDtype(BodyLoc::Response(0)))
)));
}
#[test]
fn add_targets_are_set() {
let secs = flatten(&model(), None);
let q = secs.iter().find(|s| s.title == "QUERY").unwrap();
assert_eq!(q.add, Some(Field::QueryAdd));
let h = secs.iter().find(|s| s.title == "HEADERS").unwrap();
assert_eq!(h.add, Some(Field::HeaderAdd));
assert!(h.headers.is_none()); }
#[test]
fn nested_field_has_tree_prefix_and_response_has_example_row() {
let secs = flatten(&model(), None);
let resp = secs
.iter()
.find(|s| s.expand == Some(Expand::Response(0)))
.unwrap();
assert!(resp.rows.iter().any(|r| {
r.kind == RowKind::Field && (r.prefix.contains("├─") || r.prefix.contains("└─"))
}));
assert!(
resp.rows
.iter()
.any(|r| r.kind == RowKind::Example && r.raw.contains("status"))
);
}
#[test]
fn table_sections_have_selectable_title_rows() {
let secs = flatten(&model(), None);
for t in ["VARIABLE", "QUERY", "HEADERS"] {
let s = secs.iter().find(|s| s.title == t).unwrap();
assert!(
s.rows
.first()
.map(|r| r.kind == RowKind::Title)
.unwrap_or(false),
"{t} should start with a Title row"
);
}
}
#[test]
fn trailing_add_response_affordance_present() {
let secs = flatten(&model(), None);
let aff = secs
.iter()
.find(|s| {
s.add == Some(Field::ResponseAdd)
&& s.rows.iter().any(|r| {
r.cells
.first()
.map(|c| c.value == "+ add response")
.unwrap_or(false)
})
})
.expect("a '+ add response' affordance section exists");
assert_eq!(aff.add, Some(Field::ResponseAdd));
}
#[test]
fn nested_name_cell_is_bare_with_prefix_on_row() {
let secs = flatten(&model(), None);
let resp = secs
.iter()
.find(|s| s.title.starts_with("RESPONSE 200") || s.expand == Some(Expand::Response(0)))
.unwrap();
let nested = resp
.rows
.iter()
.find(|r| matches!(&r.cells.first().map(|c| &c.field), Some(Field::SchemaName(BodyLoc::Response(_), p)) if p.len()==2))
.unwrap();
assert!(!nested.cells[0].value.contains('├') && !nested.cells[0].value.contains('└'));
assert!(nested.prefix.contains('└') || nested.prefix.contains('├'));
}
}