use std::time::Duration;
use serde::Serialize;
use super::Role;
use super::component::{Component, KvPair, StatusLabel};
use super::renderer::Table;
#[derive(Default)]
pub struct StatusFields {
pub detail: Option<String>,
pub duration: Option<Duration>,
pub target: Option<String>,
pub label: Option<StatusLabel>,
}
impl StatusFields {
pub fn detail(mut self, s: impl Into<String>) -> Self {
self.detail = Some(s.into());
self
}
pub fn detail_opt(mut self, s: Option<&str>) -> Self {
self.detail = s.map(|x| x.to_string());
self
}
pub fn duration(mut self, d: Duration) -> Self {
self.duration = Some(d);
self
}
pub fn target(mut self, s: impl Into<String>) -> Self {
self.target = Some(s.into());
self
}
pub fn label(mut self, role: Role, text: impl Into<String>) -> Self {
self.label = Some(StatusLabel {
role,
text: text.into(),
});
self
}
}
pub struct Doc {
pub(crate) heading: Option<String>,
pub(crate) children: Vec<Component>,
pub(crate) data: Option<serde_json::Value>,
}
impl Default for Doc {
fn default() -> Self {
Self::new()
}
}
impl Doc {
pub fn new() -> Self {
Self {
heading: None,
children: Vec::new(),
data: None,
}
}
pub fn heading(mut self, text: impl Into<String>) -> Self {
self.heading = Some(text.into());
self
}
pub fn kv(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
let pair = KvPair::new(key, value);
if let Some(Component::KvBlock { pairs }) = self.children.last_mut() {
pairs.push(pair);
} else {
self.children.push(Component::KvBlock { pairs: vec![pair] });
}
self
}
pub fn kv_block<I, K, V>(mut self, pairs: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
let pairs: Vec<KvPair> = pairs.into_iter().map(|(k, v)| KvPair::new(k, v)).collect();
if !pairs.is_empty() {
self.children.push(Component::KvBlock { pairs });
}
self
}
pub fn status(mut self, role: Role, subject: impl Into<String>) -> Self {
self.children.push(Component::Status {
role,
subject: subject.into(),
detail: None,
duration_ms: None,
target: None,
label: None,
});
self
}
pub fn status_with(
mut self,
role: Role,
subject: impl Into<String>,
build: impl FnOnce(StatusFields) -> StatusFields,
) -> Self {
let f = build(StatusFields::default());
self.children.push(Component::Status {
role,
subject: subject.into(),
detail: f.detail,
duration_ms: f.duration.map(|d| d.as_millis()),
target: f.target,
label: f.label,
});
self
}
pub fn hint(mut self, text: impl Into<String>) -> Self {
self.children.push(Component::Hint { text: text.into() });
self
}
pub fn note(mut self, text: impl Into<String>) -> Self {
self.children.push(Component::Note { text: text.into() });
self
}
pub fn table(mut self, t: Table) -> Self {
self.children.push(Component::Table {
headers: t.headers,
rows: t.rows,
row_roles: t.row_roles,
});
self
}
pub fn section<F>(mut self, name: impl Into<String>, build: F) -> Self
where
F: FnOnce(SectionBuilder) -> SectionBuilder,
{
let sb = build(SectionBuilder::new(name, true));
self.children.push(sb.into_component());
self
}
pub fn section_or_collapse<F>(mut self, name: impl Into<String>, build: F) -> Self
where
F: FnOnce(SectionBuilder) -> SectionBuilder,
{
let sb = build(SectionBuilder::new(name, false));
self.children.push(sb.into_component());
self
}
pub fn section_if_nonempty<T, F>(self, name: impl Into<String>, items: &[T], build: F) -> Self
where
F: FnOnce(SectionBuilder, &[T]) -> SectionBuilder,
{
if items.is_empty() {
return self;
}
let mut s = self;
let sb = build(SectionBuilder::new(name, true), items);
s.children.push(sb.into_component());
s
}
pub fn with_data<T: Serialize>(mut self, value: T) -> Self {
self.data = Some(serde_json::to_value(&value).unwrap_or(serde_json::Value::Null));
self
}
pub(crate) fn to_json_value(&self) -> serde_json::Value {
let children: Vec<serde_json::Value> = self
.children
.iter()
.map(|c| serde_json::to_value(c).unwrap_or(serde_json::Value::Null))
.collect();
let mut obj = serde_json::Map::new();
if let Some(h) = &self.heading {
obj.insert("heading".into(), serde_json::Value::String(h.clone()));
}
obj.insert("children".into(), serde_json::Value::Array(children));
serde_json::Value::Object(obj)
}
pub(crate) fn data_or_self_json(&self) -> serde_json::Value {
self.data.clone().unwrap_or_else(|| self.to_json_value())
}
}
pub struct SectionBuilder {
name: String,
keep_when_empty: bool,
empty_state: Option<String>,
children: Vec<Component>,
}
impl SectionBuilder {
pub(crate) fn new(name: impl Into<String>, keep_when_empty: bool) -> Self {
Self {
name: name.into(),
keep_when_empty,
empty_state: None,
children: Vec::new(),
}
}
pub(crate) fn into_component(self) -> Component {
Component::Section {
name: self.name,
keep_when_empty: self.keep_when_empty,
empty_state: self.empty_state,
children: self.children,
}
}
pub fn bullet(mut self, text: impl Into<String>) -> Self {
self.children.push(Component::Bullet { text: text.into() });
self
}
pub fn kv(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
let pair = KvPair::new(key, value);
if let Some(Component::KvBlock { pairs }) = self.children.last_mut() {
pairs.push(pair);
} else {
self.children.push(Component::KvBlock { pairs: vec![pair] });
}
self
}
pub fn kv_block<I, K, V>(mut self, pairs: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
let pairs: Vec<KvPair> = pairs.into_iter().map(|(k, v)| KvPair::new(k, v)).collect();
if !pairs.is_empty() {
self.children.push(Component::KvBlock { pairs });
}
self
}
pub fn status(mut self, role: Role, subject: impl Into<String>) -> Self {
self.children.push(Component::Status {
role,
subject: subject.into(),
detail: None,
duration_ms: None,
target: None,
label: None,
});
self
}
pub fn status_with(
mut self,
role: Role,
subject: impl Into<String>,
build: impl FnOnce(StatusFields) -> StatusFields,
) -> Self {
let f = build(StatusFields::default());
self.children.push(Component::Status {
role,
subject: subject.into(),
detail: f.detail,
duration_ms: f.duration.map(|d| d.as_millis()),
target: f.target,
label: f.label,
});
self
}
pub fn hint(mut self, text: impl Into<String>) -> Self {
self.children.push(Component::Hint { text: text.into() });
self
}
pub fn note(mut self, text: impl Into<String>) -> Self {
self.children.push(Component::Note { text: text.into() });
self
}
pub fn table(mut self, t: Table) -> Self {
self.children.push(Component::Table {
headers: t.headers,
rows: t.rows,
row_roles: t.row_roles,
});
self
}
pub fn empty_state(mut self, text: impl Into<String>) -> Self {
self.empty_state = Some(text.into());
self
}
pub fn subsection<F>(mut self, name: impl Into<String>, build: F) -> Self
where
F: FnOnce(SectionBuilder) -> SectionBuilder,
{
let sb = build(SectionBuilder::new(name, true));
self.children.push(sb.into_component());
self
}
pub fn subsection_if_nonempty<T, F>(
mut self,
name: impl Into<String>,
items: &[T],
build: F,
) -> Self
where
F: FnOnce(SectionBuilder, &[T]) -> SectionBuilder,
{
if items.is_empty() {
return self;
}
let sb = build(SectionBuilder::new(name, true), items);
self.children.push(sb.into_component());
self
}
pub fn extend<I, F>(mut self, items: I, mut build: F) -> Self
where
I: IntoIterator,
F: FnMut(SectionBuilder, I::Item) -> SectionBuilder,
{
for item in items {
self = build(self, item);
}
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_doc_serializes_minimally() {
let d = Doc::new();
let v = d.to_json_value();
assert_eq!(v["children"].as_array().unwrap().len(), 0);
assert!(v.get("heading").is_none());
}
#[test]
fn heading_and_kv_round_trip() {
let d = Doc::new().heading("Status").kv("Profile", "dev");
let v = d.to_json_value();
assert_eq!(v["heading"], "Status");
let kids = v["children"].as_array().unwrap();
assert_eq!(kids.len(), 1);
assert_eq!(kids[0]["type"], "kv_block");
assert_eq!(kids[0]["pairs"][0]["key"], "Profile");
}
#[test]
fn section_if_nonempty_skips_empty() {
let d: Doc = Doc::new().section_if_nonempty::<i32, _>("Items", &[], |s, _| s);
assert_eq!(d.children.len(), 0);
}
#[test]
fn section_if_nonempty_emits_when_present() {
let d = Doc::new().section_if_nonempty("Items", &[1, 2, 3], |s, items| {
let mut s = s;
for i in items {
s = s.bullet(format!("{i}"));
}
s
});
let v = d.to_json_value();
let kids = v["children"].as_array().unwrap();
assert_eq!(kids.len(), 1);
assert_eq!(kids[0]["type"], "section");
assert_eq!(kids[0]["children"].as_array().unwrap().len(), 3);
}
#[test]
fn extend_threads_correctly() {
let s = SectionBuilder::new("X", true)
.extend([1, 2, 3], |sb, n| sb.bullet(format!("item {n}")));
let c = s.into_component();
if let Component::Section { children, .. } = c {
assert_eq!(children.len(), 3);
} else {
panic!("expected Section");
}
}
#[test]
fn consecutive_kvs_coalesce_in_doc() {
let d = Doc::new().kv("Foo", "1").kv("LongerKey", "2").note("Next");
assert_eq!(d.children.len(), 2, "expected coalesced kvs + note");
if let Component::KvBlock { pairs } = &d.children[0] {
assert_eq!(pairs.len(), 2);
assert_eq!(pairs[0].key, "Foo");
assert_eq!(pairs[1].key, "LongerKey");
} else {
panic!(
"expected first child to be a coalesced KvBlock; got {:?}",
d.children[0]
);
}
}
#[test]
fn consecutive_kvs_coalesce_in_section_builder() {
let s = SectionBuilder::new("X", true)
.kv("Foo", "1")
.kv("LongerKey", "2")
.bullet("After"); let c = s.into_component();
if let Component::Section { children, .. } = c {
assert_eq!(children.len(), 2, "expected coalesced KvBlock + Bullet");
if let Component::KvBlock { pairs } = &children[0] {
assert_eq!(pairs.len(), 2);
assert_eq!(pairs[0].key, "Foo");
assert_eq!(pairs[1].key, "LongerKey");
} else {
panic!(
"expected first child to be a coalesced KvBlock; got {:?}",
children[0]
);
}
} else {
panic!("expected Section");
}
}
#[test]
fn explicit_kv_block_does_not_coalesce_with_kv() {
let d = Doc::new().kv("a", "1").kv_block([("b", "2"), ("c", "3")]);
assert_eq!(
d.children.len(),
2,
"kv_block should NOT merge with prior kv"
);
}
#[test]
fn with_data_overrides_doc_json() {
#[derive(serde::Serialize)]
struct Payload {
x: i32,
}
let d = Doc::new().heading("Foo").with_data(Payload { x: 7 });
let v = d.data_or_self_json();
assert_eq!(v["x"], 7);
}
#[test]
fn data_or_self_json_falls_back_to_doc_tree_without_data() {
let d = Doc::new().heading("Hi").kv("a", "b");
let v = d.data_or_self_json();
assert_eq!(v["heading"], "Hi");
assert!(!v["children"].as_array().unwrap().is_empty());
}
#[test]
fn doc_default_is_empty() {
let d = Doc::default();
assert!(d.heading.is_none());
assert!(d.children.is_empty());
assert!(d.data.is_none());
}
#[test]
fn doc_status_adds_status_component() {
let d = Doc::new().status(Role::Ok, "applied");
assert_eq!(d.children.len(), 1);
if let Component::Status {
role,
subject,
detail,
duration_ms,
target,
label,
} = &d.children[0]
{
assert!(matches!(role, Role::Ok));
assert_eq!(subject, "applied");
assert!(detail.is_none());
assert!(duration_ms.is_none());
assert!(target.is_none());
assert!(label.is_none());
} else {
panic!("expected Status");
}
}
#[test]
fn doc_status_with_populates_all_fields() {
let d = Doc::new().status_with(Role::Warn, "drift detected", |f| {
f.detail("3 files changed")
.duration(Duration::from_millis(42))
.target("/etc/config")
.label(Role::Secondary, "source-a")
});
if let Component::Status {
role,
subject,
detail,
duration_ms,
target,
label,
} = &d.children[0]
{
assert!(matches!(role, Role::Warn));
assert_eq!(subject, "drift detected");
assert_eq!(detail.as_deref(), Some("3 files changed"));
assert_eq!(*duration_ms, Some(42));
assert_eq!(target.as_deref(), Some("/etc/config"));
let l = label.as_ref().unwrap();
assert!(matches!(l.role, Role::Secondary));
assert_eq!(l.text, "source-a");
} else {
panic!("expected Status");
}
}
#[test]
fn status_fields_detail_opt_sets_none_for_none() {
let f = StatusFields::default().detail_opt(None);
assert!(f.detail.is_none());
}
#[test]
fn status_fields_detail_opt_sets_some() {
let f = StatusFields::default().detail_opt(Some("x"));
assert_eq!(f.detail.as_deref(), Some("x"));
}
#[test]
fn doc_hint_adds_hint_component() {
let d = Doc::new().hint("run cfgd apply");
if let Component::Hint { text } = &d.children[0] {
assert_eq!(text, "run cfgd apply");
} else {
panic!("expected Hint");
}
}
#[test]
fn doc_note_adds_note_component() {
let d = Doc::new().note("see docs");
if let Component::Note { text } = &d.children[0] {
assert_eq!(text, "see docs");
} else {
panic!("expected Note");
}
}
#[test]
fn doc_table_adds_table_component() {
let t = Table {
headers: vec!["Name".into(), "Version".into()],
rows: vec![vec!["foo".into(), "1.0".into()]],
row_roles: vec![],
};
let d = Doc::new().table(t);
if let Component::Table {
headers,
rows,
row_roles,
} = &d.children[0]
{
assert_eq!(headers.len(), 2);
assert_eq!(rows.len(), 1);
assert!(row_roles.is_empty());
} else {
panic!("expected Table");
}
}
#[test]
fn doc_section_builds_section_component() {
let d = Doc::new().section("Packages", |s| s.bullet("foo").bullet("bar"));
if let Component::Section {
name,
keep_when_empty,
children,
..
} = &d.children[0]
{
assert_eq!(name, "Packages");
assert!(keep_when_empty);
assert_eq!(children.len(), 2);
} else {
panic!("expected Section");
}
}
#[test]
fn doc_section_or_collapse_sets_keep_when_empty_false() {
let d = Doc::new().section_or_collapse("Empty", |s| s);
if let Component::Section {
keep_when_empty, ..
} = &d.children[0]
{
assert!(!keep_when_empty);
} else {
panic!("expected Section");
}
}
#[test]
fn doc_kv_block_stays_separate_from_prior_kv() {
let d = Doc::new()
.kv("standalone", "1")
.kv_block([("a", "2"), ("b", "3")]);
assert_eq!(d.children.len(), 2);
if let Component::KvBlock { pairs } = &d.children[1] {
assert_eq!(pairs.len(), 2);
assert_eq!(pairs[0].key, "a");
} else {
panic!("expected KvBlock");
}
}
#[test]
fn doc_kv_block_empty_is_noop() {
let d = Doc::new().kv_block::<Vec<(&str, &str)>, _, _>(vec![]);
assert!(d.children.is_empty());
}
#[test]
fn section_builder_status_adds_status() {
let s = SectionBuilder::new("X", true).status(Role::Info, "checking");
let c = s.into_component();
if let Component::Section { children, .. } = c {
assert_eq!(children.len(), 1);
assert!(
matches!(&children[0], Component::Status { role: Role::Info, subject, .. } if subject == "checking")
);
} else {
panic!("expected Section");
}
}
#[test]
fn section_builder_status_with_populates_fields() {
let s =
SectionBuilder::new("X", true).status_with(Role::Fail, "error", |f| f.detail("oops"));
let c = s.into_component();
if let Component::Section { children, .. } = c {
if let Component::Status { detail, .. } = &children[0] {
assert_eq!(detail.as_deref(), Some("oops"));
} else {
panic!("expected Status");
}
} else {
panic!("expected Section");
}
}
#[test]
fn section_builder_hint_and_note() {
let s = SectionBuilder::new("X", true)
.hint("try this")
.note("see also");
let c = s.into_component();
if let Component::Section { children, .. } = c {
assert_eq!(children.len(), 2);
assert!(matches!(&children[0], Component::Hint { text } if text == "try this"));
assert!(matches!(&children[1], Component::Note { text } if text == "see also"));
} else {
panic!("expected Section");
}
}
#[test]
fn section_builder_table() {
let t = Table {
headers: vec!["H".into()],
rows: vec![vec!["R".into()]],
row_roles: vec![],
};
let s = SectionBuilder::new("X", true).table(t);
let c = s.into_component();
if let Component::Section { children, .. } = c {
assert!(matches!(&children[0], Component::Table { .. }));
} else {
panic!("expected Section");
}
}
#[test]
fn section_builder_empty_state() {
let s = SectionBuilder::new("X", true).empty_state("nothing here");
let c = s.into_component();
if let Component::Section { empty_state, .. } = c {
assert_eq!(empty_state.as_deref(), Some("nothing here"));
} else {
panic!("expected Section");
}
}
#[test]
fn section_builder_subsection() {
let s = SectionBuilder::new("Parent", true).subsection("Child", |sub| sub.bullet("inner"));
let c = s.into_component();
if let Component::Section { children, .. } = c {
assert_eq!(children.len(), 1);
if let Component::Section { name, children, .. } = &children[0] {
assert_eq!(name, "Child");
assert_eq!(children.len(), 1);
} else {
panic!("expected nested Section");
}
} else {
panic!("expected Section");
}
}
#[test]
fn section_builder_subsection_if_nonempty_skips_empty() {
let s = SectionBuilder::new("P", true).subsection_if_nonempty::<i32, _>(
"Empty",
&[],
|sub, _| sub,
);
let c = s.into_component();
if let Component::Section { children, .. } = c {
assert!(children.is_empty());
} else {
panic!("expected Section");
}
}
#[test]
fn section_builder_subsection_if_nonempty_emits_when_present() {
let s = SectionBuilder::new("P", true).subsection_if_nonempty(
"Items",
&["a", "b"],
|sub, items| sub.extend(items.iter(), |sb, item| sb.bullet(*item)),
);
let c = s.into_component();
if let Component::Section { children, .. } = c {
assert_eq!(children.len(), 1);
if let Component::Section {
name,
children: inner,
..
} = &children[0]
{
assert_eq!(name, "Items");
assert_eq!(inner.len(), 2);
} else {
panic!("expected nested Section");
}
} else {
panic!("expected Section");
}
}
#[test]
fn section_builder_kv_block_separate() {
let s = SectionBuilder::new("X", true)
.kv("a", "1")
.kv_block([("b", "2")]);
let c = s.into_component();
if let Component::Section { children, .. } = c {
assert_eq!(children.len(), 2);
} else {
panic!("expected Section");
}
}
#[test]
fn section_builder_kv_block_empty_noop() {
let s = SectionBuilder::new("X", true).kv_block::<Vec<(&str, &str)>, _, _>(vec![]);
let c = s.into_component();
if let Component::Section { children, .. } = c {
assert!(children.is_empty());
} else {
panic!("expected Section");
}
}
}