1use std::time::Duration;
2
3use serde::Serialize;
4
5use super::Role;
6use super::component::{Component, KvPair, StatusLabel};
7use super::renderer::Table;
8
9#[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 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
47pub struct Doc {
49 pub(crate) heading: Option<String>,
50 pub(crate) children: Vec<Component>,
51 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 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 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, 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, 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 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 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
210pub 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 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, 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 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 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"); 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 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}