Skip to main content

shep_core/config/
dogs.rs

1//! `$SHEP_HOME/dogs.toml`, a dog's own settings.
2//!
3//! One table per dog, keyed by the name the dog was registered under, with
4//! no prefix: `[metrics]` here is what `[dog.metrics]` was in `shep.toml`
5//! before the move. The daemon serves a section verbatim over the socket as
6//! `Response::DogSection` and never interprets it, so this type parses
7//! exactly far enough to find the right table and no further.
8//!
9//! Hand-editable, not a locked shep-owned store like `overrides.json`. A
10//! dog's config is authored intent, not derived state, and an operator on
11//! a box with only a shell has to be able to set one without a dashboard.
12
13use core::fmt;
14use std::collections::BTreeMap;
15
16/// Every `[<dog>]` table in `dogs.toml`.
17///
18/// `Debug` is redacted: a dog section routinely carries a webhook URL
19/// with a bearer token in it, and this type exists to be logged near the
20/// boot path.
21#[derive(Clone, Default, PartialEq)]
22pub struct DogsConfig {
23    /// Raw `[<name>]` tables keyed by dog name
24    pub dog: BTreeMap<String, toml::Table>,
25}
26
27impl fmt::Debug for DogsConfig {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        f.debug_struct("DogsConfig")
30            .field("dog", &format_args!("<{} tables>", self.dog.len()))
31            .finish()
32    }
33}
34
35impl DogsConfig {
36    /// Parses `dogs.toml`, or answers empty when there is no file
37    ///
38    /// # Errors
39    ///
40    /// - [`DogsConfigError::Toml`] when `source` is not valid TOML.
41    pub fn load(source: Option<&str>) -> Result<Self, DogsConfigError> {
42        let Some(source) = source else {
43            return Ok(Self::default());
44        };
45        let dog = toml::from_str(source).map_err(DogsConfigError::Toml)?;
46        Ok(Self { dog })
47    }
48}
49
50/// Why `dogs.toml` could not be read
51// One variant today. `#[non_exhaustive]` so a second reading failure (a
52// permissions error, once this type learns to open the file itself) is
53// additive rather than breaking.
54#[non_exhaustive]
55pub enum DogsConfigError {
56    /// The file is not valid TOML
57    Toml(toml::de::Error),
58}
59
60/// Manual, not derived: `toml::de::Error`'s own `Debug` forwards to
61/// `toml_edit::TomlError`, which keeps the whole source document in a
62/// `raw` field so `Display` can quote a line of context. A derived
63/// `Debug` here would print all of it, and this is the one type in the
64/// workspace whose source document, `dogs.toml`, is where an operator
65/// pastes a webhook URL.
66///
67/// The redaction is the parser's short `message()`, never the line it
68/// quotes. `Display` below still shows the full line-and-column
69/// rendering, the surface meant for the operator who broke their own
70/// file; `Debug` is what a log captures instead.
71impl fmt::Debug for DogsConfigError {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            Self::Toml(err) => f.debug_tuple("Toml").field(&err.message()).finish(),
75        }
76    }
77}
78
79impl fmt::Display for DogsConfigError {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Self::Toml(err) => write!(f, "invalid TOML in dogs.toml: {err}"),
83        }
84    }
85}
86
87impl core::error::Error for DogsConfigError {
88    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
89        match self {
90            Self::Toml(err) => Some(err),
91        }
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn a_missing_file_loads_as_an_empty_map() {
101        let config = DogsConfig::load(None).expect("None is not an error");
102        assert!(config.dog.is_empty());
103    }
104
105    #[test]
106    fn sections_are_keyed_by_name_with_no_prefix() {
107        let source = "[metrics]\nbind = \"127.0.0.1:9615\"\n\n[bark.sinks]\noncall = { kind = \"discord\" }\n";
108        let config = DogsConfig::load(Some(source)).expect("valid TOML");
109        assert_eq!(
110            config.dog.keys().collect::<Vec<_>>(),
111            vec!["bark", "metrics"]
112        );
113        assert_eq!(
114            config.dog["metrics"]["bind"].as_str(),
115            Some("127.0.0.1:9615")
116        );
117    }
118
119    #[test]
120    fn invalid_toml_is_a_named_error() {
121        let err = DogsConfig::load(Some("[metrics")).expect_err("unterminated table header");
122        assert!(matches!(err, DogsConfigError::Toml(_)));
123    }
124
125    // The exact string is pinned, not the shape: a shape assertion would
126    // pass on a `Debug` that appended the raw source after the message,
127    // which is exactly the leak this redaction defeats.
128    #[test]
129    fn debug_redacts_the_source_a_parse_error_carries() {
130        let source =
131            "[bark.sinks]\noncall = { url = \"https://discord.com/api/webhooks/SECRET\" }\n[oops\n";
132        let err = DogsConfig::load(Some(source)).expect_err("unterminated table header");
133        assert_eq!(
134            format!("{err:?}"),
135            "Toml(\"invalid table header\\nexpected `.`, `]`\")"
136        );
137        // The operator's own surface is untouched: `Display` still quotes
138        // the line that failed, which is the one line of the file this type
139        // is meant to show.
140        assert!(
141            err.to_string().contains("line 3, column 6"),
142            "Display keeps its line-and-column context: {err}"
143        );
144        assert!(
145            !err.to_string().contains("SECRET"),
146            "and it quotes only the line that failed: {err}"
147        );
148    }
149
150    #[test]
151    fn debug_redacts_every_dog_section() {
152        let source =
153            "[bark.sinks]\noncall = { url = \"https://discord.com/api/webhooks/SECRET\" }\n";
154        let config = DogsConfig::load(Some(source)).expect("valid TOML");
155        assert_eq!(format!("{config:?}"), "DogsConfig { dog: <1 tables> }");
156    }
157}