use super::{BodyShape, ScaffoldRequest, Todo, TodoCategory};
pub fn render(request: &ScaffoldRequest, todos: &mut Vec<Todo>) -> String {
let mut out = String::new();
let mut pending: Vec<Todo> = Vec::new();
line(
&mut out,
"# yaml-language-server: $schema=https://raw.githubusercontent.com/NazarKalytiuk/hive/main/schemas/v1/testfile.json",
);
line(&mut out, "# Generated by tarn scaffold (NAZ-411).");
line(
&mut out,
"# Scaffold-quality output: intentionally incomplete — review every",
);
line(
&mut out,
"# line and every TODO before running against a real API.",
);
line(
&mut out,
&format!("name: {}", yaml_scalar(&request.file_name)),
);
pending.push(Todo::new(
TodoCategory::Env,
"confirm env.base_url is set in tarn.env.yaml (or pass --var base_url=...)",
));
line(&mut out, "steps:");
flush_todos(&mut out, &mut pending, todos, "");
line(
&mut out,
&format!(" - name: {}", yaml_scalar(&request.step_name)),
);
pending.push(Todo::new(
TodoCategory::Method,
format!(
"confirm method is `{}` (generated from the provided input)",
request.method
),
));
flush_todos(&mut out, &mut pending, todos, " ");
line(&mut out, " request:");
line(&mut out, &format!(" method: {}", request.method));
let mut params = request.path_params.clone();
params.sort();
params.dedup();
for param in ¶ms {
pending.push(Todo::new(
TodoCategory::PathParam,
format!(
"supply a real value for path parameter `{}` via `--var {}=...` or inline env",
param, param
),
));
}
if request.url.trim().is_empty() {
pending.push(Todo::new(TodoCategory::Url, "set the request URL"));
}
flush_todos(&mut out, &mut pending, todos, " ");
line(
&mut out,
&format!(" url: {}", yaml_scalar(&request.url)),
);
if !request.headers.is_empty() {
pending.push(Todo::new(
TodoCategory::Headers,
"confirm headers — move secret values (tokens, keys) to env",
));
flush_todos(&mut out, &mut pending, todos, " ");
line(&mut out, " headers:");
for (name, value) in &request.headers {
if is_sensitive(name, &request.sensitive_headers) {
pending.push(Todo::new(
TodoCategory::Auth,
format!(
"`{}` looks sensitive — replace the literal with `{{{{ env.<VAR> }}}}`",
name
),
));
flush_todos(&mut out, &mut pending, todos, " ");
}
line(
&mut out,
&format!(" {}: {}", yaml_key(name), yaml_scalar(value)),
);
}
}
if let Some(body) = &request.body {
match body {
BodyShape::Json(value) => {
pending.push(Todo::new(
TodoCategory::Body,
"fill in or tighten required body fields",
));
flush_todos(&mut out, &mut pending, todos, " ");
line(&mut out, " body:");
render_json_body(&mut out, value, " ");
}
BodyShape::Raw(text) => {
pending.push(Todo::new(
TodoCategory::Body,
"body was not JSON — emitted as a raw string; confirm Content-Type and shape",
));
flush_todos(&mut out, &mut pending, todos, " ");
line(
&mut out,
&format!(" body: {}", yaml_quoted_string(text)),
);
}
}
}
pending.push(Todo::new(
TodoCategory::Assertion,
"tighten assertions — placeholder asserts only status range + body shape",
));
flush_todos(&mut out, &mut pending, todos, " ");
line(&mut out, " assert:");
let status = request.status_assertion.as_deref().unwrap_or("2xx");
line(&mut out, &format!(" status: {}", yaml_scalar(status)));
line(&mut out, " body:");
line(&mut out, " \"$\":");
line(&mut out, " type: object");
if !request.captures.is_empty() {
pending.push(Todo::new(
TodoCategory::Capture,
"verify captured JSONPaths resolve against the real response",
));
flush_todos(&mut out, &mut pending, todos, " ");
line(&mut out, " capture:");
for (name, path) in &request.captures {
line(
&mut out,
&format!(" {}: {}", yaml_key(name), yaml_scalar(path)),
);
}
}
out
}
fn flush_todos(out: &mut String, pending: &mut Vec<Todo>, sink: &mut Vec<Todo>, indent: &str) {
for mut todo in pending.drain(..) {
line(out, &format!("{indent}# TODO: {}", todo.message));
let line_no = out.matches('\n').count();
todo.line = Some(line_no);
sink.push(todo);
}
}
fn is_sensitive(name: &str, flagged: &[String]) -> bool {
let lower = name.to_ascii_lowercase();
if matches!(
lower.as_str(),
"authorization" | "cookie" | "x-api-key" | "x-auth-token"
) {
return true;
}
flagged.iter().any(|h| h.eq_ignore_ascii_case(name))
}
fn render_json_body(out: &mut String, value: &serde_json::Value, indent: &str) {
match value {
serde_json::Value::Object(map) => {
if map.is_empty() {
line(out, &format!("{indent}{{}}"));
return;
}
for (k, v) in map {
render_json_pair(out, k, v, indent);
}
}
serde_json::Value::Array(arr) => {
if arr.is_empty() {
line(out, &format!("{indent}[]"));
return;
}
for item in arr {
match item {
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
line(out, &format!("{indent}-"));
let next = format!("{indent} ");
render_json_body(out, item, &next);
}
_ => {
line(out, &format!("{indent}- {}", yaml_scalar_for_json(item)));
}
}
}
}
_ => {
line(out, &format!("{indent}{}", yaml_scalar_for_json(value)));
}
}
}
fn render_json_pair(out: &mut String, key: &str, value: &serde_json::Value, indent: &str) {
match value {
serde_json::Value::Object(map) if !map.is_empty() => {
line(out, &format!("{indent}{}:", yaml_key(key)));
let next = format!("{indent} ");
for (k, v) in map {
render_json_pair(out, k, v, &next);
}
}
serde_json::Value::Array(arr) if !arr.is_empty() => {
line(out, &format!("{indent}{}:", yaml_key(key)));
let next = format!("{indent} ");
render_json_body(out, value, &next);
let _ = arr; }
serde_json::Value::Object(_) => {
line(out, &format!("{indent}{}: {{}}", yaml_key(key)));
}
serde_json::Value::Array(_) => {
line(out, &format!("{indent}{}: []", yaml_key(key)));
}
_ => {
line(
out,
&format!("{indent}{}: {}", yaml_key(key), yaml_scalar_for_json(value)),
);
}
}
}
fn yaml_scalar_for_json(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => "null".to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => yaml_scalar(s),
serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
value.to_string()
}
}
}
pub(crate) fn yaml_scalar(value: &str) -> String {
if value.is_empty() {
return "\"\"".to_string();
}
let needs_quote = value.contains("{{")
|| value.contains(": ")
|| value.contains(" #")
|| value.contains('\n')
|| value.contains('"')
|| value.contains('\'')
|| value.contains('\t')
|| value.starts_with(|c: char| "!&*?|>@`%#,[]{}\"'".contains(c))
|| value.ends_with(':')
|| matches!(
value.to_ascii_lowercase().as_str(),
"true" | "false" | "null" | "yes" | "no" | "on" | "off" | "~"
)
|| value.parse::<f64>().is_ok();
if needs_quote {
yaml_quoted_string(value)
} else {
value.to_string()
}
}
fn yaml_quoted_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
use std::fmt::Write as _;
let _ = write!(out, "\\x{:02x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
pub(crate) fn yaml_key(key: &str) -> String {
let needs_quote = key.is_empty()
|| key.contains(':')
|| key.contains(' ')
|| key.contains('#')
|| key.starts_with('$')
|| key.starts_with('@')
|| key.starts_with('[')
|| key.starts_with('"')
|| key.starts_with('\'');
if needs_quote {
yaml_quoted_string(key)
} else {
key.to_string()
}
}
fn line(out: &mut String, s: &str) {
out.push_str(s);
out.push('\n');
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn minimal_request() -> ScaffoldRequest {
let mut r = ScaffoldRequest::new("smoke", "GET /health");
r.method = "GET".into();
r.url = "{{ env.base_url }}/health".into();
r
}
#[test]
fn render_produces_valid_yaml_with_status_and_body_assertion() {
let mut todos = Vec::new();
let yaml = render(&minimal_request(), &mut todos);
assert!(yaml.contains("name: smoke"));
assert!(yaml.contains("method: GET"));
assert!(yaml.contains("url: \"{{ env.base_url }}/health\""));
assert!(yaml.contains("status: 2xx"));
assert!(yaml.contains("type: object"));
}
#[test]
fn todos_are_anchored_by_line_number() {
let mut todos = Vec::new();
let yaml = render(&minimal_request(), &mut todos);
assert!(!todos.is_empty());
let lines: Vec<&str> = yaml.lines().collect();
for todo in &todos {
let line = todo.line.expect("line populated");
let text = lines.get(line - 1).copied().unwrap_or("");
assert!(
text.trim_start().starts_with("# TODO:"),
"expected todo line {} to be a TODO, got {:?}",
line,
text
);
}
}
#[test]
fn sensitive_header_gets_auth_todo() {
let mut r = minimal_request();
r.headers
.insert("Authorization".into(), "Bearer abc".into());
r.sensitive_headers.push("Authorization".into());
let mut todos = Vec::new();
let yaml = render(&r, &mut todos);
assert!(todos.iter().any(|t| t.category == TodoCategory::Auth));
let auth_line = yaml
.lines()
.position(|l| l.contains("Authorization:"))
.expect("authorization header rendered");
let prev = yaml.lines().nth(auth_line - 1).unwrap();
assert!(prev.trim_start().starts_with("# TODO:"));
}
#[test]
fn json_body_is_rendered_as_yaml_mapping() {
let mut r = minimal_request();
let mut body = serde_json::Map::new();
body.insert("email".into(), serde_json::Value::Null);
body.insert(
"name".into(),
serde_json::Value::String("{{ $email }}".into()),
);
r.body = Some(BodyShape::Json(serde_json::Value::Object(body)));
let mut todos = Vec::new();
let yaml = render(&r, &mut todos);
assert!(yaml.contains("body:\n"));
assert!(yaml.contains("email: null"));
assert!(yaml.contains("name: \"{{ $email }}\""));
}
#[test]
fn captures_are_sorted_alphabetically() {
let mut r = minimal_request();
let mut caps = BTreeMap::new();
caps.insert("uuid".into(), "$.uuid".into());
caps.insert("id".into(), "$.id".into());
r.captures = caps;
let mut todos = Vec::new();
let yaml = render(&r, &mut todos);
let id_pos = yaml.find("id: $.id").unwrap();
let uuid_pos = yaml.find("uuid: $.uuid").unwrap();
assert!(id_pos < uuid_pos, "captures must render sorted");
}
#[test]
fn yaml_scalar_quotes_templated_values() {
assert_eq!(yaml_scalar("{{ env.x }}"), "\"{{ env.x }}\"");
}
#[test]
fn yaml_scalar_does_not_quote_plain_path() {
assert_eq!(yaml_scalar("/health"), "/health");
}
#[test]
fn yaml_scalar_quotes_reserved_words_and_numbers() {
assert_eq!(yaml_scalar("true"), "\"true\"");
assert_eq!(yaml_scalar("42"), "\"42\"");
}
#[test]
fn yaml_key_quotes_dollar_prefixed_names() {
assert_eq!(yaml_key("$.id"), "\"$.id\"");
assert_eq!(yaml_key("id"), "id");
}
}