Skip to main content

cfgd_core/config/
theme.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize)]
4#[serde(rename_all = "camelCase")]
5pub struct ThemeConfig {
6    #[serde(default = "default_theme_name")]
7    pub name: String,
8    #[serde(default, skip_serializing_if = "ThemeOverrides::is_empty")]
9    pub overrides: ThemeOverrides,
10}
11
12fn default_theme_name() -> String {
13    "default".to_string()
14}
15
16impl Default for ThemeConfig {
17    fn default() -> Self {
18        Self {
19            name: default_theme_name(),
20            overrides: ThemeOverrides::default(),
21        }
22    }
23}
24
25// Accept both `theme: "dracula"` (string) and `theme: { name: dracula, overrides: ... }` (struct)
26impl<'de> serde::Deserialize<'de> for ThemeConfig {
27    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
28    where
29        D: serde::Deserializer<'de>,
30    {
31        use serde::de;
32
33        struct ThemeVisitor;
34        impl<'de> de::Visitor<'de> for ThemeVisitor {
35            type Value = ThemeConfig;
36            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
37                f.write_str("a theme name string or a theme config mapping")
38            }
39            fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<ThemeConfig, E> {
40                Ok(ThemeConfig {
41                    name: v.to_string(),
42                    overrides: ThemeOverrides::default(),
43                })
44            }
45            fn visit_map<M: de::MapAccess<'de>>(
46                self,
47                map: M,
48            ) -> std::result::Result<ThemeConfig, M::Error> {
49                #[derive(Deserialize)]
50                #[serde(rename_all = "camelCase")]
51                struct Inner {
52                    #[serde(default = "default_theme_name")]
53                    name: String,
54                    #[serde(default)]
55                    overrides: ThemeOverrides,
56                }
57                let inner = Inner::deserialize(de::value::MapAccessDeserializer::new(map))?;
58                Ok(ThemeConfig {
59                    name: inner.name,
60                    overrides: inner.overrides,
61                })
62            }
63        }
64        deserializer.deserialize_any(ThemeVisitor)
65    }
66}
67
68// no deny_unknown_fields — legacy theme keys (`subheader`, `iconSuccess`, etc.)
69// are deliberately ignored at the typed-deserialize layer so old configs keep
70// parsing; `parse::warn_on_legacy_theme_keys` surfaces them as `tracing::warn!`
71// so users see their override did nothing and can migrate cleanly.
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct ThemeOverrides {
75    // Style overrides (12) — hex colors applied on top of the active preset.
76    pub header: Option<String>,
77    pub success: Option<String>,
78    pub warning: Option<String>,
79    pub error: Option<String>,
80    pub info: Option<String>,
81    pub muted: Option<String>,
82    pub running: Option<String>,
83    pub diff_add: Option<String>,
84    pub diff_remove: Option<String>,
85    pub diff_context: Option<String>,
86    pub accent: Option<String>,
87    pub secondary: Option<String>,
88
89    // Icon overrides (7) — single glyphs (or short strings) for status roles.
90    pub icon_ok: Option<String>,
91    pub icon_warn: Option<String>,
92    pub icon_fail: Option<String>,
93    pub icon_pending: Option<String>,
94    pub icon_running: Option<String>,
95    pub icon_skipped: Option<String>,
96    pub icon_arrow: Option<String>,
97}
98
99impl ThemeOverrides {
100    pub fn is_empty(&self) -> bool {
101        self.header.is_none()
102            && self.success.is_none()
103            && self.warning.is_none()
104            && self.error.is_none()
105            && self.info.is_none()
106            && self.muted.is_none()
107            && self.running.is_none()
108            && self.diff_add.is_none()
109            && self.diff_remove.is_none()
110            && self.diff_context.is_none()
111            && self.accent.is_none()
112            && self.secondary.is_none()
113            && self.icon_ok.is_none()
114            && self.icon_warn.is_none()
115            && self.icon_fail.is_none()
116            && self.icon_pending.is_none()
117            && self.icon_running.is_none()
118            && self.icon_skipped.is_none()
119            && self.icon_arrow.is_none()
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn default_theme_config_uses_default_name() {
129        let tc = ThemeConfig::default();
130        assert_eq!(tc.name, "default");
131        assert!(tc.overrides.is_empty());
132    }
133
134    #[test]
135    fn deserialize_string_shorthand() {
136        let tc: ThemeConfig = serde_yaml::from_str("\"dracula\"").unwrap();
137        assert_eq!(tc.name, "dracula");
138        assert!(tc.overrides.is_empty());
139    }
140
141    #[test]
142    fn deserialize_map_with_name_only() {
143        let tc: ThemeConfig = serde_yaml::from_str("name: monokai").unwrap();
144        assert_eq!(tc.name, "monokai");
145        assert!(tc.overrides.is_empty());
146    }
147
148    #[test]
149    fn deserialize_map_with_overrides() {
150        let yaml = r##"
151name: custom
152overrides:
153  header: "#ff0000"
154  iconOk: "Y"
155"##;
156        let tc: ThemeConfig = serde_yaml::from_str(yaml).unwrap();
157        assert_eq!(tc.name, "custom");
158        assert_eq!(tc.overrides.header.as_deref(), Some("#ff0000"));
159        assert_eq!(tc.overrides.icon_ok.as_deref(), Some("Y"));
160        assert!(!tc.overrides.is_empty());
161    }
162
163    #[test]
164    fn deserialize_map_defaults_name_when_omitted() {
165        let tc: ThemeConfig = serde_yaml::from_str("overrides: {}").unwrap();
166        assert_eq!(tc.name, "default");
167    }
168
169    #[test]
170    fn overrides_is_empty_when_default() {
171        let o = ThemeOverrides::default();
172        assert!(o.is_empty());
173    }
174
175    #[test]
176    fn overrides_not_empty_when_any_field_set() {
177        let o = ThemeOverrides {
178            error: Some("#f00".to_string()),
179            ..ThemeOverrides::default()
180        };
181        assert!(!o.is_empty());
182    }
183}