1use {serde_json::Value, std::fmt::Write};
7
8#[must_use]
19pub fn render_field_table(schema: &Value) -> String {
20 let mut rows = String::new();
21 collect_rows(schema, schema, "", 0, &mut rows);
22 let mut out = field_table_header();
23 out.push_str(&rows);
24 out
25}
26
27#[must_use]
47pub fn render_sectioned_field_table(schema: &Value) -> String {
48 let required = required_fields(schema);
49 let Some(props) = schema.get("properties").and_then(Value::as_object) else {
50 return String::new();
51 };
52
53 let mut general: Vec<TopLevelField<'_>> = Vec::new();
57 let mut sections: Vec<SectionField<'_>> = Vec::new();
58 for (name, field) in props {
59 let resolved = effective_schema(schema, field);
60 if resolved.get("properties").is_some() {
61 sections.push(SectionField {
62 name,
63 field,
64 resolved,
65 });
66 } else {
67 general.push(TopLevelField { name, field });
68 }
69 }
70
71 let mut parts: Vec<String> = Vec::new();
72
73 if !general.is_empty() {
74 let mut body = String::from("## General\n\n");
75 body.push_str(&field_table_header());
76 for TopLevelField { name, field } in general {
77 let resolved = effective_schema(schema, field);
78 let req = required.contains(&name.as_str());
79 write_field_row(schema, name, field, resolved, req, &mut body);
80 }
81 parts.push(body);
82 }
83
84 for SectionField {
85 name,
86 field,
87 resolved,
88 } in sections
89 {
90 let mut body = format!("## {name}\n\n");
91 if let Some(desc) = field
92 .get("description")
93 .or_else(|| resolved.get("description"))
94 .and_then(Value::as_str)
95 {
96 body.push_str(&desc.replace('\n', " "));
99 body.push_str("\n\n");
100 }
101 body.push_str(&field_table_header());
102 collect_rows(schema, resolved, "", 0, &mut body);
105 parts.push(body);
106 }
107
108 parts.join("\n\n---\n\n")
109}
110
111struct TopLevelField<'a> {
113 name: &'a String,
114 field: &'a Value,
115}
116
117struct SectionField<'a> {
120 name: &'a String,
121 field: &'a Value,
122 resolved: &'a Value,
123}
124
125fn field_table_header() -> String {
130 String::from(
131 "| Field | Type | Required | Default | Description |\n\
132 |-------|------|----------|---------|-------------|\n",
133 )
134}
135
136fn write_field_row(
143 root: &Value,
144 name: &str,
145 field: &Value,
146 resolved: &Value,
147 required: bool,
148 out: &mut String,
149) {
150 let ty = type_label(root, resolved);
151 let req = if required { "yes" } else { "no" };
152 let default = field
153 .get("default")
154 .or_else(|| resolved.get("default"))
155 .map_or_default(render_default);
156 let desc = field
157 .get("description")
158 .or_else(|| resolved.get("description"))
159 .and_then(Value::as_str)
160 .unwrap_or("")
161 .replace('\n', " ")
162 .replace('|', "\\|");
163 let _ = writeln!(out, "| `{name}` | {ty} | {req} | {default} | {desc} |");
164}
165
166fn resolve_ref<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
170 let pointer = reference.strip_prefix('#')?;
171 root.pointer(pointer)
172}
173
174fn effective_schema<'a>(root: &'a Value, field: &'a Value) -> &'a Value {
183 let mut current = field;
184 for _ in 0..MAX_DEPTH {
185 if let Some(reference) = current.get("$ref").and_then(Value::as_str)
186 && let Some(target) = resolve_ref(root, reference)
187 {
188 current = target;
189 continue;
190 }
191 let next = ["anyOf", "allOf", "oneOf"].into_iter().find_map(|key| {
192 let branches = current.get(key).and_then(Value::as_array)?;
193 branches
194 .iter()
195 .find(|branch| branch.get("type").and_then(Value::as_str) != Some("null"))
196 });
197 let Some(next) = next else {
198 return current;
199 };
200 current = next;
201 }
202 current
203}
204
205const MAX_DEPTH: usize = 12;
211
212fn collect_rows(root: &Value, schema: &Value, prefix: &str, depth: usize, out: &mut String) {
213 let required = required_fields(schema);
214 let Some(props) = schema.get("properties").and_then(Value::as_object) else {
215 return;
216 };
217 for (name, field) in props {
218 let path = if prefix.is_empty() {
219 name.clone()
220 } else {
221 format!("{prefix}.{name}")
222 };
223 let resolved = effective_schema(root, field);
224 write_field_row(
225 root,
226 &path,
227 field,
228 resolved,
229 required.contains(&name.as_str()),
230 out,
231 );
232 if resolved.get("properties").is_some() && depth < MAX_DEPTH {
235 collect_rows(root, resolved, &path, depth + 1, out);
236 }
237 }
238}
239
240fn primitive_type(field: &Value) -> Option<String> {
244 match field.get("type") {
245 Some(Value::String(s)) => Some(s.clone()),
246 Some(Value::Array(arr)) => arr
247 .iter()
248 .filter_map(Value::as_str)
249 .find(|t| *t != "null")
250 .map(str::to_owned),
251 _ => None,
252 }
253}
254
255fn type_label(root: &Value, field: &Value) -> String { type_label_at(root, field, 0) }
256
257fn type_label_at(root: &Value, field: &Value, depth: usize) -> String {
258 if depth >= MAX_DEPTH {
259 return "any".to_owned();
260 }
261 let field = effective_schema(root, field);
263 match primitive_type(field).as_deref() {
264 Some("array") => {
265 let item = field
266 .get("items")
267 .map_or_else(|| "any".to_owned(), |it| type_label_at(root, it, depth + 1));
268 format!("array<{item}>")
269 }
270 Some(t) => t.to_owned(),
271 None => "object".to_owned(),
272 }
273}
274
275fn render_default(v: &Value) -> String {
276 match v {
279 Value::String(s) => format!("`{}`", s.replace('|', "\\|")),
280 other => format!("`{}`", other.to_string().replace('|', "\\|")),
281 }
282}
283
284fn required_fields(schema: &Value) -> Vec<&str> {
285 schema
286 .get("required")
287 .and_then(Value::as_array)
288 .map_or_default(|a| a.iter().filter_map(Value::as_str).collect())
289}
290
291#[cfg(test)]
292mod tests {
293 use {super::*, assert2::check, serde_json::json};
294
295 #[test]
296 fn renders_nested_object_with_types_and_defaults() {
297 let schema = json!({
298 "type": "object",
299 "required": ["name"],
300 "properties": {
301 "name": { "type": "string", "description": "The thing's name." },
302 "replicas": { "type": "integer", "default": 3, "description": "How many." },
303 "nested": { "type": "object", "properties": {
304 "enabled": { "type": "boolean", "description": "Toggle." } } }
305 }
306 });
307 let md = render_field_table(&schema);
308 for needle in [
309 "| `name` | string | yes |",
310 "The thing's name.",
311 "| `replicas` | integer | no | `3` |",
312 "`nested.enabled`",
313 ] {
314 assert2::assert!(md.contains(needle));
315 }
316 }
317
318 #[test]
319 fn renders_array_item_type() {
320 let schema = json!({ "type": "object", "properties": {
321 "ports": { "type": "array", "items": { "type": "integer" }, "description": "Listener ports." } } });
322 let md = render_field_table(&schema);
323 assert2::assert!(md.contains("`ports`"));
324 assert2::assert!(md.contains("array<integer>"));
325 }
326
327 #[test]
332 fn resolves_refs_and_nullable_type_arrays() {
333 let schema = json!({
334 "type": "object",
335 "$defs": {
336 "Tls": {
337 "type": "object",
338 "required": ["cert"],
339 "properties": {
340 "cert": { "type": "string", "description": "Cert path." }
341 }
342 }
343 },
344 "properties": {
345 "broker_id": { "type": ["integer", "null"], "format": "int32" },
346 "rack": { "type": ["string", "null"], "default": null, "description": "Rack id." },
347 "tls": {
348 "anyOf": [ { "$ref": "#/$defs/Tls" }, { "type": "null" } ],
349 "description": "TLS material."
350 },
351 "listeners": { "type": "array", "items": { "$ref": "#/$defs/Tls" } }
352 }
353 });
354 let md = render_field_table(&schema);
355 for needle in [
356 "| `broker_id` | integer | no |",
358 "| `rack` | string | no |",
359 "| `tls` | object | no |",
361 "| `tls.cert` | string | yes |",
362 "| `listeners` | array<object> | no |",
364 ] {
365 assert2::assert!(md.contains(needle));
366 }
367 }
368
369 #[test]
374 fn caps_recursion_on_cyclic_ref() {
375 let schema = json!({
376 "type": "object",
377 "$defs": {
378 "Node": {
379 "type": "object",
380 "properties": {
381 "child": { "$ref": "#/$defs/Node" },
383 "label": { "type": "string", "description": "Node label." }
384 }
385 }
386 },
387 "properties": {
388 "root": { "$ref": "#/$defs/Node" }
389 }
390 });
391 let md = render_field_table(&schema);
393 assert2::assert!(md.contains("| `root` | object | no |"));
394 let deepest = md
396 .lines()
397 .filter_map(|l| l.split('`').nth(1))
398 .map(|path| path.matches('.').count())
399 .max()
400 .unwrap_or(0);
401 assert2::assert!(deepest <= MAX_DEPTH);
402 }
403
404 fn check_escaped_mode_row(md: &str) {
405 let row = md.lines().find(|l| l.contains("`mode`")).expect("mode row");
406 assert2::assert!(row.contains("a \\| b \\| c"));
408 let unescaped_bars = row
410 .match_indices('|')
411 .filter(|(i, _)| *i == 0 || row.as_bytes()[i - 1] != b'\\')
412 .count();
413 assert2::assert!(unescaped_bars == 6);
414 }
415
416 #[test]
417 fn escapes_pipe_in_description() {
418 let schema = json!({
419 "type": "object",
420 "properties": {
421 "mode": {
422 "type": "string",
423 "description": "one of: a | b | c"
424 }
425 }
426 });
427 check_escaped_mode_row(&render_field_table(&schema));
428 }
429
430 #[test]
431 fn sectioned_table_emits_headings_and_separators() {
432 let schema = json!({
433 "type": "object",
434 "required": ["broker_id"],
435 "$defs": {
436 "Tls": {
437 "type": "object",
438 "description": "TLS material for the listener.",
439 "required": ["cert"],
440 "properties": {
441 "cert": { "type": "string", "description": "Cert path." },
442 "key": { "type": "string", "description": "Key path." }
443 }
444 }
445 },
446 "properties": {
447 "broker_id": { "type": "integer", "description": "This broker's id." },
448 "log_dir": { "type": ["string", "null"], "description": "Data directory." },
449 "tls": {
450 "anyOf": [ { "$ref": "#/$defs/Tls" }, { "type": "null" } ],
451 "description": "Server TLS config."
452 }
453 }
454 });
455 let md = render_sectioned_field_table(&schema);
456 check!(md.contains("## General"), "{md}");
458 check!(md.contains("| `broker_id` | integer | yes |"), "{md}");
459 check!(md.contains("| `log_dir` | string | no |"), "{md}");
460 check!(md.contains("## tls"), "{md}");
462 check!(md.contains("Server TLS config."), "{md}");
463 check!(md.contains("| `cert` | string | yes |"), "{md}");
465 check!(!md.contains("`tls.cert`"), "{md}");
466 check!(md.contains("\n---\n"), "{md}");
468 }
469
470 #[test]
471 fn sectioned_table_escapes_pipes_in_section_rows() {
472 let schema = json!({
473 "type": "object",
474 "$defs": {
475 "Inner": {
476 "type": "object",
477 "properties": {
478 "mode": { "type": "string", "description": "one of: a | b | c" }
479 }
480 }
481 },
482 "properties": {
483 "section": { "$ref": "#/$defs/Inner" }
484 }
485 });
486 check_escaped_mode_row(&render_sectioned_field_table(&schema));
487 }
488}