Skip to main content

cfgd_core/output/
component.rs

1use serde::Serialize;
2
3use super::Role;
4
5/// A node in a Doc's component tree. Streaming output does not produce these
6/// (it pushes directly to the renderer); only `Doc` and `SectionBuilder` do.
7#[derive(Debug, Clone, Serialize)]
8#[serde(tag = "type", rename_all = "snake_case")]
9pub enum Component {
10    Heading {
11        text: String,
12    },
13    KvBlock {
14        pairs: Vec<KvPair>,
15    },
16    Bullet {
17        text: String,
18    },
19    Status {
20        role: Role,
21        subject: String,
22        #[serde(skip_serializing_if = "Option::is_none")]
23        detail: Option<String>,
24        #[serde(skip_serializing_if = "Option::is_none")]
25        duration_ms: Option<u128>,
26        #[serde(skip_serializing_if = "Option::is_none")]
27        target: Option<String>,
28        /// Trailing styled label (e.g. `[source-name]`). Rendered at the END of
29        /// the subject by `render_doc` so the inner SGR reset can never be
30        /// followed by outer-role-styled text — enforces the at-end layout that
31        /// nested ANSI styling requires.
32        #[serde(skip_serializing_if = "Option::is_none")]
33        label: Option<StatusLabel>,
34    },
35    Hint {
36        text: String,
37    },
38    Note {
39        text: String,
40    },
41    Table {
42        headers: Vec<String>,
43        rows: Vec<Vec<String>>,
44        /// Per-cell role tags, parallel to `rows`. Skipped from JSON when all
45        /// cells are plain — keeps the structured-output shape stable for
46        /// consumers that don't care about presentation styling.
47        #[serde(default, skip_serializing_if = "Vec::is_empty")]
48        row_roles: Vec<Vec<Option<Role>>>,
49    },
50    Section {
51        name: String,
52        /// True for `section`; false for `section_or_collapse`.
53        keep_when_empty: bool,
54        /// Set when the user provided an explicit `empty_state(...)`.
55        empty_state: Option<String>,
56        children: Vec<Component>,
57    },
58}
59
60#[derive(Debug, Clone, Serialize)]
61pub struct StatusLabel {
62    pub role: Role,
63    pub text: String,
64}
65
66#[derive(Debug, Clone, Serialize)]
67pub struct KvPair {
68    pub key: String,
69    pub value: String,
70}
71
72impl KvPair {
73    pub fn new(k: impl Into<String>, v: impl Into<String>) -> Self {
74        Self {
75            key: k.into(),
76            value: v.into(),
77        }
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn heading_serializes_with_type_tag() {
87        let c = Component::Heading {
88            text: "Status".into(),
89        };
90        let json = serde_json::to_value(&c).unwrap();
91        assert_eq!(json["type"], "heading");
92        assert_eq!(json["text"], "Status");
93    }
94
95    #[test]
96    fn status_omits_optional_fields_when_unset() {
97        let c = Component::Status {
98            role: Role::Ok,
99            subject: "ok".into(),
100            detail: None,
101            duration_ms: None,
102            target: None,
103            label: None,
104        };
105        let json = serde_json::to_value(&c).unwrap();
106        assert!(json.get("detail").is_none());
107        assert!(json.get("duration_ms").is_none());
108        assert!(json.get("target").is_none());
109        assert!(json.get("label").is_none());
110        assert_eq!(json["role"], "ok");
111    }
112
113    #[test]
114    fn status_label_serializes_with_role_and_text() {
115        let c = Component::Status {
116            role: Role::Ok,
117            subject: "ok".into(),
118            detail: None,
119            duration_ms: None,
120            target: None,
121            label: Some(StatusLabel {
122                role: Role::Secondary,
123                text: "[team-config]".into(),
124            }),
125        };
126        let json = serde_json::to_value(&c).unwrap();
127        let label = json.get("label").expect("label must serialize when set");
128        assert_eq!(label["role"], "secondary");
129        assert_eq!(label["text"], "[team-config]");
130    }
131
132    #[test]
133    fn section_keep_when_empty_distinguishes_variants() {
134        let plain = Component::Section {
135            name: "X".into(),
136            keep_when_empty: true,
137            empty_state: None,
138            children: vec![],
139        };
140        let collapse = Component::Section {
141            name: "X".into(),
142            keep_when_empty: false,
143            empty_state: None,
144            children: vec![],
145        };
146        let p = serde_json::to_value(&plain).unwrap();
147        let c = serde_json::to_value(&collapse).unwrap();
148        assert_eq!(p["keep_when_empty"], true);
149        assert_eq!(c["keep_when_empty"], false);
150    }
151}