Skip to main content

cfgd_core/output/
doc.rs

1use std::time::Duration;
2
3use serde::Serialize;
4
5use super::Role;
6use super::component::{Component, KvPair, StatusLabel};
7use super::renderer::Table;
8
9/// One status row's optional fields, used by `status_with`.
10#[derive(Default)]
11pub struct StatusFields {
12    pub detail: Option<String>,
13    pub duration: Option<Duration>,
14    pub target: Option<String>,
15    pub label: Option<StatusLabel>,
16}
17
18impl StatusFields {
19    pub fn detail(mut self, s: impl Into<String>) -> Self {
20        self.detail = Some(s.into());
21        self
22    }
23    pub fn detail_opt(mut self, s: Option<&str>) -> Self {
24        self.detail = s.map(|x| x.to_string());
25        self
26    }
27    pub fn duration(mut self, d: Duration) -> Self {
28        self.duration = Some(d);
29        self
30    }
31    pub fn target(mut self, s: impl Into<String>) -> Self {
32        self.target = Some(s.into());
33        self
34    }
35    /// Trailing styled label (e.g. `[source-name]`). Rendered at the END of
36    /// the subject so the inner SGR reset cannot be followed by outer-role
37    /// styled text — the only safe nesting shape for the streaming renderer.
38    pub fn label(mut self, role: Role, text: impl Into<String>) -> Self {
39        self.label = Some(StatusLabel {
40            role,
41            text: text.into(),
42        });
43        self
44    }
45}
46
47/// Top-level buffered document. Built then handed to `Printer::emit`.
48pub struct Doc {
49    pub(crate) heading: Option<String>,
50    pub(crate) children: Vec<Component>,
51    /// Optional payload that REPLACES Doc-derived JSON in structured modes.
52    pub(crate) data: Option<serde_json::Value>,
53}
54
55impl Default for Doc {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl Doc {
62    pub fn new() -> Self {
63        Self {
64            heading: None,
65            children: Vec::new(),
66            data: None,
67        }
68    }
69
70    pub fn heading(mut self, text: impl Into<String>) -> Self {
71        self.heading = Some(text.into());
72        self
73    }
74
75    pub fn kv(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
76        // Consecutive standalone kv() calls must render as one aligned block
77        // (the buffered surface mirrors the streaming auto-batching rule).
78        let pair = KvPair::new(key, value);
79        if let Some(Component::KvBlock { pairs }) = self.children.last_mut() {
80            pairs.push(pair);
81        } else {
82            self.children.push(Component::KvBlock { pairs: vec![pair] });
83        }
84        self
85    }
86
87    pub fn kv_block<I, K, V>(mut self, pairs: I) -> Self
88    where
89        I: IntoIterator<Item = (K, V)>,
90        K: Into<String>,
91        V: Into<String>,
92    {
93        let pairs: Vec<KvPair> = pairs.into_iter().map(|(k, v)| KvPair::new(k, v)).collect();
94        if !pairs.is_empty() {
95            // kv_block is an explicit batch — it does NOT coalesce with prior
96            // KvBlock children. Author intent matters here; consecutive
97            // kv_block calls remain as separate aligned blocks.
98            self.children.push(Component::KvBlock { pairs });
99        }
100        self
101    }
102
103    pub fn status(mut self, role: Role, subject: impl Into<String>) -> Self {
104        self.children.push(Component::Status {
105            role,
106            subject: subject.into(),
107            detail: None,
108            duration_ms: None,
109            target: None,
110            label: None,
111        });
112        self
113    }
114
115    pub fn status_with(
116        mut self,
117        role: Role,
118        subject: impl Into<String>,
119        build: impl FnOnce(StatusFields) -> StatusFields,
120    ) -> Self {
121        let f = build(StatusFields::default());
122        self.children.push(Component::Status {
123            role,
124            subject: subject.into(),
125            detail: f.detail,
126            duration_ms: f.duration.map(|d| d.as_millis()),
127            target: f.target,
128            label: f.label,
129        });
130        self
131    }
132
133    pub fn hint(mut self, text: impl Into<String>) -> Self {
134        self.children.push(Component::Hint { text: text.into() });
135        self
136    }
137
138    pub fn note(mut self, text: impl Into<String>) -> Self {
139        self.children.push(Component::Note { text: text.into() });
140        self
141    }
142
143    pub fn table(mut self, t: Table) -> Self {
144        self.children.push(Component::Table {
145            headers: t.headers,
146            rows: t.rows,
147            row_roles: t.row_roles,
148        });
149        self
150    }
151
152    pub fn section<F>(mut self, name: impl Into<String>, build: F) -> Self
153    where
154        F: FnOnce(SectionBuilder) -> SectionBuilder,
155    {
156        let sb = build(SectionBuilder::new(name, /*keep_when_empty=*/ true));
157        self.children.push(sb.into_component());
158        self
159    }
160
161    pub fn section_or_collapse<F>(mut self, name: impl Into<String>, build: F) -> Self
162    where
163        F: FnOnce(SectionBuilder) -> SectionBuilder,
164    {
165        let sb = build(SectionBuilder::new(name, /*keep_when_empty=*/ false));
166        self.children.push(sb.into_component());
167        self
168    }
169
170    pub fn section_if_nonempty<T, F>(self, name: impl Into<String>, items: &[T], build: F) -> Self
171    where
172        F: FnOnce(SectionBuilder, &[T]) -> SectionBuilder,
173    {
174        if items.is_empty() {
175            return self;
176        }
177        let mut s = self;
178        let sb = build(SectionBuilder::new(name, true), items);
179        s.children.push(sb.into_component());
180        s
181    }
182
183    /// Attach a typed payload that REPLACES Doc-derived JSON in structured modes.
184    pub fn with_data<T: Serialize>(mut self, value: T) -> Self {
185        self.data = Some(serde_json::to_value(&value).unwrap_or(serde_json::Value::Null));
186        self
187    }
188
189    /// Convert the Doc into a JSON value (excluding `data`); used by tests +
190    /// the structured emit path when no `with_data` was set.
191    pub(crate) fn to_json_value(&self) -> serde_json::Value {
192        let children: Vec<serde_json::Value> = self
193            .children
194            .iter()
195            .map(|c| serde_json::to_value(c).unwrap_or(serde_json::Value::Null))
196            .collect();
197        let mut obj = serde_json::Map::new();
198        if let Some(h) = &self.heading {
199            obj.insert("heading".into(), serde_json::Value::String(h.clone()));
200        }
201        obj.insert("children".into(), serde_json::Value::Array(children));
202        serde_json::Value::Object(obj)
203    }
204
205    pub(crate) fn data_or_self_json(&self) -> serde_json::Value {
206        self.data.clone().unwrap_or_else(|| self.to_json_value())
207    }
208}
209
210/// Builder for one Section. Same vocabulary as Doc plus `subsection`.
211pub struct SectionBuilder {
212    name: String,
213    keep_when_empty: bool,
214    empty_state: Option<String>,
215    children: Vec<Component>,
216}
217
218impl SectionBuilder {
219    pub(crate) fn new(name: impl Into<String>, keep_when_empty: bool) -> Self {
220        Self {
221            name: name.into(),
222            keep_when_empty,
223            empty_state: None,
224            children: Vec::new(),
225        }
226    }
227
228    pub(crate) fn into_component(self) -> Component {
229        Component::Section {
230            name: self.name,
231            keep_when_empty: self.keep_when_empty,
232            empty_state: self.empty_state,
233            children: self.children,
234        }
235    }
236
237    pub fn bullet(mut self, text: impl Into<String>) -> Self {
238        self.children.push(Component::Bullet { text: text.into() });
239        self
240    }
241
242    pub fn kv(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
243        // Coalesce consecutive standalone kv() calls into one aligned block.
244        let pair = KvPair::new(key, value);
245        if let Some(Component::KvBlock { pairs }) = self.children.last_mut() {
246            pairs.push(pair);
247        } else {
248            self.children.push(Component::KvBlock { pairs: vec![pair] });
249        }
250        self
251    }
252
253    pub fn kv_block<I, K, V>(mut self, pairs: I) -> Self
254    where
255        I: IntoIterator<Item = (K, V)>,
256        K: Into<String>,
257        V: Into<String>,
258    {
259        let pairs: Vec<KvPair> = pairs.into_iter().map(|(k, v)| KvPair::new(k, v)).collect();
260        if !pairs.is_empty() {
261            self.children.push(Component::KvBlock { pairs });
262        }
263        self
264    }
265
266    pub fn status(mut self, role: Role, subject: impl Into<String>) -> Self {
267        self.children.push(Component::Status {
268            role,
269            subject: subject.into(),
270            detail: None,
271            duration_ms: None,
272            target: None,
273            label: None,
274        });
275        self
276    }
277
278    pub fn status_with(
279        mut self,
280        role: Role,
281        subject: impl Into<String>,
282        build: impl FnOnce(StatusFields) -> StatusFields,
283    ) -> Self {
284        let f = build(StatusFields::default());
285        self.children.push(Component::Status {
286            role,
287            subject: subject.into(),
288            detail: f.detail,
289            duration_ms: f.duration.map(|d| d.as_millis()),
290            target: f.target,
291            label: f.label,
292        });
293        self
294    }
295
296    pub fn hint(mut self, text: impl Into<String>) -> Self {
297        self.children.push(Component::Hint { text: text.into() });
298        self
299    }
300
301    pub fn note(mut self, text: impl Into<String>) -> Self {
302        self.children.push(Component::Note { text: text.into() });
303        self
304    }
305
306    pub fn table(mut self, t: Table) -> Self {
307        self.children.push(Component::Table {
308            headers: t.headers,
309            rows: t.rows,
310            row_roles: t.row_roles,
311        });
312        self
313    }
314
315    pub fn empty_state(mut self, text: impl Into<String>) -> Self {
316        self.empty_state = Some(text.into());
317        self
318    }
319
320    pub fn subsection<F>(mut self, name: impl Into<String>, build: F) -> Self
321    where
322        F: FnOnce(SectionBuilder) -> SectionBuilder,
323    {
324        let sb = build(SectionBuilder::new(name, /*keep_when_empty=*/ true));
325        self.children.push(sb.into_component());
326        self
327    }
328
329    pub fn subsection_if_nonempty<T, F>(
330        mut self,
331        name: impl Into<String>,
332        items: &[T],
333        build: F,
334    ) -> Self
335    where
336        F: FnOnce(SectionBuilder, &[T]) -> SectionBuilder,
337    {
338        if items.is_empty() {
339            return self;
340        }
341        let sb = build(SectionBuilder::new(name, true), items);
342        self.children.push(sb.into_component());
343        self
344    }
345
346    /// Helper: extend a builder by iterating `items`, applying `build` for each.
347    /// Avoids the `let mut s = s; for ... { s = ... } s` boilerplate.
348    pub fn extend<I, F>(mut self, items: I, mut build: F) -> Self
349    where
350        I: IntoIterator,
351        F: FnMut(SectionBuilder, I::Item) -> SectionBuilder,
352    {
353        for item in items {
354            self = build(self, item);
355        }
356        self
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn empty_doc_serializes_minimally() {
366        let d = Doc::new();
367        let v = d.to_json_value();
368        assert_eq!(v["children"].as_array().unwrap().len(), 0);
369        assert!(v.get("heading").is_none());
370    }
371
372    #[test]
373    fn heading_and_kv_round_trip() {
374        let d = Doc::new().heading("Status").kv("Profile", "dev");
375        let v = d.to_json_value();
376        assert_eq!(v["heading"], "Status");
377        let kids = v["children"].as_array().unwrap();
378        assert_eq!(kids.len(), 1);
379        assert_eq!(kids[0]["type"], "kv_block");
380        assert_eq!(kids[0]["pairs"][0]["key"], "Profile");
381    }
382
383    #[test]
384    fn section_if_nonempty_skips_empty() {
385        let d: Doc = Doc::new().section_if_nonempty::<i32, _>("Items", &[], |s, _| s);
386        assert_eq!(d.children.len(), 0);
387    }
388
389    #[test]
390    fn section_if_nonempty_emits_when_present() {
391        let d = Doc::new().section_if_nonempty("Items", &[1, 2, 3], |s, items| {
392            let mut s = s;
393            for i in items {
394                s = s.bullet(format!("{i}"));
395            }
396            s
397        });
398        let v = d.to_json_value();
399        let kids = v["children"].as_array().unwrap();
400        assert_eq!(kids.len(), 1);
401        assert_eq!(kids[0]["type"], "section");
402        assert_eq!(kids[0]["children"].as_array().unwrap().len(), 3);
403    }
404
405    #[test]
406    fn extend_threads_correctly() {
407        let s = SectionBuilder::new("X", true)
408            .extend([1, 2, 3], |sb, n| sb.bullet(format!("item {n}")));
409        let c = s.into_component();
410        if let Component::Section { children, .. } = c {
411            assert_eq!(children.len(), 3);
412        } else {
413            panic!("expected Section");
414        }
415    }
416
417    #[test]
418    fn consecutive_kvs_coalesce_in_doc() {
419        // Doc::kv must coalesce consecutive standalone calls into one aligned
420        // block. The trailing `.note(...)` is a non-Kv child used to verify
421        // the coalescing boundary — Kv runs end when a non-Kv child arrives.
422        let d = Doc::new().kv("Foo", "1").kv("LongerKey", "2").note("Next");
423        assert_eq!(d.children.len(), 2, "expected coalesced kvs + note");
424        if let Component::KvBlock { pairs } = &d.children[0] {
425            assert_eq!(pairs.len(), 2);
426            assert_eq!(pairs[0].key, "Foo");
427            assert_eq!(pairs[1].key, "LongerKey");
428        } else {
429            panic!(
430                "expected first child to be a coalesced KvBlock; got {:?}",
431                d.children[0]
432            );
433        }
434    }
435
436    #[test]
437    fn consecutive_kvs_coalesce_in_section_builder() {
438        let s = SectionBuilder::new("X", true)
439            .kv("Foo", "1")
440            .kv("LongerKey", "2")
441            .bullet("After"); // non-Kv child should break coalescing
442        let c = s.into_component();
443        if let Component::Section { children, .. } = c {
444            assert_eq!(children.len(), 2, "expected coalesced KvBlock + Bullet");
445            if let Component::KvBlock { pairs } = &children[0] {
446                assert_eq!(pairs.len(), 2);
447                assert_eq!(pairs[0].key, "Foo");
448                assert_eq!(pairs[1].key, "LongerKey");
449            } else {
450                panic!(
451                    "expected first child to be a coalesced KvBlock; got {:?}",
452                    children[0]
453                );
454            }
455        } else {
456            panic!("expected Section");
457        }
458    }
459
460    #[test]
461    fn explicit_kv_block_does_not_coalesce_with_kv() {
462        // kv_block expresses author intent — keep as a separate block.
463        let d = Doc::new().kv("a", "1").kv_block([("b", "2"), ("c", "3")]);
464        assert_eq!(
465            d.children.len(),
466            2,
467            "kv_block should NOT merge with prior kv"
468        );
469    }
470
471    #[test]
472    fn with_data_overrides_doc_json() {
473        #[derive(serde::Serialize)]
474        struct Payload {
475            x: i32,
476        }
477        let d = Doc::new().heading("Foo").with_data(Payload { x: 7 });
478        let v = d.data_or_self_json();
479        assert_eq!(v["x"], 7);
480    }
481
482    #[test]
483    fn data_or_self_json_falls_back_to_doc_tree_without_data() {
484        let d = Doc::new().heading("Hi").kv("a", "b");
485        let v = d.data_or_self_json();
486        assert_eq!(v["heading"], "Hi");
487        assert!(!v["children"].as_array().unwrap().is_empty());
488    }
489
490    #[test]
491    fn doc_default_is_empty() {
492        let d = Doc::default();
493        assert!(d.heading.is_none());
494        assert!(d.children.is_empty());
495        assert!(d.data.is_none());
496    }
497
498    #[test]
499    fn doc_status_adds_status_component() {
500        let d = Doc::new().status(Role::Ok, "applied");
501        assert_eq!(d.children.len(), 1);
502        if let Component::Status {
503            role,
504            subject,
505            detail,
506            duration_ms,
507            target,
508            label,
509        } = &d.children[0]
510        {
511            assert!(matches!(role, Role::Ok));
512            assert_eq!(subject, "applied");
513            assert!(detail.is_none());
514            assert!(duration_ms.is_none());
515            assert!(target.is_none());
516            assert!(label.is_none());
517        } else {
518            panic!("expected Status");
519        }
520    }
521
522    #[test]
523    fn doc_status_with_populates_all_fields() {
524        let d = Doc::new().status_with(Role::Warn, "drift detected", |f| {
525            f.detail("3 files changed")
526                .duration(Duration::from_millis(42))
527                .target("/etc/config")
528                .label(Role::Secondary, "source-a")
529        });
530        if let Component::Status {
531            role,
532            subject,
533            detail,
534            duration_ms,
535            target,
536            label,
537        } = &d.children[0]
538        {
539            assert!(matches!(role, Role::Warn));
540            assert_eq!(subject, "drift detected");
541            assert_eq!(detail.as_deref(), Some("3 files changed"));
542            assert_eq!(*duration_ms, Some(42));
543            assert_eq!(target.as_deref(), Some("/etc/config"));
544            let l = label.as_ref().unwrap();
545            assert!(matches!(l.role, Role::Secondary));
546            assert_eq!(l.text, "source-a");
547        } else {
548            panic!("expected Status");
549        }
550    }
551
552    #[test]
553    fn status_fields_detail_opt_sets_none_for_none() {
554        let f = StatusFields::default().detail_opt(None);
555        assert!(f.detail.is_none());
556    }
557
558    #[test]
559    fn status_fields_detail_opt_sets_some() {
560        let f = StatusFields::default().detail_opt(Some("x"));
561        assert_eq!(f.detail.as_deref(), Some("x"));
562    }
563
564    #[test]
565    fn doc_hint_adds_hint_component() {
566        let d = Doc::new().hint("run cfgd apply");
567        if let Component::Hint { text } = &d.children[0] {
568            assert_eq!(text, "run cfgd apply");
569        } else {
570            panic!("expected Hint");
571        }
572    }
573
574    #[test]
575    fn doc_note_adds_note_component() {
576        let d = Doc::new().note("see docs");
577        if let Component::Note { text } = &d.children[0] {
578            assert_eq!(text, "see docs");
579        } else {
580            panic!("expected Note");
581        }
582    }
583
584    #[test]
585    fn doc_table_adds_table_component() {
586        let t = Table {
587            headers: vec!["Name".into(), "Version".into()],
588            rows: vec![vec!["foo".into(), "1.0".into()]],
589            row_roles: vec![],
590        };
591        let d = Doc::new().table(t);
592        if let Component::Table {
593            headers,
594            rows,
595            row_roles,
596        } = &d.children[0]
597        {
598            assert_eq!(headers.len(), 2);
599            assert_eq!(rows.len(), 1);
600            assert!(row_roles.is_empty());
601        } else {
602            panic!("expected Table");
603        }
604    }
605
606    #[test]
607    fn doc_section_builds_section_component() {
608        let d = Doc::new().section("Packages", |s| s.bullet("foo").bullet("bar"));
609        if let Component::Section {
610            name,
611            keep_when_empty,
612            children,
613            ..
614        } = &d.children[0]
615        {
616            assert_eq!(name, "Packages");
617            assert!(keep_when_empty);
618            assert_eq!(children.len(), 2);
619        } else {
620            panic!("expected Section");
621        }
622    }
623
624    #[test]
625    fn doc_section_or_collapse_sets_keep_when_empty_false() {
626        let d = Doc::new().section_or_collapse("Empty", |s| s);
627        if let Component::Section {
628            keep_when_empty, ..
629        } = &d.children[0]
630        {
631            assert!(!keep_when_empty);
632        } else {
633            panic!("expected Section");
634        }
635    }
636
637    #[test]
638    fn doc_kv_block_stays_separate_from_prior_kv() {
639        let d = Doc::new()
640            .kv("standalone", "1")
641            .kv_block([("a", "2"), ("b", "3")]);
642        assert_eq!(d.children.len(), 2);
643        if let Component::KvBlock { pairs } = &d.children[1] {
644            assert_eq!(pairs.len(), 2);
645            assert_eq!(pairs[0].key, "a");
646        } else {
647            panic!("expected KvBlock");
648        }
649    }
650
651    #[test]
652    fn doc_kv_block_empty_is_noop() {
653        let d = Doc::new().kv_block::<Vec<(&str, &str)>, _, _>(vec![]);
654        assert!(d.children.is_empty());
655    }
656
657    #[test]
658    fn section_builder_status_adds_status() {
659        let s = SectionBuilder::new("X", true).status(Role::Info, "checking");
660        let c = s.into_component();
661        if let Component::Section { children, .. } = c {
662            assert_eq!(children.len(), 1);
663            assert!(
664                matches!(&children[0], Component::Status { role: Role::Info, subject, .. } if subject == "checking")
665            );
666        } else {
667            panic!("expected Section");
668        }
669    }
670
671    #[test]
672    fn section_builder_status_with_populates_fields() {
673        let s =
674            SectionBuilder::new("X", true).status_with(Role::Fail, "error", |f| f.detail("oops"));
675        let c = s.into_component();
676        if let Component::Section { children, .. } = c {
677            if let Component::Status { detail, .. } = &children[0] {
678                assert_eq!(detail.as_deref(), Some("oops"));
679            } else {
680                panic!("expected Status");
681            }
682        } else {
683            panic!("expected Section");
684        }
685    }
686
687    #[test]
688    fn section_builder_hint_and_note() {
689        let s = SectionBuilder::new("X", true)
690            .hint("try this")
691            .note("see also");
692        let c = s.into_component();
693        if let Component::Section { children, .. } = c {
694            assert_eq!(children.len(), 2);
695            assert!(matches!(&children[0], Component::Hint { text } if text == "try this"));
696            assert!(matches!(&children[1], Component::Note { text } if text == "see also"));
697        } else {
698            panic!("expected Section");
699        }
700    }
701
702    #[test]
703    fn section_builder_table() {
704        let t = Table {
705            headers: vec!["H".into()],
706            rows: vec![vec!["R".into()]],
707            row_roles: vec![],
708        };
709        let s = SectionBuilder::new("X", true).table(t);
710        let c = s.into_component();
711        if let Component::Section { children, .. } = c {
712            assert!(matches!(&children[0], Component::Table { .. }));
713        } else {
714            panic!("expected Section");
715        }
716    }
717
718    #[test]
719    fn section_builder_empty_state() {
720        let s = SectionBuilder::new("X", true).empty_state("nothing here");
721        let c = s.into_component();
722        if let Component::Section { empty_state, .. } = c {
723            assert_eq!(empty_state.as_deref(), Some("nothing here"));
724        } else {
725            panic!("expected Section");
726        }
727    }
728
729    #[test]
730    fn section_builder_subsection() {
731        let s = SectionBuilder::new("Parent", true).subsection("Child", |sub| sub.bullet("inner"));
732        let c = s.into_component();
733        if let Component::Section { children, .. } = c {
734            assert_eq!(children.len(), 1);
735            if let Component::Section { name, children, .. } = &children[0] {
736                assert_eq!(name, "Child");
737                assert_eq!(children.len(), 1);
738            } else {
739                panic!("expected nested Section");
740            }
741        } else {
742            panic!("expected Section");
743        }
744    }
745
746    #[test]
747    fn section_builder_subsection_if_nonempty_skips_empty() {
748        let s = SectionBuilder::new("P", true).subsection_if_nonempty::<i32, _>(
749            "Empty",
750            &[],
751            |sub, _| sub,
752        );
753        let c = s.into_component();
754        if let Component::Section { children, .. } = c {
755            assert!(children.is_empty());
756        } else {
757            panic!("expected Section");
758        }
759    }
760
761    #[test]
762    fn section_builder_subsection_if_nonempty_emits_when_present() {
763        let s = SectionBuilder::new("P", true).subsection_if_nonempty(
764            "Items",
765            &["a", "b"],
766            |sub, items| sub.extend(items.iter(), |sb, item| sb.bullet(*item)),
767        );
768        let c = s.into_component();
769        if let Component::Section { children, .. } = c {
770            assert_eq!(children.len(), 1);
771            if let Component::Section {
772                name,
773                children: inner,
774                ..
775            } = &children[0]
776            {
777                assert_eq!(name, "Items");
778                assert_eq!(inner.len(), 2);
779            } else {
780                panic!("expected nested Section");
781            }
782        } else {
783            panic!("expected Section");
784        }
785    }
786
787    #[test]
788    fn section_builder_kv_block_separate() {
789        let s = SectionBuilder::new("X", true)
790            .kv("a", "1")
791            .kv_block([("b", "2")]);
792        let c = s.into_component();
793        if let Component::Section { children, .. } = c {
794            assert_eq!(children.len(), 2);
795        } else {
796            panic!("expected Section");
797        }
798    }
799
800    #[test]
801    fn section_builder_kv_block_empty_noop() {
802        let s = SectionBuilder::new("X", true).kv_block::<Vec<(&str, &str)>, _, _>(vec![]);
803        let c = s.into_component();
804        if let Component::Section { children, .. } = c {
805            assert!(children.is_empty());
806        } else {
807            panic!("expected Section");
808        }
809    }
810}