use serde::Serialize;
use serde_json::{Map, Value};
const INDENT: &str = " ";
pub(crate) const ABSENT: &str = "-";
pub(crate) fn or_absent<T: std::fmt::Display>(value: Option<T>) -> String {
value
.map(|v| v.to_string())
.unwrap_or_else(|| ABSENT.to_string())
}
pub fn print_human<T: Serialize>(value: &T) -> Result<(), serde_json::Error> {
let value = serde_json::to_value(value)?;
let mut lines = Vec::new();
render(&mut lines, 0, &value);
for line in &lines {
println!("{line}");
}
Ok(())
}
fn pad(indent: usize) -> String {
INDENT.repeat(indent)
}
fn is_empty(value: &Value) -> bool {
match value {
Value::Null => true,
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
_ => false,
}
}
fn is_scalar(value: &Value) -> bool {
matches!(value, Value::String(_) | Value::Number(_) | Value::Bool(_))
}
fn scalar_string(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn render(lines: &mut Vec<String>, indent: usize, value: &Value) {
match value {
Value::Object(map) => render_object(lines, indent, map),
Value::Array(items) => render_array(lines, indent, items),
scalar => {
if let Some(s) = scalar_string(scalar) {
lines.push(format!("{}{}", pad(indent), s));
}
}
}
}
fn render_object(lines: &mut Vec<String>, indent: usize, map: &Map<String, Value>) {
for (key, value) in map {
if is_empty(value) {
continue;
}
match value {
Value::Object(inner) => {
let start = lines.len();
lines.push(format!("{}{}:", pad(indent), key));
render_object(lines, indent + 1, inner);
if lines.len() == start + 1 {
lines.pop();
}
}
Value::Array(items) => render_array_field(lines, indent, key, items),
scalar => lines.push(format!(
"{}{}: {}",
pad(indent),
key,
scalar_string(scalar).unwrap_or_default()
)),
}
}
}
fn render_array_field(lines: &mut Vec<String>, indent: usize, key: &str, items: &[Value]) {
if items.iter().all(is_scalar) {
let joined = items
.iter()
.filter_map(scalar_string)
.collect::<Vec<_>>()
.join(", ");
lines.push(format!("{}{}: [{}]", pad(indent), key, joined));
} else {
let start = lines.len();
lines.push(format!("{}{}:", pad(indent), key));
render_array(lines, indent + 1, items);
if lines.len() == start + 1 {
lines.pop();
}
}
}
fn render_array(lines: &mut Vec<String>, indent: usize, items: &[Value]) {
for item in items {
if is_empty(item) {
continue;
}
let start = lines.len();
render(lines, indent + 1, item);
if lines.len() > start {
let bullet_pos = indent * INDENT.len();
let line = &mut lines[start];
if line.len() >= bullet_pos + INDENT.len()
&& &line[bullet_pos..bullet_pos + INDENT.len()] == INDENT
{
line.replace_range(bullet_pos..bullet_pos + INDENT.len(), "- ");
} else {
line.insert_str(bullet_pos, "- ");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn render_to_string(value: &Value) -> String {
let mut lines = Vec::new();
render(&mut lines, 0, value);
lines.join("\n")
}
#[test]
fn renders_flat_object() {
let v = json!({"name": "svc", "port": 9000, "secure": true});
assert_eq!(render_to_string(&v), "name: svc\nport: 9000\nsecure: true");
}
#[test]
fn omits_null_and_empty_values() {
let v = json!({
"name": "svc",
"note": null,
"empty": "",
"tags": [],
"meta": {},
"count": 0,
"flag": false
});
assert_eq!(render_to_string(&v), "name: svc\ncount: 0\nflag: false");
}
#[test]
fn renders_nested_object_indented() {
let v = json!({"service": {"name": "svc", "region": "us-east-1"}});
assert_eq!(
render_to_string(&v),
"service:\n name: svc\n region: us-east-1"
);
}
#[test]
fn renders_scalar_array_inline() {
let v = json!({"providers": ["aws", "gcp", "azure"]});
assert_eq!(render_to_string(&v), "providers: [aws, gcp, azure]");
}
#[test]
fn renders_object_array_as_bullets() {
let v = json!({
"endpoints": [
{"protocol": "https", "port": 8443},
{"protocol": "nativesecure", "port": 9440}
]
});
assert_eq!(
render_to_string(&v),
"endpoints:\n - protocol: https\n port: 8443\n - protocol: nativesecure\n port: 9440"
);
}
#[test]
fn renders_nested_object_under_bullet() {
let v = json!({
"columns": [
{"name": "id", "type": {"kind": "UInt64", "nullable": false}}
]
});
assert_eq!(
render_to_string(&v),
"columns:\n - name: id\n type:\n kind: UInt64\n nullable: false"
);
}
#[cfg(not(feature = "deprecated-fields"))]
#[test]
fn service_get_render_omits_deprecated_tier() {
let svc: clickhouse_cloud_api::models::Service = serde_json::from_str(
r#"{
"name": "analytics",
"provider": "aws",
"region": "us-east-1",
"tier": "production",
"minTotalMemoryGb": 24,
"maxTotalMemoryGb": 48,
"numReplicas": 3
}"#,
)
.unwrap();
let rendered = render_to_string(&serde_json::to_value(&svc).unwrap());
assert!(rendered.contains("name: analytics"));
assert!(rendered.contains("numReplicas: 3"));
assert!(
!rendered.contains("tier"),
"deprecated tier leaked: {rendered}"
);
assert!(!rendered.contains("minTotalMemoryGb"));
assert!(!rendered.contains("maxTotalMemoryGb"));
}
#[test]
fn deprecated_field_absent_means_omitted() {
let v = json!({"name": "svc", "state": "running"});
let out = render_to_string(&v);
assert!(!out.contains("tier"));
assert_eq!(out, "name: svc\nstate: running");
}
}