Skip to main content

zerodds_websocket_bridge/daemon/
config.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Config-File-Parser fuer `zerodds-ws-bridged`.
5//!
6//! Spec: `zerodds-ws-bridge-1.0.md` §3.
7//!
8//! YAML-Subset (kein externer Parser im Workspace):
9//!
10//! * Top-Level Mapping (Schluessel-Wert).
11//! * Verschachtelte Mappings via Indent (2 Spaces).
12//! * Sequenzen via `- ` Prefix mit Indent.
13//! * Skalare: Strings (mit/ohne Quotes), Integer, Bool (`true`/`false`).
14//! * `#` Kommentare bis EOL.
15//! * `${VAR}` und `${VAR:-default}` ENV-Substitution vor dem Parse.
16//!
17//! Bewusst kein generischer YAML-Parser — der Spec-Subset ist
18//! explizit; alles ausserhalb wird mit `ConfigError::Syntax` lehnt
19//! abgelehnt.
20
21use std::collections::BTreeMap;
22use std::env;
23use std::fs;
24use std::path::Path;
25use std::string::{String, ToString};
26use std::vec::Vec;
27
28/// Geparste Daemon-Config.
29#[derive(Debug, Clone, Default)]
30pub struct DaemonConfig {
31    /// `listen: <addr>` — Bind-Address.
32    pub listen: String,
33    /// `domain: <id>` — DDS-Domain-ID.
34    pub domain: i32,
35    /// `log_level: <level>`.
36    pub log_level: String,
37    /// `topics:` Liste.
38    pub topics: Vec<TopicConfig>,
39    /// `tls.enabled` — wenn true, müssen `tls_cert_file`+`tls_key_file`
40    /// gesetzt sein. Spec §7.1.
41    pub tls_enabled: bool,
42    /// `tls.cert_file` — PEM-Cert-Pfad.
43    pub tls_cert_file: String,
44    /// `tls.key_file` — PEM-Key-Pfad.
45    pub tls_key_file: String,
46    /// `tls.client_ca_file` — PEM-CA-Bundle für mTLS Client-Auth.
47    pub tls_client_ca_file: String,
48    /// `auth.mode` — `none|bearer|jwt|mtls|sasl`. Spec §7.2.
49    pub auth_mode: String,
50    /// `auth.bearer_token` — Single-Token-Form (Map mit einem Eintrag).
51    pub auth_bearer_token: Option<String>,
52    /// `auth.bearer_token_subject` — wer hinter dem Bearer steckt.
53    pub auth_bearer_subject: Option<String>,
54    /// Topic-ACL: `topic → ("read,write" CSV von Subjects)`. Spec §7.3.
55    pub topic_acl: std::collections::HashMap<String, (Vec<String>, Vec<String>)>,
56    /// `metrics.enabled` — schaltet den Prometheus-Endpoint (§8.2).
57    pub metrics_enabled: bool,
58    /// Bind-Address fuer Admin-Endpoint (`/metrics`, `/catalog`,
59    /// `/healthz`). Wenn leer aber `metrics_enabled=true`: default
60    /// `127.0.0.1:9090`. Per CLI/`metrics.address` ueberschreibbar.
61    pub metrics_addr: String,
62}
63
64/// Single Topic-Map-Entry.
65#[derive(Debug, Clone, Default)]
66pub struct TopicConfig {
67    /// `name:` — DDS-Topic-Name.
68    pub name: String,
69    /// `type:` — DDS-Type-Name.
70    pub type_name: String,
71    /// `direction:` — `in|out|bidir`.
72    pub direction: String,
73    /// `ws_path:` — Override-URL-Pfad.
74    pub ws_path: String,
75    /// `qos.reliability:`.
76    pub reliability: String,
77    /// `qos.durability:`.
78    pub durability: String,
79    /// `qos.history.depth:`.
80    pub history_depth: i32,
81}
82
83/// Config-Fehler.
84#[derive(Debug, Clone)]
85pub enum ConfigError {
86    /// File-IO-Fehler.
87    Io(String),
88    /// YAML-Syntax-Fehler.
89    Syntax(String),
90    /// Pflicht-Feld fehlt.
91    MissingField(String),
92    /// Wert-Typ unpassend.
93    BadValue {
94        /// Feldname.
95        field: String,
96        /// Roher Wert.
97        value: String,
98    },
99}
100
101impl core::fmt::Display for ConfigError {
102    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
103        match self {
104            Self::Io(m) => write!(f, "config io: {m}"),
105            Self::Syntax(m) => write!(f, "config syntax: {m}"),
106            Self::MissingField(m) => write!(f, "config missing field: {m}"),
107            Self::BadValue { field, value } => {
108                write!(f, "config bad value for {field}: {value}")
109            }
110        }
111    }
112}
113
114impl std::error::Error for ConfigError {}
115
116impl DaemonConfig {
117    /// Default-Config (wenn weder File noch CLI-Override gesetzt sind).
118    #[must_use]
119    pub fn default_for_dev() -> Self {
120        Self {
121            listen: "127.0.0.1:8080".to_string(),
122            domain: 0,
123            log_level: "info".to_string(),
124            topics: Vec::new(),
125            tls_enabled: false,
126            tls_cert_file: String::new(),
127            tls_key_file: String::new(),
128            tls_client_ca_file: String::new(),
129            auth_mode: "none".to_string(),
130            auth_bearer_token: None,
131            auth_bearer_subject: None,
132            topic_acl: std::collections::HashMap::new(),
133            metrics_enabled: false,
134            metrics_addr: String::new(),
135        }
136    }
137
138    /// Laedt + parst eine Config aus File.
139    ///
140    /// # Errors
141    /// `Io` bei Read-Fehler, `Syntax`/`MissingField`/`BadValue` bei
142    /// fehlerhaftem YAML.
143    pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
144        let raw = fs::read_to_string(path).map_err(|e| ConfigError::Io(e.to_string()))?;
145        Self::load_from_str(&raw)
146    }
147
148    /// Parst Config aus YAML-String. Public fuer Tests.
149    ///
150    /// # Errors
151    /// Siehe [`ConfigError`].
152    pub fn load_from_str(raw: &str) -> Result<Self, ConfigError> {
153        let expanded = expand_env_vars(raw);
154        let nodes = parse_yaml_subset(&expanded)?;
155        let mut out = Self::default_for_dev();
156        for (k, v) in nodes.iter() {
157            match k.as_str() {
158                "listen" => out.listen = v.as_scalar()?,
159                "domain" => {
160                    let s = v.as_scalar()?;
161                    out.domain = s.parse().map_err(|_| ConfigError::BadValue {
162                        field: "domain".to_string(),
163                        value: s,
164                    })?;
165                }
166                "log_level" => out.log_level = v.as_scalar()?,
167                "tls" => {
168                    if let YamlNode::Map(m) = v {
169                        if let Some(YamlNode::Scalar(s)) = m.get("enabled") {
170                            out.tls_enabled = parse_bool(s);
171                        }
172                        if let Some(YamlNode::Scalar(s)) = m.get("cert_file") {
173                            out.tls_cert_file = s.clone();
174                        }
175                        if let Some(YamlNode::Scalar(s)) = m.get("key_file") {
176                            out.tls_key_file = s.clone();
177                        }
178                        if let Some(YamlNode::Scalar(s)) = m.get("client_ca_file") {
179                            out.tls_client_ca_file = s.clone();
180                        }
181                    }
182                }
183                "auth" => {
184                    if let YamlNode::Map(m) = v {
185                        if let Some(YamlNode::Scalar(s)) = m.get("mode") {
186                            out.auth_mode = s.clone();
187                        }
188                        if let Some(YamlNode::Scalar(s)) = m.get("bearer_token") {
189                            out.auth_bearer_token = Some(s.clone());
190                        }
191                        if let Some(YamlNode::Scalar(s)) = m.get("bearer_subject") {
192                            out.auth_bearer_subject = Some(s.clone());
193                        }
194                    }
195                }
196                "acl" => {
197                    if let YamlNode::Map(m) = v {
198                        for (topic, entry) in m.iter() {
199                            if let YamlNode::Map(em) = entry {
200                                let read = em
201                                    .get("read")
202                                    .and_then(|n| match n {
203                                        YamlNode::Scalar(s) => Some(
204                                            s.split(',').map(|x| x.trim().to_string()).collect(),
205                                        ),
206                                        _ => None,
207                                    })
208                                    .unwrap_or_default();
209                                let write = em
210                                    .get("write")
211                                    .and_then(|n| match n {
212                                        YamlNode::Scalar(s) => Some(
213                                            s.split(',').map(|x| x.trim().to_string()).collect(),
214                                        ),
215                                        _ => None,
216                                    })
217                                    .unwrap_or_default();
218                                out.topic_acl.insert(topic.clone(), (read, write));
219                            }
220                        }
221                    }
222                }
223                "metrics" => {
224                    if let YamlNode::Map(m) = v {
225                        if let Some(YamlNode::Scalar(s)) = m.get("enabled") {
226                            out.metrics_enabled = parse_bool(s);
227                        }
228                        if let Some(YamlNode::Scalar(s)) = m.get("address") {
229                            out.metrics_addr = s.clone();
230                        }
231                    }
232                }
233                "topics" => {
234                    if let YamlNode::Seq(items) = v {
235                        for item in items.iter() {
236                            if let YamlNode::Map(m) = item {
237                                let mut t = TopicConfig::default();
238                                if let Some(YamlNode::Scalar(s)) = m.get("name") {
239                                    t.name = s.clone();
240                                }
241                                if let Some(YamlNode::Scalar(s)) = m.get("type") {
242                                    t.type_name = s.clone();
243                                }
244                                if let Some(YamlNode::Scalar(s)) = m.get("direction") {
245                                    t.direction = s.clone();
246                                } else {
247                                    t.direction = "bidir".to_string();
248                                }
249                                if let Some(YamlNode::Scalar(s)) = m.get("ws_path") {
250                                    t.ws_path = s.clone();
251                                }
252                                if let Some(YamlNode::Map(qm)) = m.get("qos") {
253                                    if let Some(YamlNode::Scalar(s)) = qm.get("reliability") {
254                                        t.reliability = s.clone();
255                                    }
256                                    if let Some(YamlNode::Scalar(s)) = qm.get("durability") {
257                                        t.durability = s.clone();
258                                    }
259                                    if let Some(YamlNode::Map(hm)) = qm.get("history") {
260                                        if let Some(YamlNode::Scalar(s)) = hm.get("depth") {
261                                            t.history_depth = s.parse().unwrap_or(10);
262                                        }
263                                    }
264                                }
265                                if t.name.is_empty() {
266                                    return Err(ConfigError::MissingField(
267                                        "topics[].name".to_string(),
268                                    ));
269                                }
270                                if t.type_name.is_empty() {
271                                    t.type_name = t.name.clone();
272                                }
273                                if t.ws_path.is_empty() {
274                                    t.ws_path = default_ws_path(&t.name);
275                                }
276                                out.topics.push(t);
277                            }
278                        }
279                    }
280                }
281                _ => {} // unbekannte top-level-keys werden ignoriert
282            }
283        }
284        Ok(out)
285    }
286}
287
288/// Slug-Algorithmus per Spec §5.1: `Chat::Message` → `/topics/chat/message`.
289#[must_use]
290pub fn default_ws_path(topic: &str) -> String {
291    let mut buf = String::from("/topics/");
292    let lower = topic.to_ascii_lowercase();
293    let bytes = lower.as_bytes();
294    let mut i = 0;
295    while i < bytes.len() {
296        if i + 1 < bytes.len() && bytes[i] == b':' && bytes[i + 1] == b':' {
297            buf.push('/');
298            i += 2;
299            continue;
300        }
301        let c = bytes[i] as char;
302        if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '/' {
303            buf.push(c);
304        } else {
305            buf.push('_');
306        }
307        i += 1;
308    }
309    buf
310}
311
312fn parse_bool(s: &str) -> bool {
313    matches!(s.trim().to_ascii_lowercase().as_str(), "true" | "yes" | "1")
314}
315
316/// `${VAR}` und `${VAR:-default}` Substitution.
317#[must_use]
318pub fn expand_env_vars(input: &str) -> String {
319    let mut out = String::with_capacity(input.len());
320    let chars: Vec<char> = input.chars().collect();
321    let mut i = 0;
322    while i < chars.len() {
323        if i + 1 < chars.len() && chars[i] == '$' && chars[i + 1] == '{' {
324            // Find closing `}`.
325            if let Some(end) = chars[i + 2..].iter().position(|&c| c == '}') {
326                let inner: String = chars[i + 2..i + 2 + end].iter().collect();
327                let (name, default) = match inner.split_once(":-") {
328                    Some((n, d)) => (n.to_string(), Some(d.to_string())),
329                    None => (inner.clone(), None),
330                };
331                let value = env::var(&name).ok().or(default).unwrap_or_default();
332                out.push_str(&value);
333                i += 2 + end + 1;
334                continue;
335            }
336        }
337        out.push(chars[i]);
338        i += 1;
339    }
340    out
341}
342
343/// YAML-Subset-AST.
344#[derive(Debug, Clone)]
345enum YamlNode {
346    Scalar(String),
347    Seq(Vec<YamlNode>),
348    Map(BTreeMap<String, YamlNode>),
349}
350
351impl YamlNode {
352    fn as_scalar(&self) -> Result<String, ConfigError> {
353        match self {
354            Self::Scalar(s) => Ok(s.clone()),
355            _ => Err(ConfigError::Syntax("expected scalar".to_string())),
356        }
357    }
358}
359
360/// Mini-YAML-Parser. Verarbeitet nur das Spec-Subset.
361fn parse_yaml_subset(raw: &str) -> Result<BTreeMap<String, YamlNode>, ConfigError> {
362    // Tokenize: pro Zeile `(indent, content)`.
363    let mut lines: Vec<(usize, String)> = Vec::new();
364    for line in raw.split('\n') {
365        // Strip `#`-Kommentare (ausserhalb von Quotes).
366        let stripped = strip_comment(line);
367        if stripped.trim().is_empty() {
368            continue;
369        }
370        let indent = stripped.chars().take_while(|c| *c == ' ').count();
371        let content = stripped[indent..].to_string();
372        lines.push((indent, content));
373    }
374    let (out, _) = parse_block_map(&lines, 0, 0)?;
375    Ok(out)
376}
377
378fn strip_comment(line: &str) -> String {
379    let mut out = String::new();
380    let mut in_quote: Option<char> = None;
381    for c in line.chars() {
382        match in_quote {
383            Some(q) => {
384                out.push(c);
385                if c == q {
386                    in_quote = None;
387                }
388            }
389            None => {
390                if c == '#' {
391                    break;
392                }
393                if c == '"' || c == '\'' {
394                    in_quote = Some(c);
395                }
396                out.push(c);
397            }
398        }
399    }
400    // Trim trailing whitespace.
401    out.trim_end().to_string()
402}
403/// zerodds-lint: recursion-depth 64 (parse_block_map bounded by AST depth)
404fn parse_block_map(
405    lines: &[(usize, String)],
406    start: usize,
407    indent: usize,
408) -> Result<(BTreeMap<String, YamlNode>, usize), ConfigError> {
409    let mut map = BTreeMap::new();
410    let mut i = start;
411    while i < lines.len() {
412        let (line_indent, content) = &lines[i];
413        if *line_indent < indent {
414            break;
415        }
416        if *line_indent > indent {
417            return Err(ConfigError::Syntax(alloc_format(format_args!(
418                "unexpected indent at line containing {content}"
419            ))));
420        }
421        if content.starts_with("- ") || content.as_str() == "-" {
422            // We are inside a map but encountered a sequence-marker.
423            return Err(ConfigError::Syntax(
424                "unexpected sequence marker in map context".to_string(),
425            ));
426        }
427        let (key, value) = match content.split_once(':') {
428            Some((k, v)) => (k.trim().to_string(), v.trim().to_string()),
429            None => {
430                return Err(ConfigError::Syntax(alloc_format(format_args!(
431                    "no `:` in line: {content}"
432                ))));
433            }
434        };
435        if !value.is_empty() {
436            // Inline scalar.
437            map.insert(key, YamlNode::Scalar(unquote(&value)));
438            i += 1;
439        } else {
440            // Block-Child auf next-deeper Indent.
441            i += 1;
442            // Naechste nicht-leere Line bestimmt das Format.
443            if i >= lines.len() || lines[i].0 <= indent {
444                // leere Body — als leerer Scalar.
445                map.insert(key, YamlNode::Scalar(String::new()));
446                continue;
447            }
448            let child_indent = lines[i].0;
449            let child_content = &lines[i].1;
450            if child_content.starts_with("- ") || child_content.as_str() == "-" {
451                let (seq, advanced) = parse_block_seq(lines, i, child_indent)?;
452                map.insert(key, YamlNode::Seq(seq));
453                i = advanced;
454            } else {
455                let (sub, advanced) = parse_block_map(lines, i, child_indent)?;
456                map.insert(key, YamlNode::Map(sub));
457                i = advanced;
458            }
459        }
460    }
461    Ok((map, i))
462}
463/// zerodds-lint: recursion-depth 64 (parse_block_seq bounded by AST depth)
464fn parse_block_seq(
465    lines: &[(usize, String)],
466    start: usize,
467    indent: usize,
468) -> Result<(Vec<YamlNode>, usize), ConfigError> {
469    let mut seq = Vec::new();
470    let mut i = start;
471    while i < lines.len() {
472        let (line_indent, content) = &lines[i];
473        if *line_indent < indent {
474            break;
475        }
476        if *line_indent > indent {
477            return Err(ConfigError::Syntax("seq misindented".to_string()));
478        }
479        if !content.starts_with('-') {
480            break;
481        }
482        // `- key: value`-Form vs `-` block child auf naechster Zeile
483        let after_dash = if content == "-" {
484            String::new()
485        } else if content.starts_with("- ") {
486            content[2..].to_string()
487        } else {
488            return Err(ConfigError::Syntax("malformed seq item".to_string()));
489        };
490        if after_dash.is_empty() {
491            // Item-Body auf naechster Zeile.
492            i += 1;
493            if i >= lines.len() || lines[i].0 <= indent {
494                seq.push(YamlNode::Scalar(String::new()));
495                continue;
496            }
497            let child_indent = lines[i].0;
498            let (sub, advanced) = parse_block_map(lines, i, child_indent)?;
499            seq.push(YamlNode::Map(sub));
500            i = advanced;
501        } else if let Some((k, v)) = after_dash.split_once(':') {
502            let k = k.trim().to_string();
503            let v = v.trim();
504            // Sammle: erster Eintrag inline + Folge-Lines mit `child_indent =
505            // indent + 2` als Map-Members.
506            let mut sub = BTreeMap::new();
507            if v.is_empty() {
508                // Block-Child fuer ersten Key auf naechster Zeile.
509                i += 1;
510                if i >= lines.len() {
511                    sub.insert(k, YamlNode::Scalar(String::new()));
512                } else if lines[i].0 > indent + 2 {
513                    let ci = lines[i].0;
514                    let child = &lines[i].1;
515                    if child.starts_with("- ") || child == "-" {
516                        let (s2, advanced) = parse_block_seq(lines, i, ci)?;
517                        sub.insert(k, YamlNode::Seq(s2));
518                        i = advanced;
519                    } else {
520                        let (m2, advanced) = parse_block_map(lines, i, ci)?;
521                        sub.insert(k, YamlNode::Map(m2));
522                        i = advanced;
523                    }
524                } else {
525                    sub.insert(k, YamlNode::Scalar(String::new()));
526                }
527            } else {
528                sub.insert(k, YamlNode::Scalar(unquote(v)));
529                i += 1;
530            }
531            // Sammle weitere Mitglieder dieses Map-Items: indent muss
532            // > seq-indent sein, exakt = indent + 2.
533            let item_member_indent = indent + 2;
534            while i < lines.len() {
535                let (li, lc) = &lines[i];
536                if *li < item_member_indent {
537                    break;
538                }
539                if *li == indent && (lc.starts_with("- ") || lc == "-") {
540                    break;
541                }
542                if *li != item_member_indent {
543                    break;
544                }
545                if lc.starts_with("- ") {
546                    break;
547                }
548                let (kk, vv) = lc
549                    .split_once(':')
550                    .ok_or_else(|| ConfigError::Syntax("seq map missing colon".to_string()))?;
551                let kk = kk.trim().to_string();
552                let vv = vv.trim();
553                if vv.is_empty() {
554                    i += 1;
555                    if i < lines.len() && lines[i].0 > item_member_indent {
556                        let ci = lines[i].0;
557                        let child = &lines[i].1;
558                        if child.starts_with("- ") || child == "-" {
559                            let (s2, advanced) = parse_block_seq(lines, i, ci)?;
560                            sub.insert(kk, YamlNode::Seq(s2));
561                            i = advanced;
562                        } else {
563                            let (m2, advanced) = parse_block_map(lines, i, ci)?;
564                            sub.insert(kk, YamlNode::Map(m2));
565                            i = advanced;
566                        }
567                    } else {
568                        sub.insert(kk, YamlNode::Scalar(String::new()));
569                    }
570                } else {
571                    sub.insert(kk, YamlNode::Scalar(unquote(vv)));
572                    i += 1;
573                }
574            }
575            seq.push(YamlNode::Map(sub));
576        } else {
577            // Inline-Scalar.
578            seq.push(YamlNode::Scalar(unquote(&after_dash)));
579            i += 1;
580        }
581    }
582    Ok((seq, i))
583}
584
585fn unquote(v: &str) -> String {
586    let v = v.trim();
587    if (v.starts_with('"') && v.ends_with('"') && v.len() >= 2)
588        || (v.starts_with('\'') && v.ends_with('\'') && v.len() >= 2)
589    {
590        v[1..v.len() - 1].to_string()
591    } else {
592        v.to_string()
593    }
594}
595
596fn alloc_format(args: core::fmt::Arguments<'_>) -> String {
597    use core::fmt::Write as _;
598    let mut s = String::new();
599    let _ = s.write_fmt(args);
600    s
601}
602
603#[cfg(test)]
604#[allow(clippy::expect_used, clippy::unwrap_used)]
605mod tests {
606    use super::*;
607
608    #[test]
609    fn slug_strips_double_colon() {
610        assert_eq!(default_ws_path("Chat::Message"), "/topics/chat/message");
611    }
612
613    #[test]
614    fn slug_replaces_unsafe_chars() {
615        assert_eq!(default_ws_path("My Topic!"), "/topics/my_topic_");
616    }
617
618    #[test]
619    fn env_substitution_with_default() {
620        // Test mit einem garantiert nicht gesetzten Var-Namen
621        // (UUID-Style, damit wir nicht auf process-state vertrauen).
622        let s = expand_env_vars("token: ${ZERODDS_PROBABLY_UNSET_VAR_e2afb0b9_test:-fallback}");
623        assert!(s.contains("fallback"), "got: {s}");
624    }
625
626    #[test]
627    fn env_substitution_passthrough_when_no_placeholder() {
628        let s = expand_env_vars("plain: value");
629        assert_eq!(s, "plain: value");
630    }
631
632    #[test]
633    fn parse_minimal_config() {
634        let yaml = "\
635listen: \"0.0.0.0:8080\"
636domain: 0
637log_level: info
638topics:
639  - name: \"Chat::Message\"
640    type: \"Chat::Message\"
641    direction: bidir
642";
643        let cfg = DaemonConfig::load_from_str(yaml).unwrap();
644        assert_eq!(cfg.listen, "0.0.0.0:8080");
645        assert_eq!(cfg.domain, 0);
646        assert_eq!(cfg.topics.len(), 1);
647        assert_eq!(cfg.topics[0].name, "Chat::Message");
648        assert_eq!(cfg.topics[0].direction, "bidir");
649        assert_eq!(cfg.topics[0].ws_path, "/topics/chat/message");
650    }
651
652    #[test]
653    fn parse_qos_block() {
654        let yaml = "\
655listen: 0.0.0.0:8080
656domain: 0
657topics:
658  - name: T
659    qos:
660      reliability: reliable
661      durability: volatile
662      history:
663        depth: 25
664";
665        let cfg = DaemonConfig::load_from_str(yaml).unwrap();
666        assert_eq!(cfg.topics[0].reliability, "reliable");
667        assert_eq!(cfg.topics[0].durability, "volatile");
668        assert_eq!(cfg.topics[0].history_depth, 25);
669    }
670
671    #[test]
672    fn parse_tls_and_auth_blocks() {
673        let yaml = "\
674listen: 0.0.0.0:8080
675domain: 0
676tls:
677  enabled: true
678auth:
679  mode: bearer
680  bearer_token: secret
681metrics:
682  enabled: true
683topics:
684  - name: T
685";
686        let cfg = DaemonConfig::load_from_str(yaml).unwrap();
687        assert!(cfg.tls_enabled);
688        assert_eq!(cfg.auth_mode, "bearer");
689        assert_eq!(cfg.auth_bearer_token.as_deref(), Some("secret"));
690        assert!(cfg.metrics_enabled);
691    }
692
693    #[test]
694    fn parse_rejects_bad_domain() {
695        let yaml = "\
696listen: x
697domain: notanint
698";
699        let err = DaemonConfig::load_from_str(yaml).unwrap_err();
700        assert!(matches!(err, ConfigError::BadValue { .. }));
701    }
702}