use crate::json::{JsonContent, Method, Schema, Url, Variable, any_accept, method_str, parse_type};
use crossterm::style::Stylize;
use std::io::IsTerminal;
pub fn render(contract: &JsonContent, example_mode: bool) {
let p = Printer::new(example_mode);
p.contract(contract);
}
struct Printer {
color: bool,
example_mode: bool,
}
impl Printer {
fn new(example_mode: bool) -> Self {
Self {
color: std::io::stdout().is_terminal(),
example_mode,
}
}
fn contract(&self, c: &JsonContent) {
println!(" {}", sanitize(&c.name).to_uppercase());
if let Some(desc) = &c.description {
println!(" {}", sanitize(desc));
}
println!(
"\n {} {}",
self.method(&c.method),
sanitize(&build_url(&c.url)),
);
self.section("VARIABLE");
match c.url.variable.as_deref() {
Some(variable) if !variable.is_empty() => {
let (headers, rows) = variable_rows(variable);
self.table(Some(&headers), &rows);
}
_ => self.none(),
}
self.section("QUERY");
match c.url.query.as_deref() {
Some(query) if !query.is_empty() => {
let rows: Vec<Vec<String>> = query
.iter()
.map(|q| {
vec![
q.name.clone(),
q.value.clone(),
req_mark(q.required),
q.description.clone().unwrap_or_default(),
]
})
.collect();
self.table(Some(&["NAME", "VALUE", "REQ", "DESCRIPTION"]), &rows);
}
_ => self.none(),
}
self.section("HEADERS");
if c.headers.is_empty() {
self.none();
} else {
let rows: Vec<Vec<String>> = c
.headers
.iter()
.map(|h| vec![h.name.clone(), h.value.clone()])
.collect();
self.table(None, &rows);
}
let request_label = match &c.request {
Some(r) => format!("REQUEST{}", array_marker(&r.dtype)),
None => "REQUEST".to_string(),
};
self.section(&request_label);
match &c.request {
Some(request) if self.example_mode => self.example(request.example.as_ref()),
Some(request) => match &request.schema {
Some(schema) if !schema.is_empty() => {
let (headers, rows) = field_rows(schema);
self.table(Some(&headers), &rows);
if let Some(example) = &request.example {
self.example_block(example);
}
}
_ => match &request.example {
Some(example) => self.example(Some(example)),
None => self.none(),
},
},
None => self.none(),
}
if c.responses.is_empty() {
self.section("RESPONSE");
self.none();
} else {
for response in &c.responses {
self.response_title(response.code, &response.description, &response.dtype);
if self.example_mode {
self.example(response.example.as_ref());
} else if !response.schema.is_empty() {
let (headers, rows) = field_rows(&response.schema);
self.table(Some(&headers), &rows);
if let Some(example) = &response.example {
self.example_block(example);
}
} else {
self.example(response.example.as_ref());
}
}
}
}
fn example_block(&self, example: &serde_json::Value) {
println!();
if self.color {
println!(" {}", "Example:".dark_grey());
} else {
println!(" Example:");
}
self.example(Some(example));
}
fn example(&self, example: Option<&serde_json::Value>) {
match example {
Some(value) => {
let pretty = serde_json::to_string_pretty(value)
.unwrap_or_else(|_| "(unrenderable example)".to_string());
for line in pretty.lines() {
println!(" {line}");
}
}
None => println!(" (no example provided)"),
}
}
fn none(&self) {
if self.color {
println!(" {}", "(none)".dark_grey());
} else {
println!(" (none)");
}
}
fn section(&self, title: &str) {
println!();
if self.color {
println!(" {}", title.bold());
} else {
println!(" {title}");
}
}
fn response_title(&self, code: u16, description: &str, dtype: &str) {
println!();
let description = sanitize(description);
let marker = array_marker(dtype);
if self.color {
let code = code.to_string();
let code = match code.as_bytes()[0] {
b'2' => code.green().bold(),
b'4' | b'5' => code.red().bold(),
_ => code.yellow().bold(),
};
println!(" {} {code} — {description}{marker}", "RESPONSE".bold());
} else {
println!(" RESPONSE {code} — {description}{marker}");
}
}
fn method(&self, method: &Method) -> String {
if !self.color {
return method_str(method);
}
let method_str = method_str(method);
match method {
Method::GET => method_str.green().bold().to_string(),
Method::POST => method_str.blue().bold().to_string(),
Method::PUT => method_str.yellow().bold().to_string(),
Method::PATCH => method_str.magenta().bold().to_string(),
Method::DELETE => method_str.red().bold().to_string(),
Method::HEAD => method_str.cyan().bold().to_string(),
Method::OPTIONS => method_str.white().bold().to_string(),
}
}
fn table(&self, headers: Option<&[&str]>, rows: &[Vec<String>]) {
let cols = match (headers, rows.first()) {
(Some(h), _) => h.len(),
(None, Some(r)) => r.len(),
(None, None) => return,
};
let rows: Vec<Vec<String>> = rows
.iter()
.map(|row| row.iter().map(|cell| sanitize(cell)).collect())
.collect();
let rows = &rows;
let mut widths = vec![0usize; cols];
if let Some(headers) = headers {
for (w, h) in widths.iter_mut().zip(headers) {
*w = h.chars().count();
}
}
for row in rows {
for (w, cell) in widths.iter_mut().zip(row) {
*w = (*w).max(cell.chars().count());
}
}
let fmt_line = |cells: &[String]| -> String {
cells
.iter()
.zip(&widths)
.map(|(cell, w)| format!("{cell:<w$}"))
.collect::<Vec<_>>()
.join(" ")
.trim_end()
.to_string()
};
if let Some(headers) = headers {
let cells: Vec<String> = headers.iter().map(|h| h.to_string()).collect();
let line = fmt_line(&cells);
if self.color {
println!(" {}", line.dark_grey());
} else {
println!(" {line}");
}
}
for row in rows {
println!(" {}", fmt_line(row));
}
}
}
fn variable_rows(variables: &[Variable]) -> (Vec<&'static str>, Vec<Vec<String>>) {
let headers = vec!["NAME", "TYPE", "REQ", "DESCRIPTION"];
let rows = variables
.iter()
.map(|v| {
vec![
v.name.clone(),
v.dtype.clone(),
req_mark(v.required),
v.description.clone().unwrap_or_default(),
]
})
.collect();
(headers, rows)
}
pub(crate) fn array_marker(dtype: &str) -> String {
if parse_type(dtype).1 {
format!(" · {}", sanitize(dtype))
} else {
String::new()
}
}
fn field_rows(fields: &[Schema]) -> (Vec<&'static str>, Vec<Vec<String>>) {
let has_accept = any_accept(fields);
let headers = if has_accept {
vec!["NAME", "TYPE", "REQ", "ACCEPT", "DESCRIPTION"]
} else {
vec!["NAME", "TYPE", "REQ", "DESCRIPTION"]
};
let mut rows = Vec::new();
push_field_rows(fields, 0, has_accept, &mut rows);
(headers, rows)
}
fn push_field_rows(fields: &[Schema], depth: usize, has_accept: bool, out: &mut Vec<Vec<String>>) {
for (i, s) in fields.iter().enumerate() {
let prefix = if depth == 0 {
String::new()
} else {
let branch = if i + 1 == fields.len() {
"└─ "
} else {
"├─ "
};
format!("{}{branch}", " ".repeat(depth - 1))
};
let mut row = vec![
format!("{prefix}{}", s.name),
s.dtype.clone(),
req_mark(s.required),
];
if has_accept {
row.push(s.accept.clone().unwrap_or_default());
}
row.push(s.description.clone());
out.push(row);
if let Some(props) = &s.properties {
push_field_rows(props, depth + 1, has_accept, out);
}
}
}
fn req_mark(required: bool) -> String {
if required {
"✓".to_string()
} else {
String::new()
}
}
pub(crate) fn build_url(url: &Url) -> String {
let path = url.path.as_deref().unwrap_or(&[]).join("/");
let authority = if url.host.is_empty() {
String::new()
} else if url.protocol.is_empty() {
url.host.clone()
} else {
format!("{}://{}", url.protocol, url.host)
};
match (authority.is_empty(), path.is_empty()) {
(true, _) => format!("/{path}"),
(false, true) => authority,
(false, false) => format!("{}/{path}", authority.trim_end_matches('/')),
}
}
pub(crate) fn sanitize(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::json::Schema;
#[test]
fn sanitize_strips_escape_and_bell_sequences() {
let evil = "\x1b[2J\x1b[31mHACKED\x1b[0m\x07";
let clean = sanitize(evil);
assert!(!clean.contains('\x1b'), "ESC survived: {clean:?}");
assert!(!clean.contains('\x07'), "BEL survived: {clean:?}");
assert!(clean.contains("HACKED"));
}
#[test]
fn sanitize_keeps_normal_and_multibyte_text() {
assert_eq!(sanitize("café /auth/login"), "café /auth/login");
}
fn url(protocol: &str, host: &str, path: Option<&[&str]>) -> Url {
Url {
protocol: protocol.to_string(),
host: host.to_string(),
path: path.map(|segs| segs.iter().map(|s| s.to_string()).collect()),
query: None,
variable: None,
}
}
#[test]
fn build_url_joins_protocol_host_and_path() {
let u = url("https", "api.example.com", Some(&["auth", "login"]));
assert_eq!(build_url(&u), "https://api.example.com/auth/login");
}
#[test]
fn build_url_drops_scheme_when_protocol_empty() {
let u = url("", "api.example.com", Some(&["user"]));
assert_eq!(build_url(&u), "api.example.com/user");
}
#[test]
fn build_url_falls_back_to_leading_slash_path_without_host() {
let u = url("https", "", Some(&["auth", "login"]));
assert_eq!(build_url(&u), "/auth/login");
}
#[test]
fn build_url_renders_authority_alone_without_path() {
let u = url("https", "api.example.com", None);
assert_eq!(build_url(&u), "https://api.example.com");
}
#[test]
fn req_mark_renders_check_only_when_required() {
assert_eq!(req_mark(true), "✓");
assert_eq!(req_mark(false), "");
}
#[test]
fn array_marker_only_decorates_array_bodies() {
assert_eq!(array_marker("object[]"), " · object[]");
assert_eq!(array_marker("string[]"), " · string[]");
assert_eq!(array_marker("object"), "");
assert_eq!(array_marker("string"), "");
}
fn field(name: &str, properties: Option<Vec<Schema>>) -> Schema {
Schema {
name: name.to_string(),
dtype: "string".to_string(),
default: None,
description: String::new(),
required: true,
properties,
accept: None,
}
}
#[test]
fn variable_rows_includes_req_column_marking_only_required() {
let variables = vec![
Variable {
name: "id".to_string(),
dtype: "int".to_string(),
description: Some("User ID".to_string()),
required: true,
},
Variable {
name: "slug".to_string(),
dtype: "string".to_string(),
description: None,
required: false,
},
];
let (headers, rows) = variable_rows(&variables);
assert_eq!(headers, vec!["NAME", "TYPE", "REQ", "DESCRIPTION"]);
assert_eq!(rows[0].len(), 4);
assert_eq!(rows[0][2], req_mark(true));
assert_eq!(rows[1][2], "");
}
#[test]
fn schema_rows_flattens_nested_properties_with_tree_prefixes() {
let schema = vec![field(
"data",
Some(vec![field("first", None), field("last", None)]),
)];
let (_headers, rows) = field_rows(&schema);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0][0], "data");
assert_eq!(rows[1][0], "├─ first");
assert_eq!(rows[2][0], "└─ last");
}
#[test]
fn field_rows_recurses_properties_and_adds_accept_column() {
let nested = Schema {
name: "user".to_string(),
dtype: "object[]".to_string(),
default: None,
description: "list".to_string(),
required: true,
properties: Some(vec![Schema {
name: "id".to_string(),
dtype: "string".to_string(),
default: None,
description: "uid".to_string(),
required: true,
properties: None,
accept: None,
}]),
accept: None,
};
let avatar = Schema {
name: "avatar".to_string(),
dtype: "file".to_string(),
default: None,
description: "img".to_string(),
required: true,
properties: None,
accept: Some("image/png".to_string()),
};
let (headers, rows) = field_rows(&[nested, avatar]);
assert_eq!(
headers,
vec!["NAME", "TYPE", "REQ", "ACCEPT", "DESCRIPTION"]
);
assert_eq!(rows.len(), 3);
assert_eq!(rows[1][0], "└─ id");
assert_eq!(rows[0][3], "");
assert_eq!(rows[2][3], "image/png");
}
}