Skip to main content

tmprl_core/
config.rs

1//! `keys.toml` and `views.toml`.
2//!
3//! Parsing lives here, in the crate with no IO, so a malformed config is a unit test rather
4//! than something you discover by launching the application. `tmprl-tui` reads the bytes off
5//! disk and hands them to these functions.
6//!
7//! Both loaders are *strict and additive*: an unknown command id or an unparseable chord is
8//! reported, not skipped silently. A keymap that quietly drops the line you just wrote is
9//! considerably worse than one that tells you the line is wrong.
10
11use crate::command::Registry;
12use crate::key::KeyParseError;
13use crate::keymap::Keymap;
14use crate::mode::Mode;
15
16#[derive(Debug, thiserror::Error, PartialEq, Eq)]
17pub enum ConfigError {
18    #[error("{file} is not valid TOML: {message}")]
19    Syntax { file: &'static str, message: String },
20    #[error("{file}: `{path}` should be {expected}")]
21    Type {
22        file: &'static str,
23        path: String,
24        expected: &'static str,
25    },
26    #[error("keys.toml: `{0}` is not a mode (expected normal, insert, visual, v-line or command)")]
27    UnknownMode(String),
28    #[error("keys.toml: `{chord}` is bound to `{command}`, which is not a command")]
29    UnknownCommand { chord: String, command: String },
30    #[error("keys.toml: `{chord}` is not a key sequence: {source}")]
31    BadChord {
32        chord: String,
33        #[source]
34        source: KeyParseError,
35    },
36    #[error("views.toml: view `{name}` has key `{key}`; keys must be a single digit 1-9")]
37    BadViewKey { name: String, key: String },
38    #[error("views.toml: two views claim key `{0}`")]
39    DuplicateViewKey(char),
40    #[error("config.toml: `{path}` is `{value}`, which is not a colour ({expected})")]
41    BadAccent {
42        path: String,
43        value: String,
44        expected: &'static str,
45    },
46}
47
48/// A saved visibility query, reachable from a key.
49///
50/// The query is stored verbatim. A saved view sets the query bar's contents and nothing
51/// else, it is a bookmark, not a mode, so after selecting one the text is still right there
52/// to edit.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct SavedView {
55    /// `1`–`9`. Views are reached with the leader key, because a bare digit in Normal mode
56    /// is the start of a count.
57    pub key: char,
58    pub name: String,
59    pub query: String,
60}
61
62/// Parse `views.toml`:
63///
64/// ```toml
65/// [[view]]
66/// key   = "1"
67/// name  = "Running"
68/// query = "ExecutionStatus = 'Running'"
69/// ```
70pub fn parse_views(src: &str) -> Result<Vec<SavedView>, ConfigError> {
71    const FILE: &str = "views.toml";
72    let table: toml::Table = toml::from_str(src).map_err(|e| ConfigError::Syntax {
73        file: FILE,
74        message: e.message().to_string(),
75    })?;
76
77    let Some(raw) = table.get("view") else {
78        return Ok(Vec::new());
79    };
80    let entries = raw.as_array().ok_or(ConfigError::Type {
81        file: FILE,
82        path: "view".into(),
83        expected: "an array of [[view]] tables",
84    })?;
85
86    let mut views: Vec<SavedView> = Vec::new();
87    for (i, entry) in entries.iter().enumerate() {
88        let t = entry.as_table().ok_or_else(|| ConfigError::Type {
89            file: FILE,
90            path: format!("view[{i}]"),
91            expected: "a table",
92        })?;
93        let field = |name: &str| -> Result<String, ConfigError> {
94            t.get(name)
95                .and_then(|v| v.as_str())
96                .map(str::to_string)
97                .ok_or_else(|| ConfigError::Type {
98                    file: FILE,
99                    path: format!("view[{i}].{name}"),
100                    expected: "a string",
101                })
102        };
103
104        let name = field("name")?;
105        let key = field("key")?;
106        let mut chars = key.chars();
107        let key = match (chars.next(), chars.next()) {
108            (Some(c @ '1'..='9'), None) => c,
109            _ => return Err(ConfigError::BadViewKey { name, key }),
110        };
111        if views.iter().any(|v| v.key == key) {
112            return Err(ConfigError::DuplicateViewKey(key));
113        }
114        views.push(SavedView {
115            key,
116            name,
117            query: field("query")?,
118        });
119    }
120
121    views.sort_by_key(|v| v.key);
122    Ok(views)
123}
124
125/// Where the codec server lives, if the cluster uses one.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct CodecConfig {
128    /// Base URL. `/decode` is appended to it, per Temporal's contract.
129    pub endpoint: String,
130    /// Sent verbatim as `Authorization`. Optional, and deliberately *not* defaulted from
131    /// anything: a codec server is a service the user runs, and quietly forwarding a
132    /// credential they did not ask us to send would be a surprise.
133    pub auth: Option<String>,
134}
135
136/// A colour name for a profile's accent.
137///
138/// Named rather than a hex triple: this has to read on a 16-colour terminal, and the point
139/// is that production is unmistakable, not that it matches anyone's palette.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum Accent {
142    Red,
143    Green,
144    Yellow,
145    Blue,
146    Magenta,
147    Cyan,
148}
149
150impl Accent {
151    pub const NAMES: &'static str = "red, green, yellow, blue, magenta or cyan";
152
153    pub fn parse(s: &str) -> Option<Self> {
154        Some(match s {
155            "red" => Self::Red,
156            "green" => Self::Green,
157            "yellow" => Self::Yellow,
158            "blue" => Self::Blue,
159            "magenta" => Self::Magenta,
160            "cyan" => Self::Cyan,
161            _ => return None,
162        })
163    }
164}
165
166/// Per-profile settings, keyed by the profile name in `temporal.toml`.
167#[derive(Debug, Clone, Default, PartialEq, Eq)]
168pub struct ProfileConfig {
169    /// Colour for the profile name in the statusline.
170    pub accent: Option<Accent>,
171    /// Refuse every mutation on this profile.
172    pub readonly: bool,
173    /// Overrides the top-level codec for this profile.
174    pub codec: Option<CodecConfig>,
175}
176
177/// What applies to the profile actually connected, after falling back to the globals.
178#[derive(Debug, Clone, Default, PartialEq, Eq)]
179pub struct Resolved {
180    pub accent: Option<Accent>,
181    pub readonly: bool,
182    pub codec: Option<CodecConfig>,
183}
184
185/// `config.toml`. Everything in it is optional.
186#[derive(Debug, Clone, Default, PartialEq, Eq)]
187pub struct Config {
188    /// The codec used by any profile that does not name its own.
189    pub codec: Option<CodecConfig>,
190    pub profiles: Vec<(String, ProfileConfig)>,
191}
192
193impl Config {
194    /// Settings for one profile. A profile with no section is not an error: it simply gets
195    /// the globals, which is what every single-cluster user has today.
196    pub fn resolve(&self, profile: &str) -> Resolved {
197        let found = self.profiles.iter().find(|(name, _)| name == profile);
198        match found {
199            None => Resolved {
200                accent: None,
201                readonly: false,
202                codec: self.codec.clone(),
203            },
204            Some((_, p)) => Resolved {
205                accent: p.accent,
206                readonly: p.readonly,
207                // A profile that names no codec uses the global one; pointing production at
208                // the codec you set up for SIT is exactly the mistake worth preventing, but
209                // a single-cluster config must keep working unchanged.
210                codec: p.codec.clone().or_else(|| self.codec.clone()),
211            },
212        }
213    }
214}
215
216/// Parse `config.toml`:
217///
218/// ```toml
219/// [codec]
220/// endpoint = "http://localhost:8081"
221/// auth     = "Bearer …"          # optional
222///
223/// [profile.prod]                 # keyed by the profile in temporal.toml
224/// accent   = "red"
225/// readonly = true
226///
227/// [profile.prod.codec]           # overrides the codec above, for this profile only
228/// endpoint = "https://codec.internal"
229/// ```
230pub fn parse_config(src: &str) -> Result<Config, ConfigError> {
231    const FILE: &str = "config.toml";
232    let table: toml::Table = toml::from_str(src).map_err(|e| ConfigError::Syntax {
233        file: FILE,
234        message: e.message().to_string(),
235    })?;
236
237    let codec = match table.get("codec") {
238        None => None,
239        Some(raw) => Some(parse_codec(raw, "codec")?),
240    };
241
242    let profiles = match table.get("profile") {
243        None => Vec::new(),
244        Some(raw) => {
245            let table = raw.as_table().ok_or(ConfigError::Type {
246                file: FILE,
247                path: "profile".into(),
248                expected: "a table",
249            })?;
250            let mut out = Vec::with_capacity(table.len());
251            for (name, raw) in table {
252                out.push((name.clone(), parse_profile(raw, name)?));
253            }
254            out
255        }
256    };
257
258    Ok(Config { codec, profiles })
259}
260
261fn parse_profile(raw: &toml::Value, name: &str) -> Result<ProfileConfig, ConfigError> {
262    const FILE: &str = "config.toml";
263    let table = raw.as_table().ok_or_else(|| ConfigError::Type {
264        file: FILE,
265        path: format!("profile.{name}"),
266        expected: "a table",
267    })?;
268
269    let accent = match table.get("accent") {
270        None => None,
271        Some(v) => {
272            let text = v.as_str().ok_or_else(|| ConfigError::Type {
273                file: FILE,
274                path: format!("profile.{name}.accent"),
275                expected: "a string",
276            })?;
277            Some(Accent::parse(text).ok_or_else(|| ConfigError::BadAccent {
278                path: format!("profile.{name}.accent"),
279                value: text.to_string(),
280                expected: Accent::NAMES,
281            })?)
282        }
283    };
284
285    let readonly = match table.get("readonly") {
286        None => false,
287        Some(v) => v.as_bool().ok_or_else(|| ConfigError::Type {
288            file: FILE,
289            path: format!("profile.{name}.readonly"),
290            expected: "true or false",
291        })?,
292    };
293
294    let codec = match table.get("codec") {
295        None => None,
296        Some(raw) => Some(parse_codec(raw, &format!("profile.{name}.codec"))?),
297    };
298
299    Ok(ProfileConfig {
300        accent,
301        readonly,
302        codec,
303    })
304}
305
306fn parse_codec(raw: &toml::Value, path: &str) -> Result<CodecConfig, ConfigError> {
307    const FILE: &str = "config.toml";
308    let codec = raw.as_table().ok_or_else(|| ConfigError::Type {
309        file: FILE,
310        path: path.to_string(),
311        expected: "a table",
312    })?;
313
314    let endpoint = codec
315        .get("endpoint")
316        .and_then(|v| v.as_str())
317        .ok_or_else(|| ConfigError::Type {
318            file: FILE,
319            path: format!("{path}.endpoint"),
320            expected: "a string",
321        })?
322        .trim_end_matches('/')
323        .to_string();
324    if endpoint.is_empty() {
325        return Err(ConfigError::Type {
326            file: FILE,
327            path: format!("{path}.endpoint"),
328            expected: "a non-empty URL",
329        });
330    }
331
332    let auth = match codec.get("auth") {
333        None => None,
334        Some(v) => Some(
335            v.as_str()
336                .ok_or_else(|| ConfigError::Type {
337                    file: FILE,
338                    path: format!("{path}.auth"),
339                    expected: "a string",
340                })?
341                .to_string(),
342        ),
343    };
344
345    Ok(CodecConfig { endpoint, auth })
346}
347
348/// Apply `keys.toml` on top of a keymap:
349///
350/// ```toml
351/// [normal]
352/// "<leader>w" = "nav.open"
353/// "ZZ"        = "app.quit"
354///
355/// [insert]
356/// "jj" = "mode.normal"
357/// ```
358///
359/// Later bindings win, and this runs after the defaults, so a user binding overrides the
360/// built-in one for the same chord in the same mode.
361///
362/// Command ids are resolved against `registry`, which is what lets a `String` from a config
363/// file become the `&'static str` the keymap stores, and what makes a typo an error at
364/// startup instead of a key that silently does nothing.
365pub fn apply_keys(src: &str, registry: &Registry, keymap: &mut Keymap) -> Result<(), ConfigError> {
366    const FILE: &str = "keys.toml";
367    let table: toml::Table = toml::from_str(src).map_err(|e| ConfigError::Syntax {
368        file: FILE,
369        message: e.message().to_string(),
370    })?;
371
372    for (mode_name, bindings) in &table {
373        let mode = parse_mode(mode_name)?;
374        let bindings = bindings.as_table().ok_or_else(|| ConfigError::Type {
375            file: FILE,
376            path: mode_name.clone(),
377            expected: "a table of \"chord\" = \"command.id\"",
378        })?;
379
380        for (chord, command) in bindings {
381            let command = command.as_str().ok_or_else(|| ConfigError::Type {
382                file: FILE,
383                path: format!("{mode_name}.{chord}"),
384                expected: "a command id string",
385            })?;
386            // Resolving through the registry is what turns the config's String into the
387            // 'static id the keymap holds.
388            let id = registry
389                .get(command)
390                .ok_or_else(|| ConfigError::UnknownCommand {
391                    chord: chord.clone(),
392                    command: command.to_string(),
393                })?
394                .id;
395            keymap
396                .bind(mode, chord, id)
397                .map_err(|source| ConfigError::BadChord {
398                    chord: chord.clone(),
399                    source,
400                })?;
401        }
402    }
403    Ok(())
404}
405
406/// Bind each saved view to `<leader>{digit}`.
407///
408/// Not to the bare digit the interface design originally called for: a leading digit in
409/// Normal mode is a count (`7j`), and counts are load-bearing. `<leader>1` keeps both, and
410/// puts the views in the which-key popup under the leader where they are discoverable.
411///
412/// Only views that actually exist get a binding, so the popup never advertises an empty
413/// slot. Call [`Registry::add_views`] first, the commands must exist to be bound.
414pub fn bind_views(views: &[SavedView], keymap: &mut Keymap) -> Result<(), ConfigError> {
415    for v in views {
416        let seq = format!("<leader>{}", v.key);
417        let id: &'static str = Box::leak(format!("view.{}", v.key).into_boxed_str());
418        keymap
419            .bind(Mode::Normal, &seq, id)
420            .map_err(|source| ConfigError::BadChord { chord: seq, source })?;
421    }
422    Ok(())
423}
424
425fn parse_mode(name: &str) -> Result<Mode, ConfigError> {
426    Ok(match name.trim().to_ascii_lowercase().as_str() {
427        "normal" => Mode::Normal,
428        "insert" => Mode::Insert,
429        "visual" => Mode::Visual,
430        "v-line" | "visual-line" | "visualline" => Mode::VisualLine,
431        "command" => Mode::Command,
432        _ => return Err(ConfigError::UnknownMode(name.to_string())),
433    })
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::key::Chord;
440    use crate::keymap::{Pending, Resolution, default_keymap};
441
442    #[test]
443    fn views_parse_in_key_order() {
444        let views = parse_views(
445            r#"
446            [[view]]
447            key = "3"
448            name = "Failed"
449            query = "ExecutionStatus = 'Failed'"
450
451            [[view]]
452            key = "1"
453            name = "Running"
454            query = "ExecutionStatus = 'Running'"
455            "#,
456        )
457        .unwrap();
458
459        assert_eq!(views.len(), 2);
460        assert_eq!(views[0].key, '1');
461        assert_eq!(views[0].name, "Running");
462        assert_eq!(views[1].key, '3');
463        assert_eq!(views[1].query, "ExecutionStatus = 'Failed'");
464    }
465
466    #[test]
467    fn an_absent_or_empty_views_file_is_not_an_error() {
468        assert_eq!(parse_views("").unwrap(), Vec::new());
469        assert_eq!(parse_views("# nothing here\n").unwrap(), Vec::new());
470    }
471
472    #[test]
473    fn a_view_key_must_be_a_single_digit() {
474        for key in ["0", "10", "a", ""] {
475            let src = format!("[[view]]\nkey = \"{key}\"\nname = \"N\"\nquery = \"\"\n");
476            assert!(
477                matches!(parse_views(&src), Err(ConfigError::BadViewKey { .. })),
478                "key {key:?} should be rejected"
479            );
480        }
481    }
482
483    #[test]
484    fn two_views_cannot_claim_the_same_key() {
485        let src = r#"
486            [[view]]
487            key = "1"
488            name = "A"
489            query = ""
490            [[view]]
491            key = "1"
492            name = "B"
493            query = ""
494        "#;
495        assert_eq!(parse_views(src), Err(ConfigError::DuplicateViewKey('1')));
496    }
497
498    #[test]
499    fn a_view_missing_a_field_says_which_one() {
500        let err = parse_views("[[view]]\nkey = \"1\"\n").unwrap_err();
501        assert!(
502            err.to_string().contains("view[0].name"),
503            "error should name the missing field, got: {err}"
504        );
505    }
506
507    #[test]
508    fn malformed_toml_is_reported_not_ignored() {
509        assert!(matches!(
510            parse_views("[[view]\nkey =").unwrap_err(),
511            ConfigError::Syntax { .. }
512        ));
513    }
514
515    #[test]
516    fn saved_views_bind_under_the_leader_not_the_bare_digit() {
517        // A bare `1` starts a count, and counts compose with every motion. Binding views
518        // to bare digits would break `7j`, which is not a trade worth making.
519        let mut registry = Registry::builtin();
520        let views = vec![SavedView {
521            key: '1',
522            name: "Running".into(),
523            query: "ExecutionStatus = 'Running'".into(),
524        }];
525        registry.add_views(&views);
526        let mut keymap = default_keymap();
527        bind_views(&views, &mut keymap).unwrap();
528
529        let mut p = Pending::default();
530        assert_eq!(
531            keymap.resolve(Mode::Normal, &mut p, Chord::ch('1')),
532            Resolution::Count(1),
533            "a bare digit must still start a count"
534        );
535        p.clear();
536
537        assert!(matches!(
538            keymap.resolve(Mode::Normal, &mut p, Chord::ch(' ')),
539            Resolution::Pending { .. }
540        ));
541        assert_eq!(
542            keymap.resolve(Mode::Normal, &mut p, Chord::ch('1')),
543            Resolution::Run {
544                id: "view.1",
545                count: None
546            }
547        );
548    }
549
550    #[test]
551    fn an_unconfigured_view_slot_is_left_unbound() {
552        // Which-key and the help overlay are generated from the keymap, so a binding for a
553        // view that does not exist would be a lie rendered on screen.
554        let mut keymap = default_keymap();
555        bind_views(&[], &mut keymap).unwrap();
556        let mut p = Pending::default();
557        keymap.resolve(Mode::Normal, &mut p, Chord::ch(' '));
558        match keymap.resolve(Mode::Normal, &mut p, Chord::ch('4')) {
559            Resolution::Unbound { .. } => {}
560            other => panic!("<leader>4 should be unbound, got {other:?}"),
561        }
562    }
563
564    #[test]
565    fn a_codec_endpoint_is_read_and_normalised() {
566        let c = parse_config(
567            r#"
568            [codec]
569            endpoint = "http://localhost:8081/"
570            auth = "Bearer abc"
571            "#,
572        )
573        .unwrap();
574        let codec = c.codec.unwrap();
575        // The trailing slash goes, because `/decode` is appended and `//decode` is not the
576        // same path to every server.
577        assert_eq!(codec.endpoint, "http://localhost:8081");
578        assert_eq!(codec.auth.as_deref(), Some("Bearer abc"));
579    }
580
581    #[test]
582    fn auth_is_optional_and_never_invented() {
583        let c = parse_config("[codec]\nendpoint = \"http://x\"\n").unwrap();
584        assert_eq!(c.codec.unwrap().auth, None);
585    }
586
587    #[test]
588    fn no_codec_section_means_no_codec() {
589        assert_eq!(parse_config("").unwrap(), Config::default());
590        assert_eq!(parse_config("# nothing\n").unwrap().codec, None);
591    }
592
593    #[test]
594    fn a_codec_section_without_an_endpoint_is_an_error() {
595        // Silently ignoring it would leave encrypted payloads unreadable with no clue why.
596        let err = parse_config("[codec]\nauth = \"x\"\n").unwrap_err();
597        assert!(err.to_string().contains("codec.endpoint"), "got {err}");
598
599        let err = parse_config("[codec]\nendpoint = \"\"\n").unwrap_err();
600        assert!(err.to_string().contains("codec.endpoint"), "got {err}");
601    }
602
603    #[test]
604    fn keys_toml_overrides_a_default_binding() {
605        let registry = Registry::builtin();
606        let mut keymap = default_keymap();
607
608        apply_keys("[normal]\n\"j\" = \"motion.up\"\n", &registry, &mut keymap).unwrap();
609
610        let mut p = Pending::default();
611        assert_eq!(
612            keymap.resolve(Mode::Normal, &mut p, Chord::ch('j')),
613            Resolution::Run {
614                id: "motion.up",
615                count: None
616            },
617            "a user binding must win over the built-in one"
618        );
619    }
620
621    #[test]
622    fn keys_toml_adds_a_new_sequence() {
623        let registry = Registry::builtin();
624        let mut keymap = default_keymap();
625        apply_keys("[normal]\n\"ZZ\" = \"app.quit\"\n", &registry, &mut keymap).unwrap();
626
627        let mut p = Pending::default();
628        assert!(matches!(
629            keymap.resolve(Mode::Normal, &mut p, Chord::ch('Z')),
630            Resolution::Pending { .. }
631        ));
632        assert_eq!(
633            keymap.resolve(Mode::Normal, &mut p, Chord::ch('Z')),
634            Resolution::Run {
635                id: "app.quit",
636                count: None
637            }
638        );
639    }
640
641    #[test]
642    fn every_mode_name_is_accepted() {
643        let registry = Registry::builtin();
644        let mut keymap = default_keymap();
645        let src = r#"
646            [normal]
647            "<F5>" = "app.refresh"
648            [insert]
649            "<F5>" = "mode.normal"
650            [visual]
651            "<F5>" = "app.cancel"
652            [v-line]
653            "<F5>" = "app.cancel"
654            [command]
655            "<F5>" = "app.cancel"
656        "#;
657        assert_eq!(apply_keys(src, &registry, &mut keymap), Ok(()));
658    }
659
660    #[test]
661    fn an_unknown_command_is_an_error_rather_than_a_dead_key() {
662        // This is the whole reason the loader resolves through the registry. A silently
663        // dropped binding is a key that does nothing, with no way to find out why.
664        let registry = Registry::builtin();
665        let mut keymap = default_keymap();
666        let err = apply_keys(
667            "[normal]\n\"x\" = \"motion.sideways\"\n",
668            &registry,
669            &mut keymap,
670        )
671        .unwrap_err();
672        assert_eq!(
673            err,
674            ConfigError::UnknownCommand {
675                chord: "x".into(),
676                command: "motion.sideways".into()
677            }
678        );
679        assert!(err.to_string().contains("motion.sideways"));
680    }
681
682    #[test]
683    fn an_unknown_mode_is_an_error() {
684        let registry = Registry::builtin();
685        let mut keymap = default_keymap();
686        assert_eq!(
687            apply_keys("[sideways]\n\"x\" = \"app.quit\"\n", &registry, &mut keymap),
688            Err(ConfigError::UnknownMode("sideways".into()))
689        );
690    }
691
692    #[test]
693    fn an_unparseable_chord_names_itself() {
694        let registry = Registry::builtin();
695        let mut keymap = default_keymap();
696        let err = apply_keys(
697            "[normal]\n\"<Nope>\" = \"app.quit\"\n",
698            &registry,
699            &mut keymap,
700        )
701        .unwrap_err();
702        assert!(
703            matches!(err, ConfigError::BadChord { ref chord, .. } if chord == "<Nope>"),
704            "got {err}"
705        );
706    }
707
708    #[test]
709    fn an_empty_keys_file_leaves_the_defaults_alone() {
710        let registry = Registry::builtin();
711        let mut keymap = default_keymap();
712        let before = keymap.bindings().len();
713        apply_keys("", &registry, &mut keymap).unwrap();
714        assert_eq!(keymap.bindings().len(), before);
715    }
716
717    #[test]
718    fn a_config_with_no_profile_section_gives_every_profile_the_globals() {
719        // The single-cluster config that exists today must keep working untouched.
720        let cfg = parse_config("[codec]\nendpoint = \"http://localhost:8081\"").unwrap();
721        let r = cfg.resolve("anything");
722        assert_eq!(r.codec.unwrap().endpoint, "http://localhost:8081");
723        assert!(!r.readonly);
724        assert_eq!(r.accent, None);
725    }
726
727    #[test]
728    fn a_profile_codec_overrides_the_global_one() {
729        let cfg = parse_config(
730            r#"
731[codec]
732endpoint = "http://localhost:8081"
733
734[profile.prod.codec]
735endpoint = "https://codec.internal"
736"#,
737        )
738        .unwrap();
739        assert_eq!(
740            cfg.resolve("prod").codec.unwrap().endpoint,
741            "https://codec.internal"
742        );
743        // A profile that names no codec still falls back, rather than losing decoding.
744        assert_eq!(
745            cfg.resolve("sit").codec.unwrap().endpoint,
746            "http://localhost:8081"
747        );
748    }
749
750    #[test]
751    fn a_profile_carries_its_accent_and_readonly_flag() {
752        let cfg = parse_config(
753            r#"
754[profile.prod]
755accent   = "red"
756readonly = true
757
758[profile.sit]
759accent = "green"
760"#,
761        )
762        .unwrap();
763        let prod = cfg.resolve("prod");
764        assert_eq!(prod.accent, Some(Accent::Red));
765        assert!(prod.readonly);
766
767        let sit = cfg.resolve("sit");
768        assert_eq!(sit.accent, Some(Accent::Green));
769        assert!(!sit.readonly, "readonly must not leak between profiles");
770    }
771
772    #[test]
773    fn an_unknown_accent_is_reported_rather_than_ignored() {
774        // Silently dropping it would leave production painted like everything else, which
775        // is the exact failure the accent exists to prevent.
776        let err = parse_config("[profile.prod]\naccent = \"crimson\"").unwrap_err();
777        assert!(matches!(err, ConfigError::BadAccent { .. }), "{err:?}");
778        assert!(err.to_string().contains("crimson"), "{err}");
779    }
780
781    #[test]
782    fn readonly_must_be_a_boolean() {
783        let err = parse_config("[profile.prod]\nreadonly = \"yes\"").unwrap_err();
784        assert!(matches!(err, ConfigError::Type { .. }), "{err:?}");
785    }
786}