1use std::collections::BTreeMap;
22use std::env;
23use std::fs;
24use std::path::Path;
25use std::string::{String, ToString};
26use std::vec::Vec;
27
28#[derive(Debug, Clone, Default)]
30pub struct DaemonConfig {
31 pub listen: String,
33 pub domain: i32,
35 pub log_level: String,
37 pub topics: Vec<TopicConfig>,
39 pub tls_enabled: bool,
42 pub tls_cert_file: String,
44 pub tls_key_file: String,
46 pub tls_client_ca_file: String,
48 pub auth_mode: String,
50 pub auth_bearer_token: Option<String>,
52 pub auth_bearer_subject: Option<String>,
54 pub topic_acl: std::collections::HashMap<String, (Vec<String>, Vec<String>)>,
56 pub metrics_enabled: bool,
58 pub metrics_addr: String,
62}
63
64#[derive(Debug, Clone, Default)]
66pub struct TopicConfig {
67 pub name: String,
69 pub type_name: String,
71 pub direction: String,
73 pub ws_path: String,
75 pub reliability: String,
77 pub durability: String,
79 pub history_depth: i32,
81}
82
83#[derive(Debug, Clone)]
85pub enum ConfigError {
86 Io(String),
88 Syntax(String),
90 MissingField(String),
92 BadValue {
94 field: String,
96 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 #[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 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 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 _ => {} }
283 }
284 Ok(out)
285 }
286}
287
288#[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#[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 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#[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
360fn parse_yaml_subset(raw: &str) -> Result<BTreeMap<String, YamlNode>, ConfigError> {
362 let mut lines: Vec<(usize, String)> = Vec::new();
364 for line in raw.split('\n') {
365 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 out.trim_end().to_string()
402}
403fn 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 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 map.insert(key, YamlNode::Scalar(unquote(&value)));
438 i += 1;
439 } else {
440 i += 1;
442 if i >= lines.len() || lines[i].0 <= indent {
444 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}
463fn 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 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 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 let mut sub = BTreeMap::new();
507 if v.is_empty() {
508 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 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 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 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}