11//! Optional TOML config file that adds resolvers to the built-in list, or
22//! replaces the list entirely (e.g. to check propagation across your own
3- //! infrastructure, or an internal split-horizon zone).
3+ //! infrastructure, or an internal split-horizon zone), and recolors the UI
4+ //! via a `[theme]` table.
45//!
56//! Looked up at `$DNSGLOBE_CONFIG`, else `$XDG_CONFIG_HOME/dnsglobe/config.toml`,
67//! else `~/.config/dnsglobe/config.toml`. A missing default file just means
@@ -14,6 +15,7 @@ use serde::Deserialize;
1415
1516use crate :: app:: ViewMode ;
1617use crate :: resolvers:: { self , Resolver } ;
18+ use crate :: theme:: { self , Theme } ;
1719
1820#[ derive( Debug , Default , Deserialize ) ]
1921#[ serde( deny_unknown_fields) ]
@@ -25,9 +27,29 @@ pub struct Config {
2527 /// Preferred map panel style; the --view flag overrides it.
2628 view : Option < ViewMode > ,
2729 #[ serde( default ) ]
30+ theme : ThemeTable ,
31+ #[ serde( default ) ]
2832 resolvers : Vec < Entry > ,
2933}
3034
35+ /// Raw `[theme]` colors as written in the file; validated into a
36+ /// `theme::Theme` by `build_theme`. Every key is optional — unset roles keep
37+ /// their defaults, so a theme can adjust a single color.
38+ #[ derive( Debug , Default , Deserialize ) ]
39+ #[ serde( deny_unknown_fields) ]
40+ struct ThemeTable {
41+ accent : Option < String > ,
42+ agree : Option < String > ,
43+ differ : Option < String > ,
44+ error : Option < String > ,
45+ pending : Option < String > ,
46+ stale : Option < String > ,
47+ upstream : Option < String > ,
48+ muted : Option < String > ,
49+ coastline : Option < String > ,
50+ grid : Option < String > ,
51+ }
52+
3153#[ derive( Debug , Deserialize ) ]
3254#[ serde( deny_unknown_fields) ]
3355struct Entry {
@@ -46,13 +68,15 @@ struct Entry {
4668pub struct Settings {
4769 pub resolvers : Vec < Resolver > ,
4870 pub view : Option < ViewMode > ,
71+ pub theme : Theme ,
4972}
5073
5174impl Settings {
5275 fn defaults ( ) -> Self {
5376 Self {
5477 resolvers : resolvers:: defaults ( ) ,
5578 view : None ,
79+ theme : Theme :: default ( ) ,
5680 }
5781 }
5882}
@@ -76,12 +100,18 @@ pub fn load() -> Result<Settings> {
76100 return Err ( err) . with_context ( || format ! ( "reading config file {}" , path. display( ) ) ) ;
77101 }
78102 } ;
79- let config: Config =
103+ let mut config: Config =
80104 toml:: from_str ( & text) . with_context ( || format ! ( "parsing {}" , path. display( ) ) ) ?;
81105 let view = config. view ;
106+ let theme = build_theme ( std:: mem:: take ( & mut config. theme ) )
107+ . with_context ( || format ! ( "invalid config {}" , path. display( ) ) ) ?;
82108 let resolvers =
83109 resolver_list ( config) . with_context ( || format ! ( "invalid config {}" , path. display( ) ) ) ?;
84- Ok ( Settings { resolvers, view } )
110+ Ok ( Settings {
111+ resolvers,
112+ view,
113+ theme,
114+ } )
85115}
86116
87117fn default_path ( ) -> Option < PathBuf > {
@@ -92,6 +122,31 @@ fn default_path() -> Option<PathBuf> {
92122 Some ( base. join ( "dnsglobe" ) . join ( "config.toml" ) )
93123}
94124
125+ /// Overlay the config's `[theme]` colors on the defaults, erroring with the
126+ /// offending key so a typo'd color is easy to find in the file.
127+ fn build_theme ( table : ThemeTable ) -> Result < Theme > {
128+ let mut out = Theme :: default ( ) ;
129+ for ( key, value, slot) in [
130+ ( "accent" , table. accent , & mut out. accent ) ,
131+ ( "agree" , table. agree , & mut out. agree ) ,
132+ ( "differ" , table. differ , & mut out. differ ) ,
133+ ( "error" , table. error , & mut out. error ) ,
134+ ( "pending" , table. pending , & mut out. pending ) ,
135+ ( "stale" , table. stale , & mut out. stale ) ,
136+ ( "upstream" , table. upstream , & mut out. upstream ) ,
137+ ( "coastline" , table. coastline , & mut out. coastline ) ,
138+ ( "grid" , table. grid , & mut out. grid ) ,
139+ ] {
140+ if let Some ( value) = value {
141+ * slot = theme:: parse_color ( & value) . with_context ( || format ! ( "theme.{key}" ) ) ?;
142+ }
143+ }
144+ if let Some ( value) = table. muted {
145+ out. muted = theme:: parse_muted ( & value) . context ( "theme.muted" ) ?;
146+ }
147+ Ok ( out)
148+ }
149+
95150/// Validate the config and merge it with the built-in list.
96151fn resolver_list ( config : Config ) -> Result < Vec < Resolver > > {
97152 let mut list = if config. replace {
@@ -259,6 +314,48 @@ mod tests {
259314 assert ! ( toml:: from_str:: <Config >( "view = \" sphere\" " ) . is_err( ) ) ;
260315 }
261316
317+ fn theme ( toml_text : & str ) -> Result < Theme > {
318+ let config: Config = toml:: from_str ( toml_text) ?;
319+ build_theme ( config. theme )
320+ }
321+
322+ #[ test]
323+ fn missing_or_empty_theme_keeps_the_defaults ( ) {
324+ assert_eq ! ( theme( "" ) . unwrap( ) , Theme :: default ( ) ) ;
325+ assert_eq ! ( theme( "[theme]" ) . unwrap( ) , Theme :: default ( ) ) ;
326+ }
327+
328+ #[ test]
329+ fn theme_overrides_only_the_given_roles ( ) {
330+ let theme = theme (
331+ r##"
332+ [theme]
333+ accent = "#ff8700"
334+ muted = "darkgray"
335+ "## ,
336+ )
337+ . unwrap ( ) ;
338+ assert_eq ! ( theme. accent, ratatui:: style:: Color :: Rgb ( 0xff , 0x87 , 0x00 ) ) ;
339+ assert_eq ! (
340+ theme. muted,
341+ crate :: theme:: Muted :: Color ( ratatui:: style:: Color :: DarkGray )
342+ ) ;
343+ assert_eq ! ( theme. agree, Theme :: default ( ) . agree) ;
344+ }
345+
346+ #[ test]
347+ fn bad_theme_color_errors_with_the_key_name ( ) {
348+ let err = theme ( "[theme]\n stale = \" ornage\" " ) . unwrap_err ( ) ;
349+ let chain = format ! ( "{err:#}" ) ;
350+ assert ! ( chain. contains( "theme.stale" ) , "{chain}" ) ;
351+ assert ! ( chain. contains( "\" ornage\" " ) , "{chain}" ) ;
352+ }
353+
354+ #[ test]
355+ fn unknown_theme_keys_are_rejected_to_catch_typos ( ) {
356+ assert ! ( toml:: from_str:: <Config >( "[theme]\n accnt = \" red\" " ) . is_err( ) ) ;
357+ }
358+
262359 #[ test]
263360 fn unknown_keys_are_rejected_to_catch_typos ( ) {
264361 assert ! ( toml:: from_str:: <Config >( "replase = true" ) . is_err( ) ) ;
0 commit comments