1use std::str::FromStr;
4use std::time::Duration;
5
6use async_nats::HeaderName;
7
8use crate::config::ConnectorConfig;
9use crate::error::ConnectorError;
10use crate::serde::Format;
11
12pub(super) const MAX_PENDING_PUBLISH_ACKS: usize = 4_096;
15
16pub(super) const MAX_NATS_IO_TIMEOUT: Duration = Duration::from_secs(300);
19
20const NATS_CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
21const NATS_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum Mode {
26 Core,
28 #[default]
30 JetStream,
31}
32
33str_enum!(fromstr Mode, lowercase, ConnectorError, "invalid nats mode",
34 Core => "core";
35 JetStream => "jetstream"
36);
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum AckPolicy {
41 #[default]
43 Explicit,
44 None,
46}
47
48str_enum!(fromstr AckPolicy, lowercase, ConnectorError, "invalid ack.policy",
49 Explicit => "explicit";
50 None => "none"
51);
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum DeliverPolicy {
56 #[default]
58 All,
59 New,
61 ByStartSequence,
63 ByStartTime,
65}
66
67str_enum!(fromstr DeliverPolicy, lowercase, ConnectorError, "invalid deliver.policy",
68 All => "all";
69 New => "new";
70 ByStartSequence => "by_start_sequence";
71 ByStartTime => "by_start_time"
72);
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum SubjectSpec {
77 Literal(String),
79 Column(String),
81}
82
83#[derive(Clone, Default, PartialEq, Eq)]
86#[allow(missing_docs)]
87pub enum AuthMode {
88 #[default]
89 None,
90 UserPass {
91 user: String,
92 password: String,
93 },
94 Token(String),
95 CredsFile(String),
96}
97
98impl std::fmt::Debug for AuthMode {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 match self {
101 Self::None => f.write_str("None"),
102 Self::UserPass { user, .. } => f
103 .debug_struct("UserPass")
104 .field("user", user)
105 .field("password", &"<redacted>")
106 .finish(),
107 Self::Token(_) => f.debug_tuple("Token").field(&"<redacted>").finish(),
108 Self::CredsFile(path) => f.debug_tuple("CredsFile").field(path).finish(),
109 }
110 }
111}
112
113#[derive(Debug, Clone, Default)]
115#[allow(missing_docs)]
116pub struct TlsConfig {
117 pub enabled: bool,
118 pub ca_location: Option<String>,
119 pub cert_location: Option<String>,
120 pub key_location: Option<String>,
121}
122
123#[derive(Debug, Clone)]
125#[allow(missing_docs)] pub struct NatsSourceConfig {
127 pub servers: Vec<String>,
128 pub mode: Mode,
129 pub auth: AuthMode,
130 pub tls: TlsConfig,
131
132 pub stream: Option<String>,
134 pub subject: Option<String>,
135 pub subject_filters: Vec<String>,
136 pub consumer: Option<String>,
137 pub deliver_policy: DeliverPolicy,
138 pub start_sequence: Option<u64>,
139 pub start_time: Option<String>, pub ack_policy: AckPolicy,
141 pub ack_wait: Duration,
142 pub max_deliver: i64,
143 pub max_ack_pending: i64,
144 pub fetch_batch: usize,
145 pub fetch_max_wait: Duration,
146 pub lag_poll_interval: Duration,
149
150 pub queue_group: Option<String>,
152
153 pub format: Format,
154}
155
156impl NatsSourceConfig {
157 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
161 let servers = parse_servers(config)?;
162 let mode = parse_or_default::<Mode>(config, "mode")?;
163 let auth = parse_auth(config)?;
164 let tls = parse_tls(config)?;
165
166 let stream = config.get("stream").map(str::to_string);
167 let subject = config.get("subject").map(str::to_string);
168 let subject_filters = config
169 .get("subject.filters")
170 .map(split_csv)
171 .unwrap_or_default();
172 let consumer = config.get("consumer").map(str::to_string);
173 let deliver_policy = parse_or_default::<DeliverPolicy>(config, "deliver.policy")?;
174 let start_sequence = parse_opt_u64(config, "start.sequence")?;
175 let start_time = config.get("start.time").map(str::to_string);
176 let ack_policy = parse_or_default::<AckPolicy>(config, "ack.policy")?;
177 let ack_wait = require_positive_duration(config, "ack.wait.ms", Duration::from_secs(60))?;
178 let max_deliver = require_positive_or_unlimited_i64(config, "max.deliver", 5)?;
179 let max_ack_pending = require_positive_or_unlimited_i64(config, "max.ack.pending", 10_000)?;
180 let fetch_batch = require_positive_usize(config, "fetch.batch", 500)?;
181 let fetch_max_wait =
182 require_positive_duration(config, "fetch.max.wait.ms", Duration::from_millis(500))?;
183 let lag_poll_interval =
184 parse_duration_ms(config, "lag.poll.interval.ms", Duration::from_secs(10))?;
185 let queue_group = config.get("queue.group").map(str::to_string);
186 let format = parse_format(config)?;
187
188 let cfg = Self {
189 servers,
190 mode,
191 auth,
192 tls,
193 stream,
194 subject,
195 subject_filters,
196 consumer,
197 deliver_policy,
198 start_sequence,
199 start_time,
200 ack_policy,
201 ack_wait,
202 max_deliver,
203 max_ack_pending,
204 fetch_batch,
205 fetch_max_wait,
206 lag_poll_interval,
207 queue_group,
208 format,
209 };
210 cfg.validate()?;
211 Ok(cfg)
212 }
213
214 fn validate(&self) -> Result<(), ConnectorError> {
215 match self.mode {
216 Mode::JetStream => {
217 if self.stream.is_none() {
218 return Err(cfg_err(
219 "[LDB-5040] jetstream mode requires 'stream' to be set",
220 ));
221 }
222 if self.consumer.is_none() {
223 return Err(cfg_err(
224 "[LDB-5041] jetstream mode requires 'consumer' (durable name) — \
225 ephemeral consumers are not supported",
226 ));
227 }
228 if self.subject.is_none() && self.subject_filters.is_empty() {
229 return Err(cfg_err(
230 "[LDB-5042] jetstream mode requires 'subject' or 'subject.filters' \
231 — the consumer must have at least one filter",
232 ));
233 }
234 if matches!(self.deliver_policy, DeliverPolicy::ByStartSequence)
235 && self.start_sequence.is_none()
236 {
237 return Err(cfg_err(
238 "[LDB-5043] deliver.policy=by_start_sequence requires 'start.sequence'",
239 ));
240 }
241 if matches!(self.deliver_policy, DeliverPolicy::ByStartTime)
242 && self.start_time.is_none()
243 {
244 return Err(cfg_err(
245 "[LDB-5044] deliver.policy=by_start_time requires 'start.time'",
246 ));
247 }
248 }
249 Mode::Core => {
250 if self.subject.is_none() {
251 return Err(cfg_err(
252 "[LDB-5045] core mode requires 'subject' (JetStream stream config \
253 does not apply)",
254 ));
255 }
256 }
257 }
258 Ok(())
259 }
260}
261
262#[derive(Debug, Clone)]
264#[allow(missing_docs)]
265pub struct NatsSinkConfig {
266 pub servers: Vec<String>,
267 pub mode: Mode,
268 pub auth: AuthMode,
269 pub tls: TlsConfig,
270 pub stream: Option<String>,
271 pub subject: SubjectSpec,
272 pub dedup_id_column: Option<String>,
273 pub min_duplicate_window: Duration,
276 pub max_pending: usize,
277 pub ack_timeout: Duration,
278 pub format: Format,
279 pub header_columns: Vec<HeaderName>,
280}
281
282impl NatsSinkConfig {
283 pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
287 let servers = parse_servers(config)?;
288 let mode = parse_or_default::<Mode>(config, "mode")?;
289 let auth = parse_auth(config)?;
290 let tls = parse_tls(config)?;
291
292 let subject = match (config.get("subject"), config.get("subject.column")) {
293 (Some(s), None) => SubjectSpec::Literal(s.to_string()),
294 (None, Some(col)) => SubjectSpec::Column(col.to_string()),
295 (Some(_), Some(_)) => {
296 return Err(cfg_err(
297 "[LDB-5050] set 'subject' OR 'subject.column', not both",
298 ));
299 }
300 (None, None) => {
301 return Err(cfg_err(
302 "[LDB-5051] 'subject' or 'subject.column' is required",
303 ));
304 }
305 };
306
307 let dedup_id_column = config.get("dedup.id.column").map(str::to_string);
308
309 let cfg = Self {
310 servers,
311 mode,
312 auth,
313 tls,
314 stream: config
315 .get("stream")
316 .map(str::trim)
317 .filter(|stream| !stream.is_empty())
318 .map(str::to_string),
319 subject,
320 dedup_id_column,
321 min_duplicate_window: require_positive_duration(
322 config,
323 "min.duplicate.window.ms",
324 Duration::from_secs(120),
325 )?,
326 max_pending: require_positive_usize(config, "max.pending", MAX_PENDING_PUBLISH_ACKS)?,
327 ack_timeout: require_positive_duration(
328 config,
329 "ack.timeout.ms",
330 Duration::from_secs(30),
331 )?,
332 format: parse_format(config)?,
333 header_columns: config
334 .get("header.columns")
335 .map(split_csv)
336 .unwrap_or_default()
337 .into_iter()
338 .map(|name| {
339 if !name
340 .bytes()
341 .all(|byte| (33..=126).contains(&byte) && byte != b':')
342 {
343 return Err(cfg_err(&format!(
344 "[LDB-5066] header.columns entry '{name}' is not a valid ASCII NATS header name"
345 )));
346 }
347 HeaderName::from_str(&name).map_err(|error| {
348 cfg_err(&format!(
349 "[LDB-5066] header.columns entry '{name}' is not a valid NATS header name: {error}"
350 ))
351 })
352 })
353 .collect::<Result<_, _>>()?,
354 };
355 cfg.validate()?;
356 Ok(cfg)
357 }
358
359 fn validate(&self) -> Result<(), ConnectorError> {
360 if self.max_pending > MAX_PENDING_PUBLISH_ACKS {
361 return Err(cfg_err(&format!(
362 "max.pending must be <= {MAX_PENDING_PUBLISH_ACKS}"
363 )));
364 }
365 if self.ack_timeout > MAX_NATS_IO_TIMEOUT {
366 return Err(cfg_err(&format!(
367 "ack.timeout.ms must be <= {}",
368 MAX_NATS_IO_TIMEOUT.as_millis()
369 )));
370 }
371 if self.mode == Mode::Core && self.dedup_id_column.is_some() {
372 return Err(cfg_err(
373 "[LDB-5053] 'dedup.id.column' requires mode=jetstream because core NATS has no \
374 server-side Nats-Msg-Id deduplication",
375 ));
376 }
377 if self.dedup_id_column.is_some() && self.stream.is_none() {
378 return Err(cfg_err(
379 "[LDB-5055] 'dedup.id.column' requires 'stream' so the sink can validate its \
380 duplicate_window at startup",
381 ));
382 }
383 for h in &self.header_columns {
384 if is_reserved_header(h.as_ref()) {
385 return Err(cfg_err(&format!(
386 "[LDB-5065] header.columns entry '{h}' collides with a reserved NATS \
387 header (Nats-Msg-Id / Nats-Expected-Stream); rename the column",
388 )));
389 }
390 }
391 match self.mode {
392 Mode::JetStream if self.stream.is_none() => {
393 return Err(cfg_err(
394 "[LDB-5071] mode=jetstream requires an explicit 'stream' so durable storage, \
395 replica count, and publish routing can be validated",
396 ));
397 }
398 Mode::Core if self.stream.is_some() => {
399 return Err(cfg_err(
400 "[LDB-5071] 'stream' is valid only in mode=jetstream; remove it for core NATS",
401 ));
402 }
403 Mode::Core | Mode::JetStream => {}
404 }
405 Ok(())
406 }
407}
408
409fn is_reserved_header(name: &str) -> bool {
412 const RESERVED: &[&str] = &["Nats-Msg-Id", "Nats-Expected-Stream"];
413 RESERVED.iter().any(|r| r.eq_ignore_ascii_case(name))
414}
415
416fn cfg_err(msg: &str) -> ConnectorError {
419 ConnectorError::ConfigurationError(msg.to_string())
420}
421
422fn parse_servers(config: &ConnectorConfig) -> Result<Vec<String>, ConnectorError> {
423 let raw = config.require("servers")?;
424 let servers: Vec<String> = split_csv(raw);
425 if servers.is_empty() {
426 return Err(cfg_err("[LDB-5030] 'servers' must not be empty"));
427 }
428 Ok(servers)
429}
430
431fn split_csv(raw: &str) -> Vec<String> {
432 raw.split(',')
433 .map(str::trim)
434 .filter(|s| !s.is_empty())
435 .map(str::to_string)
436 .collect()
437}
438
439fn parse_or_default<T: std::str::FromStr + Default>(
440 config: &ConnectorConfig,
441 key: &str,
442) -> Result<T, ConnectorError>
443where
444 T::Err: std::fmt::Display,
445{
446 match config.get(key) {
447 Some(s) => s
448 .parse()
449 .map_err(|e: T::Err| cfg_err(&format!("invalid {key}: {e}"))),
450 None => Ok(T::default()),
451 }
452}
453
454fn parse_format(config: &ConnectorConfig) -> Result<Format, ConnectorError> {
455 match config.get("format") {
456 Some(s) => Format::parse(s).map_err(|e| cfg_err(&e.to_string())),
457 None => Ok(Format::Json),
458 }
459}
460
461fn parse_bool(config: &ConnectorConfig, key: &str, default: bool) -> Result<bool, ConnectorError> {
462 match config.get(key) {
463 Some(s) => s
464 .parse::<bool>()
465 .map_err(|_| cfg_err(&format!("{key} must be 'true' or 'false', got '{s}'"))),
466 None => Ok(default),
467 }
468}
469
470fn parse_opt_u64(config: &ConnectorConfig, key: &str) -> Result<Option<u64>, ConnectorError> {
471 config.get(key).map(|s| parse_int(key, s)).transpose()
472}
473
474fn parse_i64(config: &ConnectorConfig, key: &str, default: i64) -> Result<i64, ConnectorError> {
475 config.get(key).map_or(Ok(default), |s| parse_int(key, s))
476}
477
478fn parse_usize(
479 config: &ConnectorConfig,
480 key: &str,
481 default: usize,
482) -> Result<usize, ConnectorError> {
483 config.get(key).map_or(Ok(default), |s| parse_int(key, s))
484}
485
486fn require_positive_duration(
487 config: &ConnectorConfig,
488 key: &str,
489 default: Duration,
490) -> Result<Duration, ConnectorError> {
491 let v = parse_duration_ms(config, key, default)?;
492 if v.is_zero() {
493 return Err(cfg_err(&format!("{key} must be > 0")));
494 }
495 Ok(v)
496}
497
498fn require_positive_usize(
499 config: &ConnectorConfig,
500 key: &str,
501 default: usize,
502) -> Result<usize, ConnectorError> {
503 let v = parse_usize(config, key, default)?;
504 if v == 0 {
505 return Err(cfg_err(&format!("{key} must be > 0")));
506 }
507 Ok(v)
508}
509
510fn require_positive_or_unlimited_i64(
513 config: &ConnectorConfig,
514 key: &str,
515 default: i64,
516) -> Result<i64, ConnectorError> {
517 let v = parse_i64(config, key, default)?;
518 if v == 0 || v < -1 {
519 return Err(cfg_err(&format!("{key} must be > 0, or -1 for unlimited")));
520 }
521 Ok(v)
522}
523
524fn parse_duration_ms(
525 config: &ConnectorConfig,
526 key: &str,
527 default: Duration,
528) -> Result<Duration, ConnectorError> {
529 config.get(key).map_or(Ok(default), |s| {
530 parse_int::<u64>(key, s).map(Duration::from_millis)
531 })
532}
533
534fn parse_int<T: std::str::FromStr>(key: &str, raw: &str) -> Result<T, ConnectorError> {
535 raw.parse::<T>()
536 .map_err(|_| cfg_err(&format!("{key} must be an integer, got '{raw}'")))
537}
538
539fn parse_auth(config: &ConnectorConfig) -> Result<AuthMode, ConnectorError> {
540 let mode = config.get("auth.mode").unwrap_or("none");
541 match mode {
542 "none" | "" => {
543 if config.get("user").is_some()
544 || config.get("password").is_some()
545 || config.get("token").is_some()
546 || config.get("creds.file").is_some()
547 {
548 return Err(cfg_err(
549 "[LDB-5063] credentials set but auth.mode=none; \
550 set auth.mode explicitly or remove the credentials",
551 ));
552 }
553 Ok(AuthMode::None)
554 }
555 "user_pass" => {
556 let user = config
557 .get("user")
558 .ok_or_else(|| cfg_err("[LDB-5060] auth.mode=user_pass requires 'user'"))?
559 .to_string();
560 let password = config
561 .get("password")
562 .ok_or_else(|| cfg_err("[LDB-5060] auth.mode=user_pass requires 'password'"))?
563 .to_string();
564 Ok(AuthMode::UserPass { user, password })
565 }
566 "token" => {
567 let token = config
568 .get("token")
569 .ok_or_else(|| cfg_err("[LDB-5061] auth.mode=token requires 'token'"))?
570 .to_string();
571 Ok(AuthMode::Token(token))
572 }
573 "creds_file" => {
574 let path = config
575 .get("creds.file")
576 .ok_or_else(|| cfg_err("[LDB-5064] auth.mode=creds_file requires 'creds.file'"))?
577 .to_string();
578 Ok(AuthMode::CredsFile(path))
579 }
580 other => Err(cfg_err(&format!(
581 "invalid auth.mode '{other}'; expected none | user_pass | token | creds_file"
582 ))),
583 }
584}
585
586fn parse_tls(config: &ConnectorConfig) -> Result<TlsConfig, ConnectorError> {
587 let cert_location = config.get("tls.cert.location").map(str::to_string);
588 let key_location = config.get("tls.key.location").map(str::to_string);
589 if cert_location.is_some() != key_location.is_some() {
590 return Err(cfg_err(
591 "[LDB-5062] tls.cert.location and tls.key.location must both be set \
592 (mutual-TLS client cert) or both be unset",
593 ));
594 }
595 Ok(TlsConfig {
596 enabled: parse_bool(config, "tls.enabled", false)?,
597 ca_location: config.get("tls.ca.location").map(str::to_string),
598 cert_location,
599 key_location,
600 })
601}
602
603pub(super) fn build_connect_options(
610 auth: &AuthMode,
611 tls: &TlsConfig,
612) -> Result<async_nats::ConnectOptions, ConnectorError> {
613 let mut opts = async_nats::ConnectOptions::new();
614 match auth {
615 AuthMode::None => {}
616 AuthMode::UserPass { user, password } => {
617 opts = opts.user_and_password(user.clone(), password.clone());
618 }
619 AuthMode::Token(token) => {
620 opts = opts.token(token.clone());
621 }
622 AuthMode::CredsFile(path) => {
623 let contents = std::fs::read_to_string(path)
624 .map_err(|e| cfg_err(&format!("creds.file '{path}': {e}")))?;
625 opts = async_nats::ConnectOptions::with_credentials(&contents)
626 .map_err(|e| cfg_err(&format!("creds.file '{path}' invalid: {e}")))?;
627 }
628 }
629 let tls_touched = tls.enabled || tls.ca_location.is_some() || tls.cert_location.is_some();
630 if tls_touched {
631 opts = opts.require_tls(true);
632 }
633 if let Some(ca) = &tls.ca_location {
634 opts = opts.add_root_certificates(ca.into());
635 }
636 if let (Some(cert), Some(key)) = (&tls.cert_location, &tls.key_location) {
637 opts = opts.add_client_certificate(cert.into(), key.into());
638 }
639 Ok(opts
640 .connection_timeout(NATS_CONNECTION_TIMEOUT)
641 .request_timeout(Some(NATS_REQUEST_TIMEOUT)))
642}
643
644#[cfg(test)]
645#[allow(clippy::disallowed_types)]
646mod tests {
647 use super::*;
648 use std::collections::HashMap;
649
650 fn cfg(pairs: &[(&str, &str)]) -> ConnectorConfig {
651 let mut props = HashMap::new();
652 for (k, v) in pairs {
653 props.insert((*k).to_string(), (*v).to_string());
654 }
655 ConnectorConfig::with_properties("nats", props)
656 }
657
658 #[test]
661 fn source_jetstream_requires_stream() {
662 let err = NatsSourceConfig::from_config(&cfg(&[
663 ("servers", "nats://a:4222"),
664 ("consumer", "c"),
665 ("subject", "x.>"),
666 ]))
667 .unwrap_err()
668 .to_string();
669 assert!(err.contains("LDB-5040"), "got: {err}");
670 }
671
672 #[test]
673 fn source_jetstream_requires_consumer() {
674 let err = NatsSourceConfig::from_config(&cfg(&[
675 ("servers", "nats://a:4222"),
676 ("stream", "S"),
677 ("subject", "x.>"),
678 ]))
679 .unwrap_err()
680 .to_string();
681 assert!(err.contains("LDB-5041"), "got: {err}");
682 }
683
684 #[test]
685 fn source_jetstream_requires_subject_or_filters() {
686 let err = NatsSourceConfig::from_config(&cfg(&[
687 ("servers", "nats://a:4222"),
688 ("stream", "S"),
689 ("consumer", "c"),
690 ]))
691 .unwrap_err()
692 .to_string();
693 assert!(err.contains("LDB-5042"), "got: {err}");
694 }
695
696 #[test]
697 fn source_core_requires_subject() {
698 let err =
699 NatsSourceConfig::from_config(&cfg(&[("servers", "nats://a:4222"), ("mode", "core")]))
700 .unwrap_err()
701 .to_string();
702 assert!(err.contains("LDB-5045"), "got: {err}");
703 }
704
705 #[test]
706 fn source_by_start_sequence_requires_start_sequence() {
707 let err = NatsSourceConfig::from_config(&cfg(&[
708 ("servers", "nats://a:4222"),
709 ("stream", "S"),
710 ("consumer", "c"),
711 ("subject", "x.>"),
712 ("deliver.policy", "by_start_sequence"),
713 ]))
714 .unwrap_err()
715 .to_string();
716 assert!(err.contains("LDB-5043"), "got: {err}");
717 }
718
719 #[test]
720 fn source_happy_path_jetstream() {
721 let parsed = NatsSourceConfig::from_config(&cfg(&[
722 ("servers", "nats://a:4222,nats://b:4222"),
723 ("stream", "ORDERS"),
724 ("consumer", "laminar-orders"),
725 ("subject.filters", "orders.us.*,orders.eu.*"),
726 ("ack.wait.ms", "45000"),
727 ("max.deliver", "3"),
728 ]))
729 .unwrap();
730 assert_eq!(parsed.servers.len(), 2);
731 assert_eq!(parsed.mode, Mode::JetStream);
732 assert_eq!(parsed.subject_filters, vec!["orders.us.*", "orders.eu.*"]);
733 assert_eq!(parsed.ack_wait, Duration::from_secs(45));
734 assert_eq!(parsed.max_deliver, 3);
735 }
736
737 #[test]
738 fn source_happy_path_core() {
739 let parsed = NatsSourceConfig::from_config(&cfg(&[
740 ("servers", "nats://a:4222"),
741 ("mode", "core"),
742 ("subject", "events"),
743 ("queue.group", "workers"),
744 ]))
745 .unwrap();
746 assert_eq!(parsed.mode, Mode::Core);
747 assert_eq!(parsed.subject.as_deref(), Some("events"));
748 assert_eq!(parsed.queue_group.as_deref(), Some("workers"));
749 }
750
751 #[test]
754 fn sink_rejects_subject_and_subject_column() {
755 let err = NatsSinkConfig::from_config(&cfg(&[
756 ("servers", "nats://a:4222"),
757 ("subject", "x"),
758 ("subject.column", "c"),
759 ]))
760 .unwrap_err()
761 .to_string();
762 assert!(err.contains("LDB-5050"), "got: {err}");
763 }
764
765 #[test]
766 fn sink_rejects_neither_subject_nor_column() {
767 let err = NatsSinkConfig::from_config(&cfg(&[("servers", "nats://a:4222")]))
768 .unwrap_err()
769 .to_string();
770 assert!(err.contains("LDB-5051"), "got: {err}");
771 }
772
773 #[test]
774 fn sink_rejects_header_column_colliding_with_reserved() {
775 let err = NatsSinkConfig::from_config(&cfg(&[
776 ("servers", "nats://a:4222"),
777 ("subject", "x"),
778 ("header.columns", "trace_id,nats-msg-id"),
779 ]))
780 .unwrap_err()
781 .to_string();
782 assert!(err.contains("LDB-5065"), "got: {err}");
783 }
784
785 #[test]
786 fn sink_rejects_invalid_header_column_name() {
787 for name in ["trace:id", "trŁce"] {
788 let err = NatsSinkConfig::from_config(&cfg(&[
789 ("servers", "nats://a:4222"),
790 ("subject", "x"),
791 ("header.columns", name),
792 ]))
793 .unwrap_err()
794 .to_string();
795 assert!(err.contains("LDB-5066"), "got: {err}");
796 }
797 }
798
799 #[test]
800 fn sink_rejects_core_with_dedup() {
801 let err = NatsSinkConfig::from_config(&cfg(&[
802 ("servers", "nats://a:4222"),
803 ("mode", "core"),
804 ("subject", "x"),
805 ("dedup.id.column", "event_id"),
806 ]))
807 .unwrap_err()
808 .to_string();
809 assert!(err.contains("LDB-5053"), "got: {err}");
810 }
811
812 #[test]
813 fn sink_rejects_dedup_without_stream() {
814 let err = NatsSinkConfig::from_config(&cfg(&[
815 ("servers", "nats://a:4222"),
816 ("subject", "x"),
817 ("dedup.id.column", "event_id"),
818 ]))
819 .unwrap_err()
820 .to_string();
821 assert!(err.contains("LDB-5055"), "got: {err}");
822 }
823
824 #[test]
825 fn jetstream_sink_requires_named_stream() {
826 let err =
827 NatsSinkConfig::from_config(&cfg(&[("servers", "nats://a:4222"), ("subject", "x")]))
828 .unwrap_err()
829 .to_string();
830 assert!(err.contains("LDB-5071"), "got: {err}");
831 }
832
833 #[test]
834 fn jetstream_sink_rejects_blank_stream() {
835 let err = NatsSinkConfig::from_config(&cfg(&[
836 ("servers", "nats://a:4222"),
837 ("stream", " "),
838 ("subject", "x"),
839 ]))
840 .unwrap_err()
841 .to_string();
842 assert!(err.contains("LDB-5071"), "got: {err}");
843 }
844
845 #[test]
846 fn core_sink_rejects_ignored_stream() {
847 let err = NatsSinkConfig::from_config(&cfg(&[
848 ("servers", "nats://a:4222"),
849 ("mode", "core"),
850 ("stream", "OUT"),
851 ("subject", "x"),
852 ]))
853 .unwrap_err()
854 .to_string();
855 assert!(err.contains("LDB-5071"), "got: {err}");
856 }
857
858 #[test]
859 fn sink_happy_path_bounded_dedup() {
860 let parsed = NatsSinkConfig::from_config(&cfg(&[
861 ("servers", "nats://a:4222"),
862 ("stream", "OUT"),
863 ("subject.column", "out_subject"),
864 ("dedup.id.column", "event_id"),
865 ("header.columns", "trace_id,tenant"),
866 ]))
867 .unwrap();
868 assert_eq!(parsed.stream.as_deref(), Some("OUT"));
869 assert_eq!(parsed.subject, SubjectSpec::Column("out_subject".into()));
870 assert_eq!(parsed.dedup_id_column.as_deref(), Some("event_id"));
871 let header_columns: Vec<&str> = parsed
872 .header_columns
873 .iter()
874 .map(AsRef::<str>::as_ref)
875 .collect();
876 assert_eq!(header_columns, vec!["trace_id", "tenant"]);
877 }
878
879 #[test]
882 fn servers_required() {
883 let err = NatsSourceConfig::from_config(&cfg(&[]))
884 .unwrap_err()
885 .to_string();
886 assert!(err.contains("servers"), "got: {err}");
887 }
888
889 #[test]
890 fn zero_fetch_batch_rejected() {
891 let err = NatsSourceConfig::from_config(&jetstream_happy(&[("fetch.batch", "0")]))
892 .unwrap_err()
893 .to_string();
894 assert!(err.contains("fetch.batch must be > 0"), "got: {err}");
895 }
896
897 #[test]
898 fn zero_ack_wait_rejected() {
899 let err = NatsSourceConfig::from_config(&jetstream_happy(&[("ack.wait.ms", "0")]))
900 .unwrap_err()
901 .to_string();
902 assert!(err.contains("ack.wait.ms must be > 0"), "got: {err}");
903 }
904
905 #[test]
906 fn max_deliver_unlimited_accepted() {
907 let parsed =
908 NatsSourceConfig::from_config(&jetstream_happy(&[("max.deliver", "-1")])).unwrap();
909 assert_eq!(parsed.max_deliver, -1);
910 }
911
912 #[test]
913 fn max_deliver_negative_other_than_minus_one_rejected() {
914 let err = NatsSourceConfig::from_config(&jetstream_happy(&[("max.deliver", "-5")]))
915 .unwrap_err()
916 .to_string();
917 assert!(err.contains("max.deliver"), "got: {err}");
918 }
919
920 #[test]
921 fn sink_zero_max_pending_rejected() {
922 let err = NatsSinkConfig::from_config(&cfg(&[
923 ("servers", "nats://a:4222"),
924 ("subject", "x"),
925 ("max.pending", "0"),
926 ]))
927 .unwrap_err()
928 .to_string();
929 assert!(err.contains("max.pending must be > 0"), "got: {err}");
930 }
931
932 #[test]
933 fn sink_max_pending_is_capped_below_the_client_publish_window() {
934 let err = NatsSinkConfig::from_config(&cfg(&[
935 ("servers", "nats://a:4222"),
936 ("subject", "x"),
937 ("max.pending", "4097"),
938 ]))
939 .unwrap_err()
940 .to_string();
941 assert!(err.contains("max.pending must be <= 4096"), "got: {err}");
942 }
943
944 #[test]
945 fn sink_ack_timeout_has_a_fixed_client_side_ceiling() {
946 let err = NatsSinkConfig::from_config(&cfg(&[
947 ("servers", "nats://a:4222"),
948 ("subject", "x"),
949 ("ack.timeout.ms", "300001"),
950 ]))
951 .unwrap_err()
952 .to_string();
953 assert!(
954 err.contains("ack.timeout.ms must be <= 300000"),
955 "got: {err}"
956 );
957 }
958
959 #[test]
960 fn servers_empty_csv_rejected() {
961 let err = NatsSourceConfig::from_config(&cfg(&[("servers", ",,")]))
962 .unwrap_err()
963 .to_string();
964 assert!(err.contains("LDB-5030"), "got: {err}");
965 }
966
967 fn jetstream_happy(pairs: &[(&str, &str)]) -> ConnectorConfig {
970 let mut base = vec![
971 ("servers", "nats://a:4222"),
972 ("stream", "S"),
973 ("consumer", "c"),
974 ("subject", "x.>"),
975 ];
976 base.extend_from_slice(pairs);
977 cfg(&base)
978 }
979
980 #[test]
981 fn auth_user_pass_requires_user() {
982 let err = NatsSourceConfig::from_config(&jetstream_happy(&[
983 ("auth.mode", "user_pass"),
984 ("password", "secret"),
985 ]))
986 .unwrap_err()
987 .to_string();
988 assert!(err.contains("LDB-5060"), "got: {err}");
989 }
990
991 #[test]
992 fn auth_user_pass_requires_password() {
993 let err = NatsSourceConfig::from_config(&jetstream_happy(&[
994 ("auth.mode", "user_pass"),
995 ("user", "alice"),
996 ]))
997 .unwrap_err()
998 .to_string();
999 assert!(err.contains("LDB-5060"), "got: {err}");
1000 }
1001
1002 #[test]
1003 fn auth_user_pass_happy_path() {
1004 let parsed = NatsSourceConfig::from_config(&jetstream_happy(&[
1005 ("auth.mode", "user_pass"),
1006 ("user", "alice"),
1007 ("password", "wonderland"),
1008 ]))
1009 .unwrap();
1010 assert_eq!(
1011 parsed.auth,
1012 AuthMode::UserPass {
1013 user: "alice".into(),
1014 password: "wonderland".into(),
1015 }
1016 );
1017 }
1018
1019 #[test]
1020 fn auth_token_requires_token() {
1021 let err = NatsSourceConfig::from_config(&jetstream_happy(&[("auth.mode", "token")]))
1022 .unwrap_err()
1023 .to_string();
1024 assert!(err.contains("LDB-5061"), "got: {err}");
1025 }
1026
1027 #[test]
1028 fn auth_token_happy_path() {
1029 let parsed = NatsSourceConfig::from_config(&jetstream_happy(&[
1030 ("auth.mode", "token"),
1031 ("token", "abc123"),
1032 ]))
1033 .unwrap();
1034 assert_eq!(parsed.auth, AuthMode::Token("abc123".into()));
1035 }
1036
1037 #[test]
1038 fn auth_none_with_stray_credentials_rejected() {
1039 let err = NatsSourceConfig::from_config(&jetstream_happy(&[("user", "alice")]))
1040 .unwrap_err()
1041 .to_string();
1042 assert!(err.contains("LDB-5063"), "got: {err}");
1043 }
1044
1045 #[test]
1046 fn auth_creds_file_requires_path() {
1047 let err = NatsSourceConfig::from_config(&jetstream_happy(&[("auth.mode", "creds_file")]))
1048 .unwrap_err()
1049 .to_string();
1050 assert!(err.contains("LDB-5064"), "got: {err}");
1051 }
1052
1053 #[test]
1054 fn auth_creds_file_happy_path() {
1055 let parsed = NatsSourceConfig::from_config(&jetstream_happy(&[
1056 ("auth.mode", "creds_file"),
1057 ("creds.file", "/secrets/user.creds"),
1058 ]))
1059 .unwrap();
1060 assert_eq!(
1061 parsed.auth,
1062 AuthMode::CredsFile("/secrets/user.creds".into())
1063 );
1064 }
1065
1066 #[test]
1067 fn auth_unknown_mode_rejected() {
1068 let err = NatsSourceConfig::from_config(&jetstream_happy(&[("auth.mode", "banana")]))
1069 .unwrap_err()
1070 .to_string();
1071 assert!(err.contains("invalid auth.mode"), "got: {err}");
1072 }
1073
1074 #[test]
1077 fn tls_cert_without_key_rejected() {
1078 let err = NatsSourceConfig::from_config(&jetstream_happy(&[(
1079 "tls.cert.location",
1080 "/certs/client.pem",
1081 )]))
1082 .unwrap_err()
1083 .to_string();
1084 assert!(err.contains("LDB-5062"), "got: {err}");
1085 }
1086
1087 #[test]
1088 fn tls_key_without_cert_rejected() {
1089 let err = NatsSourceConfig::from_config(&jetstream_happy(&[(
1090 "tls.key.location",
1091 "/certs/client.key",
1092 )]))
1093 .unwrap_err()
1094 .to_string();
1095 assert!(err.contains("LDB-5062"), "got: {err}");
1096 }
1097
1098 #[test]
1099 fn tls_happy_path() {
1100 let parsed = NatsSourceConfig::from_config(&jetstream_happy(&[
1101 ("tls.enabled", "true"),
1102 ("tls.ca.location", "/certs/ca.pem"),
1103 ("tls.cert.location", "/certs/client.pem"),
1104 ("tls.key.location", "/certs/client.key"),
1105 ]))
1106 .unwrap();
1107 assert!(parsed.tls.enabled);
1108 assert_eq!(parsed.tls.ca_location.as_deref(), Some("/certs/ca.pem"));
1109 assert_eq!(
1110 parsed.tls.cert_location.as_deref(),
1111 Some("/certs/client.pem")
1112 );
1113 }
1114
1115 #[test]
1116 fn auth_and_tls_on_sink() {
1117 let parsed = NatsSinkConfig::from_config(&cfg(&[
1118 ("servers", "nats://a:4222"),
1119 ("stream", "OUT"),
1120 ("subject", "x"),
1121 ("auth.mode", "user_pass"),
1122 ("user", "alice"),
1123 ("password", "wonderland"),
1124 ("tls.enabled", "true"),
1125 ]))
1126 .unwrap();
1127 assert!(matches!(parsed.auth, AuthMode::UserPass { .. }));
1128 assert!(parsed.tls.enabled);
1129 }
1130}