Skip to main content

laminar_connectors/kafka/
config.rs

1//! Kafka source config — broker connection, format, Schema Registry,
2//! backpressure, and pass-through `rdkafka` properties.
3
4use std::collections::HashMap;
5use std::time::Duration;
6
7use rdkafka::config::ClientConfig;
8
9use crate::config::ConnectorConfig;
10use crate::error::ConnectorError;
11use crate::serde::Format;
12
13/// Kafka security protocol for broker connections.
14///
15/// Determines encryption (SSL/TLS) and authentication (SASL) requirements.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum SecurityProtocol {
18    /// Plain-text communication (no encryption, no authentication).
19    #[default]
20    Plaintext,
21    /// SSL/TLS encryption without SASL authentication.
22    Ssl,
23    /// SASL authentication over plain-text connection.
24    SaslPlaintext,
25    /// SASL authentication over SSL/TLS encrypted connection.
26    SaslSsl,
27}
28
29impl SecurityProtocol {
30    /// Returns the rdkafka config value string.
31    #[must_use]
32    pub fn as_rdkafka_str(&self) -> &'static str {
33        match self {
34            SecurityProtocol::Plaintext => "plaintext",
35            SecurityProtocol::Ssl => "ssl",
36            SecurityProtocol::SaslPlaintext => "sasl_plaintext",
37            SecurityProtocol::SaslSsl => "sasl_ssl",
38        }
39    }
40
41    /// Returns true if this protocol uses SSL/TLS.
42    #[must_use]
43    pub fn uses_ssl(&self) -> bool {
44        matches!(self, SecurityProtocol::Ssl | SecurityProtocol::SaslSsl)
45    }
46
47    /// Returns true if this protocol uses SASL authentication.
48    #[must_use]
49    pub fn uses_sasl(&self) -> bool {
50        matches!(
51            self,
52            SecurityProtocol::SaslPlaintext | SecurityProtocol::SaslSsl
53        )
54    }
55}
56
57str_enum!(fromstr SecurityProtocol, lowercase, ConnectorError, "invalid security.protocol",
58    Plaintext => "plaintext";
59    Ssl => "ssl";
60    SaslPlaintext => "sasl_plaintext";
61    SaslSsl => "sasl_ssl"
62);
63
64impl std::fmt::Display for SecurityProtocol {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        write!(f, "{}", self.as_rdkafka_str())
67    }
68}
69
70/// SASL authentication mechanism for Kafka.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub enum SaslMechanism {
73    /// PLAIN: Simple username/password authentication.
74    #[default]
75    Plain,
76    /// SCRAM-SHA-256: Salted Challenge Response Authentication Mechanism.
77    ScramSha256,
78    /// SCRAM-SHA-512: Salted Challenge Response Authentication Mechanism (stronger).
79    ScramSha512,
80    /// GSSAPI: Kerberos authentication.
81    Gssapi,
82    /// OAUTHBEARER: OAuth 2.0 bearer token authentication.
83    Oauthbearer,
84}
85
86impl SaslMechanism {
87    /// Returns the rdkafka config value string.
88    #[must_use]
89    pub fn as_rdkafka_str(&self) -> &'static str {
90        match self {
91            SaslMechanism::Plain => "PLAIN",
92            SaslMechanism::ScramSha256 => "SCRAM-SHA-256",
93            SaslMechanism::ScramSha512 => "SCRAM-SHA-512",
94            SaslMechanism::Gssapi => "GSSAPI",
95            SaslMechanism::Oauthbearer => "OAUTHBEARER",
96        }
97    }
98
99    /// Returns true if this mechanism requires username/password.
100    #[must_use]
101    pub fn requires_credentials(&self) -> bool {
102        matches!(
103            self,
104            SaslMechanism::Plain | SaslMechanism::ScramSha256 | SaslMechanism::ScramSha512
105        )
106    }
107}
108
109str_enum!(fromstr SaslMechanism, uppercase, ConnectorError, "invalid sasl.mechanism",
110    Plain => "PLAIN";
111    ScramSha256 => "SCRAM_SHA_256", "SCRAM_SHA256";
112    ScramSha512 => "SCRAM_SHA_512", "SCRAM_SHA512";
113    Gssapi => "GSSAPI", "KERBEROS";
114    Oauthbearer => "OAUTHBEARER", "OAUTH"
115);
116
117impl std::fmt::Display for SaslMechanism {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        write!(f, "{}", self.as_rdkafka_str())
120    }
121}
122
123/// Consumer isolation level for reading transactional messages.
124///
125/// Controls whether to read uncommitted messages from transactional producers.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
127pub enum IsolationLevel {
128    /// Read all messages including uncommitted transactional messages.
129    ReadUncommitted,
130    /// Only read committed messages (recommended for transactional pipelines).
131    #[default]
132    ReadCommitted,
133}
134
135impl IsolationLevel {
136    /// Returns the rdkafka config value string.
137    #[must_use]
138    pub fn as_rdkafka_str(&self) -> &'static str {
139        match self {
140            IsolationLevel::ReadUncommitted => "read_uncommitted",
141            IsolationLevel::ReadCommitted => "read_committed",
142        }
143    }
144}
145
146str_enum!(fromstr IsolationLevel, lowercase, ConnectorError, "invalid isolation.level",
147    ReadUncommitted => "read_uncommitted";
148    ReadCommitted => "read_committed"
149);
150
151impl std::fmt::Display for IsolationLevel {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        write!(f, "{}", self.as_rdkafka_str())
154    }
155}
156
157/// Consumer startup mode controlling where to begin consuming.
158///
159/// This is a higher-level abstraction than `auto.offset.reset` that provides
160/// more control over initial positioning, including timestamp-based and
161/// partition-specific offset assignment.
162#[derive(Debug, Clone, PartialEq, Eq, Default)]
163pub enum StartupMode {
164    /// Use committed group offsets, fall back to `auto.offset.reset` if none exist.
165    #[default]
166    GroupOffsets,
167    /// Start from the earliest available offset in each partition.
168    Earliest,
169    /// Start from the latest offset in each partition (only new messages).
170    Latest,
171    /// Start from specific offsets per partition (`partition_id` -> offset).
172    SpecificOffsets(HashMap<i32, i64>),
173    /// Start from a specific timestamp (milliseconds since epoch).
174    /// The consumer seeks to the first message with timestamp >= this value.
175    Timestamp(i64),
176}
177
178impl std::str::FromStr for StartupMode {
179    type Err = ConnectorError;
180
181    fn from_str(s: &str) -> Result<Self, Self::Err> {
182        match s.to_lowercase().replace('-', "_").as_str() {
183            "group_offsets" | "group" => Ok(StartupMode::GroupOffsets),
184            "earliest" => Ok(StartupMode::Earliest),
185            "latest" => Ok(StartupMode::Latest),
186            "timestamp" => Err(ConnectorError::ConfigurationError(
187                "use 'startup.timestamp.ms' to start from a timestamp, \
188                 not 'startup.mode = timestamp'"
189                    .into(),
190            )),
191            other => Err(ConnectorError::ConfigurationError(format!(
192                "invalid startup.mode: '{other}' (expected group-offsets/earliest/latest)"
193            ))),
194        }
195    }
196}
197
198impl std::fmt::Display for StartupMode {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        match self {
201            StartupMode::GroupOffsets => write!(f, "group-offsets"),
202            StartupMode::Earliest => write!(f, "earliest"),
203            StartupMode::Latest => write!(f, "latest"),
204            StartupMode::SpecificOffsets(offsets) => {
205                write!(f, "specific-offsets({} partitions)", offsets.len())
206            }
207            StartupMode::Timestamp(ts) => write!(f, "timestamp({ts})"),
208        }
209    }
210}
211
212/// Topic subscription mode: explicit list or regex pattern.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum TopicSubscription {
215    /// Subscribe to a specific list of topic names.
216    Topics(Vec<String>),
217    /// Subscribe to topics matching a regex pattern (e.g., `events-.*`).
218    Pattern(String),
219}
220
221impl TopicSubscription {
222    /// Returns the topic names if this is a `Topics` subscription.
223    #[must_use]
224    pub fn topics(&self) -> Option<&[String]> {
225        match self {
226            TopicSubscription::Topics(t) => Some(t),
227            TopicSubscription::Pattern(_) => None,
228        }
229    }
230
231    /// Returns the pattern if this is a `Pattern` subscription.
232    #[must_use]
233    pub fn pattern(&self) -> Option<&str> {
234        match self {
235            TopicSubscription::Topics(_) => None,
236            TopicSubscription::Pattern(p) => Some(p),
237        }
238    }
239
240    /// Returns true if this is a pattern-based subscription.
241    #[must_use]
242    pub fn is_pattern(&self) -> bool {
243        matches!(self, TopicSubscription::Pattern(_))
244    }
245}
246
247impl Default for TopicSubscription {
248    fn default() -> Self {
249        TopicSubscription::Topics(Vec::new())
250    }
251}
252
253/// Auto-offset reset policy for new consumer groups.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum OffsetReset {
256    /// Start from the earliest available offset.
257    Earliest,
258    /// Start from the latest offset (only new messages).
259    Latest,
260    /// Fail if no committed offset exists.
261    None,
262}
263
264impl OffsetReset {
265    /// Returns the rdkafka config value string.
266    #[must_use]
267    pub fn as_rdkafka_str(&self) -> &'static str {
268        match self {
269            OffsetReset::Earliest => "earliest",
270            OffsetReset::Latest => "latest",
271            OffsetReset::None => "error",
272        }
273    }
274}
275
276str_enum!(fromstr OffsetReset, lowercase_nodash, ConnectorError, "invalid auto.offset.reset",
277    Earliest => "earliest", "beginning";
278    Latest => "latest", "end";
279    None => "none", "error"
280);
281
282/// Kafka partition assignment strategy.
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum AssignmentStrategy {
285    /// Range assignment (default).
286    Range,
287    /// Round-robin assignment.
288    RoundRobin,
289    /// Cooperative sticky assignment.
290    CooperativeSticky,
291}
292
293impl AssignmentStrategy {
294    /// Returns the rdkafka config value string.
295    #[must_use]
296    pub fn as_rdkafka_str(&self) -> &'static str {
297        match self {
298            AssignmentStrategy::Range => "range",
299            AssignmentStrategy::RoundRobin => "roundrobin",
300            AssignmentStrategy::CooperativeSticky => "cooperative-sticky",
301        }
302    }
303}
304
305str_enum!(fromstr AssignmentStrategy, lowercase_nodash, ConnectorError,
306    "invalid partition.assignment.strategy",
307    Range => "range";
308    RoundRobin => "roundrobin", "round-robin", "round_robin";
309    CooperativeSticky => "cooperative-sticky", "cooperative_sticky"
310);
311
312/// Schema Registry compatibility level.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum CompatibilityLevel {
315    /// New schema can read old data.
316    Backward,
317    /// Backward compatible with all prior versions.
318    BackwardTransitive,
319    /// Old schema can read new data.
320    Forward,
321    /// Forward compatible with all prior versions.
322    ForwardTransitive,
323    /// Both backward and forward compatible.
324    Full,
325    /// Full compatible with all prior versions.
326    FullTransitive,
327    /// No compatibility checking.
328    None,
329}
330
331impl CompatibilityLevel {
332    /// Returns the Schema Registry API string.
333    #[must_use]
334    pub fn as_str(&self) -> &'static str {
335        match self {
336            CompatibilityLevel::Backward => "BACKWARD",
337            CompatibilityLevel::BackwardTransitive => "BACKWARD_TRANSITIVE",
338            CompatibilityLevel::Forward => "FORWARD",
339            CompatibilityLevel::ForwardTransitive => "FORWARD_TRANSITIVE",
340            CompatibilityLevel::Full => "FULL",
341            CompatibilityLevel::FullTransitive => "FULL_TRANSITIVE",
342            CompatibilityLevel::None => "NONE",
343        }
344    }
345}
346
347str_enum!(fromstr CompatibilityLevel, uppercase, ConnectorError, "invalid schema.compatibility",
348    Backward => "BACKWARD";
349    BackwardTransitive => "BACKWARD_TRANSITIVE";
350    Forward => "FORWARD";
351    ForwardTransitive => "FORWARD_TRANSITIVE";
352    Full => "FULL";
353    FullTransitive => "FULL_TRANSITIVE";
354    None => "NONE"
355);
356
357impl std::fmt::Display for CompatibilityLevel {
358    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359        write!(f, "{}", self.as_str())
360    }
361}
362
363/// Strategy for handling Avro schema evolution at runtime.
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
365pub enum SchemaEvolutionStrategy {
366    /// Log schema changes, continue processing.
367    #[default]
368    Log,
369    /// Return an error on incompatible schema changes.
370    Reject,
371    /// No detection — skip schema diffing entirely.
372    Ignore,
373}
374
375str_enum!(fromstr SchemaEvolutionStrategy, lowercase, ConnectorError,
376    "invalid schema.evolution.strategy",
377    Log => "log";
378    Reject => "reject";
379    Ignore => "ignore"
380);
381
382impl std::fmt::Display for SchemaEvolutionStrategy {
383    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384        match self {
385            SchemaEvolutionStrategy::Log => write!(f, "log"),
386            SchemaEvolutionStrategy::Reject => write!(f, "reject"),
387            SchemaEvolutionStrategy::Ignore => write!(f, "ignore"),
388        }
389    }
390}
391
392/// Confluent subject-name strategy.
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
394pub enum SubjectNameStrategy {
395    /// `{topic}-value` (Confluent default).
396    #[default]
397    TopicName,
398    /// `{record_name}-value`. Requires `schema.registry.record.name`.
399    RecordName,
400    /// `{topic}-{record_name}-value`. Requires `schema.registry.record.name`.
401    TopicRecordName,
402}
403
404str_enum!(fromstr SubjectNameStrategy, lowercase_nodash, ConnectorError,
405    "invalid schema.registry.subject.name.strategy",
406    TopicName => "topic-name", "topicname", "topicnamestrategy";
407    RecordName => "record-name", "recordname", "recordnamestrategy";
408    TopicRecordName => "topic-record-name", "topicrecordname", "topicrecordnamestrategy"
409);
410
411impl std::fmt::Display for SubjectNameStrategy {
412    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
413        match self {
414            SubjectNameStrategy::TopicName => write!(f, "topic-name"),
415            SubjectNameStrategy::RecordName => write!(f, "record-name"),
416            SubjectNameStrategy::TopicRecordName => write!(f, "topic-record-name"),
417        }
418    }
419}
420
421/// Build the SR `-value` subject for a topic. `from_config` validates
422/// that `record_name` is present for the record-based strategies, so
423/// the `expect`s are unreachable in practice.
424pub(crate) fn resolve_value_subject(
425    strategy: SubjectNameStrategy,
426    record_name: Option<&str>,
427    topic: &str,
428) -> String {
429    let name = || record_name.expect("from_config validates record.name");
430    match strategy {
431        SubjectNameStrategy::TopicName => format!("{topic}-value"),
432        SubjectNameStrategy::RecordName => format!("{}-value", name()),
433        SubjectNameStrategy::TopicRecordName => format!("{topic}-{}-value", name()),
434    }
435}
436
437/// Maps the Kafka-level [`CompatibilityLevel`] to the schema module's
438/// `CompatibilityMode` for evolution evaluation.
439impl From<CompatibilityLevel> for crate::schema::traits::CompatibilityMode {
440    fn from(level: CompatibilityLevel) -> Self {
441        use crate::schema::traits::CompatibilityMode;
442        match level {
443            CompatibilityLevel::Backward => CompatibilityMode::Backward,
444            CompatibilityLevel::BackwardTransitive => CompatibilityMode::BackwardTransitive,
445            CompatibilityLevel::Forward => CompatibilityMode::Forward,
446            CompatibilityLevel::ForwardTransitive => CompatibilityMode::ForwardTransitive,
447            CompatibilityLevel::Full => CompatibilityMode::Full,
448            CompatibilityLevel::FullTransitive => CompatibilityMode::FullTransitive,
449            CompatibilityLevel::None => CompatibilityMode::None,
450        }
451    }
452}
453
454/// Schema Registry authentication credentials.
455#[derive(Clone)]
456pub struct SrAuth {
457    /// Basic auth username.
458    pub username: String,
459    /// Basic auth password.
460    pub password: String,
461}
462
463impl std::fmt::Debug for SrAuth {
464    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
465        f.debug_struct("SrAuth")
466            .field("username", &self.username)
467            .field("password", &"***")
468            .finish()
469    }
470}
471
472/// Kafka source connector configuration.
473///
474/// Uses a custom `Debug` impl that redacts `sasl_password` and
475/// `ssl_key_password` to prevent credential leakage in logs.
476#[derive(Clone)]
477#[allow(clippy::struct_excessive_bools)] // Config struct — each bool is an independent user-facing knob.
478pub struct KafkaSourceConfig {
479    // -- Required --
480    /// Comma-separated list of broker addresses.
481    pub bootstrap_servers: String,
482    /// Consumer group identifier.
483    pub group_id: String,
484    /// Topic subscription (explicit list or regex pattern).
485    pub subscription: TopicSubscription,
486
487    // -- Security --
488    /// Security protocol for broker connections.
489    pub security_protocol: SecurityProtocol,
490    /// SASL authentication mechanism.
491    pub sasl_mechanism: Option<SaslMechanism>,
492    /// SASL username (for PLAIN, SCRAM-SHA-256, SCRAM-SHA-512).
493    pub sasl_username: Option<String>,
494    /// SASL password (for PLAIN, SCRAM-SHA-256, SCRAM-SHA-512).
495    pub sasl_password: Option<String>,
496    /// Path to SSL CA certificate file (PEM format).
497    pub ssl_ca_location: Option<String>,
498    /// Path to client SSL certificate file (PEM format).
499    pub ssl_certificate_location: Option<String>,
500    /// Path to client SSL private key file (PEM format).
501    pub ssl_key_location: Option<String>,
502    /// Password for encrypted SSL private key.
503    pub ssl_key_password: Option<String>,
504
505    // -- Format & Schema --
506    /// Data format for deserialization.
507    pub format: Format,
508    /// Confluent Schema Registry URL.
509    pub schema_registry_url: Option<String>,
510    /// Schema Registry authentication credentials.
511    pub schema_registry_auth: Option<SrAuth>,
512    /// Override compatibility level for the subject.
513    pub schema_compatibility: Option<CompatibilityLevel>,
514    /// How to handle Avro schema evolution detected at runtime.
515    pub schema_evolution_strategy: SchemaEvolutionStrategy,
516    /// Schema Registry SSL CA certificate path.
517    pub schema_registry_ssl_ca_location: Option<String>,
518    /// Schema Registry SSL client certificate path.
519    pub schema_registry_ssl_certificate_location: Option<String>,
520    /// Schema Registry SSL client key path.
521    pub schema_registry_ssl_key_location: Option<String>,
522    /// Confluent subject-name strategy. Default: `TopicName`.
523    pub schema_registry_subject_strategy: SubjectNameStrategy,
524    /// Record name for `record-name` / `topic-record-name` strategies.
525    pub schema_registry_record_name: Option<String>,
526    /// Deadline for Schema Registry lookups performed during schema
527    /// auto-discovery at DDL time. Default: 10s.
528    pub schema_registry_discovery_timeout: Duration,
529    /// Whether to include Kafka metadata columns (_partition, _offset, _timestamp).
530    pub include_metadata: bool,
531    /// Whether to include Kafka headers as a map column (_headers).
532    pub include_headers: bool,
533
534    // -- Consumer tuning --
535    /// Consumer startup mode (controls initial offset positioning).
536    pub startup_mode: StartupMode,
537    /// Where to start reading when no committed offset exists.
538    pub auto_offset_reset: OffsetReset,
539    /// Consumer transaction isolation level.
540    pub isolation_level: IsolationLevel,
541    /// Maximum records per poll batch.
542    pub max_poll_records: usize,
543    /// Partition assignment strategy.
544    pub partition_assignment_strategy: AssignmentStrategy,
545    /// Minimum bytes to return from a fetch (allows batching).
546    pub fetch_min_bytes: Option<i32>,
547    /// Maximum bytes to return from broker per request.
548    pub fetch_max_bytes: Option<i32>,
549    /// Maximum time broker waits for fetch.min.bytes.
550    pub fetch_max_wait_ms: Option<i32>,
551    /// Maximum bytes per partition to return from broker.
552    pub max_partition_fetch_bytes: Option<i32>,
553
554    // -- Consumer group timing --
555    /// Consumer session timeout (default: 45s — production-safe; rdkafka's
556    /// aggressive 10s default causes rebalance storms under GC pauses).
557    pub session_timeout: Duration,
558    /// Consumer heartbeat interval (default: 10s). Must satisfy
559    /// `heartbeat_interval * 3 < session_timeout` per Kafka broker requirement.
560    pub heartbeat_interval: Duration,
561    /// Maximum interval between calls to `rd_kafka_consumer_poll()` before
562    /// the broker considers this consumer dead and triggers a rebalance.
563    ///
564    /// Default: 600s (10 minutes). rdkafka's default is 300s, but with
565    /// reader-side pause/resume the reader keeps polling even under
566    /// backpressure, so this is a safety margin. Must be >= 60s.
567    pub max_poll_interval: Duration,
568    /// Maximum per-partition pre-fetch queue size in kbytes (default: 16384 = 16MB).
569    /// rdkafka's 64MB default is too aggressive for an embedded database.
570    pub queued_max_messages_kbytes: u32,
571
572    // -- Broker commit --
573    /// Whether to commit consumed offsets to the Kafka broker after each
574    /// `LaminarDB` checkpoint completes. Default: `true`.
575    ///
576    /// Advisory — `LaminarDB` recovery uses its own manifest, not broker-stored
577    /// offsets. This exists only so external tooling (`kafka-consumer-groups`,
578    /// kafka-exporter, Burrow) can observe progress. Guaranteed delivery always
579    /// derives an unrecorded partition's start from engine configuration and
580    /// deliberately ignores broker-stored group offsets from abandoned timelines.
581    pub broker_commit_on_checkpoint: bool,
582
583    // -- Backpressure --
584    /// Capacity of the bounded channel between the background Kafka reader
585    /// task and `poll_batch()` (default: 8192). Must be >= `max_poll_records`.
586    pub reader_channel_capacity: usize,
587    /// Channel fill ratio at which to pause consumption.
588    pub backpressure_high_watermark: f64,
589    /// Channel fill ratio at which to resume consumption.
590    pub backpressure_low_watermark: f64,
591
592    // -- Error handling --
593    /// Maximum tolerated deserialization error rate per `BestEffort` batch (0.0-1.0).
594    ///
595    /// When the poison pill fallback is active and the error rate exceeds
596    /// this threshold, the batch is rejected instead of returning partial
597    /// results. Guaranteed-delivery modes reject any deserialization failure;
598    /// they never use this threshold. Default: 0.5 (50%).
599    pub max_deser_error_rate: f64,
600
601    // -- Pass-through --
602    /// Additional rdkafka properties passed directly to librdkafka.
603    pub kafka_properties: HashMap<String, String>,
604}
605
606impl std::fmt::Debug for KafkaSourceConfig {
607    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
608        f.debug_struct("KafkaSourceConfig")
609            .field("bootstrap_servers", &self.bootstrap_servers)
610            .field("group_id", &self.group_id)
611            .field("subscription", &self.subscription)
612            .field("format", &self.format)
613            .field("security_protocol", &self.security_protocol)
614            .field("sasl_mechanism", &self.sasl_mechanism)
615            .field("sasl_username", &self.sasl_username)
616            .field("sasl_password", &self.sasl_password.as_ref().map(|_| "***"))
617            .field(
618                "ssl_key_password",
619                &self.ssl_key_password.as_ref().map(|_| "***"),
620            )
621            .field("max_poll_records", &self.max_poll_records)
622            .finish_non_exhaustive()
623    }
624}
625
626impl Default for KafkaSourceConfig {
627    fn default() -> Self {
628        Self {
629            bootstrap_servers: String::new(),
630            group_id: String::new(),
631            subscription: TopicSubscription::default(),
632            security_protocol: SecurityProtocol::default(),
633            sasl_mechanism: None,
634            sasl_username: None,
635            sasl_password: None,
636            ssl_ca_location: None,
637            ssl_certificate_location: None,
638            ssl_key_location: None,
639            ssl_key_password: None,
640            format: Format::Json,
641            schema_registry_url: None,
642            schema_registry_auth: None,
643            schema_compatibility: None,
644            schema_evolution_strategy: SchemaEvolutionStrategy::default(),
645            schema_registry_ssl_ca_location: None,
646            schema_registry_ssl_certificate_location: None,
647            schema_registry_ssl_key_location: None,
648            schema_registry_subject_strategy: SubjectNameStrategy::default(),
649            schema_registry_record_name: None,
650            schema_registry_discovery_timeout: Duration::from_secs(10),
651            include_metadata: false,
652            include_headers: false,
653            startup_mode: StartupMode::default(),
654            auto_offset_reset: OffsetReset::Earliest,
655            isolation_level: IsolationLevel::default(),
656            max_poll_records: 1000,
657            partition_assignment_strategy: AssignmentStrategy::Range,
658            fetch_min_bytes: None,
659            fetch_max_bytes: None,
660            fetch_max_wait_ms: None,
661            max_partition_fetch_bytes: None,
662            session_timeout: Duration::from_secs(45),
663            heartbeat_interval: Duration::from_secs(10),
664            max_poll_interval: Duration::from_secs(600),
665            queued_max_messages_kbytes: 16384,
666            broker_commit_on_checkpoint: true,
667            reader_channel_capacity: 8192,
668            backpressure_high_watermark: 0.8,
669            backpressure_low_watermark: 0.25,
670            max_deser_error_rate: 0.5,
671            kafka_properties: HashMap::new(),
672        }
673    }
674}
675
676impl KafkaSourceConfig {
677    /// Parses a [`KafkaSourceConfig`] from a [`ConnectorConfig`].
678    ///
679    /// # Errors
680    ///
681    /// Returns `ConnectorError` if required fields are missing or values are invalid.
682    #[allow(deprecated, clippy::too_many_lines)]
683    pub fn from_config(config: &ConnectorConfig) -> Result<Self, ConnectorError> {
684        let bootstrap_servers = config.require("bootstrap.servers")?.to_string();
685        let group_id = config.require("group.id")?.to_string();
686
687        let subscription = if let Some(pattern) = config.get("topic.pattern") {
688            TopicSubscription::Pattern(pattern.to_string())
689        } else {
690            let topics_str = config.require("topic")?;
691            let topics: Vec<String> = topics_str
692                .split(',')
693                .map(|s| s.trim().to_string())
694                .collect();
695            TopicSubscription::Topics(topics.clone())
696        };
697
698        let security_protocol = match config.get("security.protocol") {
699            Some(s) => s.parse::<SecurityProtocol>()?,
700            None => SecurityProtocol::default(),
701        };
702
703        let sasl_mechanism = match config.get("sasl.mechanism") {
704            Some(s) => Some(s.parse::<SaslMechanism>()?),
705            None => None,
706        };
707
708        let sasl_username = config.get("sasl.username").map(String::from);
709        let sasl_password = config.get("sasl.password").map(String::from);
710        let ssl_ca_location = config.get("ssl.ca.location").map(String::from);
711        let ssl_certificate_location = config.get("ssl.certificate.location").map(String::from);
712        let ssl_key_location = config.get("ssl.key.location").map(String::from);
713        let ssl_key_password = config.get("ssl.key.password").map(String::from);
714
715        let format = match config.get("format") {
716            Some(f) => f
717                .parse::<Format>()
718                .map_err(|e| ConnectorError::ConfigurationError(e.to_string()))?,
719            None => Format::Json,
720        };
721
722        let schema_registry_url = config.get("schema.registry.url").map(String::from);
723
724        let schema_registry_auth = match (
725            config.get("schema.registry.username"),
726            config.get("schema.registry.password"),
727        ) {
728            (Some(u), Some(p)) => Some(SrAuth {
729                username: u.to_string(),
730                password: p.to_string(),
731            }),
732            (Some(_), None) | (None, Some(_)) => {
733                return Err(ConnectorError::ConfigurationError(
734                    "schema.registry.username and schema.registry.password must both be set"
735                        .to_string(),
736                ));
737            }
738            (None, None) => None,
739        };
740
741        let schema_compatibility = match config.get("schema.compatibility") {
742            Some(s) => Some(s.parse::<CompatibilityLevel>()?),
743            None => None,
744        };
745
746        let schema_evolution_strategy = match config.get("schema.evolution.strategy") {
747            Some(s) => s.parse::<SchemaEvolutionStrategy>()?,
748            None => SchemaEvolutionStrategy::default(),
749        };
750
751        let schema_registry_ssl_ca_location = config
752            .get("schema.registry.ssl.ca.location")
753            .map(String::from);
754        let schema_registry_ssl_certificate_location = config
755            .get("schema.registry.ssl.certificate.location")
756            .map(String::from);
757        let schema_registry_ssl_key_location = config
758            .get("schema.registry.ssl.key.location")
759            .map(String::from);
760
761        let schema_registry_subject_strategy =
762            match config.get("schema.registry.subject.name.strategy") {
763                Some(s) => s.parse::<SubjectNameStrategy>()?,
764                None => SubjectNameStrategy::default(),
765            };
766
767        let schema_registry_record_name =
768            config.get("schema.registry.record.name").map(String::from);
769
770        if matches!(
771            schema_registry_subject_strategy,
772            SubjectNameStrategy::RecordName | SubjectNameStrategy::TopicRecordName
773        ) && schema_registry_record_name.is_none()
774        {
775            return Err(ConnectorError::ConfigurationError(format!(
776                "schema.registry.subject.name.strategy={schema_registry_subject_strategy} \
777                 requires schema.registry.record.name"
778            )));
779        }
780
781        let schema_registry_discovery_timeout = config
782            .get_parsed::<u64>("schema.registry.discovery.timeout.ms")?
783            .map_or(Duration::from_secs(10), Duration::from_millis);
784
785        let include_metadata = config
786            .get_parsed::<bool>("include.metadata")?
787            .unwrap_or(false);
788
789        let include_headers = config
790            .get_parsed::<bool>("include.headers")?
791            .unwrap_or(false);
792
793        let startup_mode = if let Some(offsets_str) = config.get("startup.specific.offsets") {
794            let offsets = parse_specific_offsets(offsets_str)?;
795            StartupMode::SpecificOffsets(offsets)
796        } else if let Some(ts_str) = config.get("startup.timestamp.ms") {
797            let ts: i64 = ts_str.parse().map_err(|_| {
798                ConnectorError::ConfigurationError(format!(
799                    "invalid startup.timestamp.ms: '{ts_str}'"
800                ))
801            })?;
802            StartupMode::Timestamp(ts)
803        } else {
804            match config.get("startup.mode") {
805                Some(s) => s.parse::<StartupMode>()?,
806                None => StartupMode::default(),
807            }
808        };
809
810        // StartupMode::Earliest/Latest override auto.offset.reset so that
811        // `startup.mode = latest` works without requiring the user to also
812        // set `auto.offset.reset = latest` (the previous disconnect was a
813        // silent data-correctness bug: consumers started from earliest).
814        let auto_offset_reset = match &startup_mode {
815            StartupMode::Earliest => OffsetReset::Earliest,
816            StartupMode::Latest => OffsetReset::Latest,
817            _ => match config.get("auto.offset.reset") {
818                Some(s) => s.parse::<OffsetReset>()?,
819                None => OffsetReset::Earliest,
820            },
821        };
822
823        let isolation_level = match config.get("isolation.level") {
824            Some(s) => s.parse::<IsolationLevel>()?,
825            None => IsolationLevel::default(),
826        };
827
828        let max_poll_records = config
829            .get_parsed::<usize>("max.poll.records")?
830            .unwrap_or(1000);
831
832        let partition_assignment_strategy = match config.get("partition.assignment.strategy") {
833            Some(s) => s.parse::<AssignmentStrategy>()?,
834            None => AssignmentStrategy::Range,
835        };
836
837        let fetch_min_bytes = config.get_parsed::<i32>("fetch.min.bytes")?;
838        let fetch_max_bytes = config.get_parsed::<i32>("fetch.max.bytes")?;
839        let fetch_max_wait_ms = config.get_parsed::<i32>("fetch.max.wait.ms")?;
840        let max_partition_fetch_bytes = config.get_parsed::<i32>("max.partition.fetch.bytes")?;
841
842        let session_timeout_ms = config
843            .get_parsed::<u64>("session.timeout.ms")?
844            .unwrap_or(45_000);
845        let heartbeat_interval_ms = config
846            .get_parsed::<u64>("heartbeat.interval.ms")?
847            .unwrap_or(10_000);
848        let queued_max_messages_kbytes = config
849            .get_parsed::<u32>("queued.max.messages.kbytes")?
850            .unwrap_or(16384);
851        let max_poll_interval_ms = config
852            .get_parsed::<u64>("max.poll.interval.ms")?
853            .unwrap_or(600_000);
854
855        // `broker.commit.interval.ms` is no longer supported. Broker offset
856        // commits now run on checkpoint completion (`notify_epoch_committed`),
857        // not on a wall-clock timer. Reject explicitly so silent semantic
858        // drift can't happen.
859        if config
860            .properties()
861            .contains_key("broker.commit.interval.ms")
862        {
863            return Err(ConnectorError::ConfigurationError(
864                "broker.commit.interval.ms is no longer supported — broker offset \
865                 commits now happen on checkpoint completion. Set \
866                 broker.commit.on.checkpoint=false to disable."
867                    .into(),
868            ));
869        }
870        let broker_commit_on_checkpoint = config
871            .get_parsed::<bool>("broker.commit.on.checkpoint")?
872            .unwrap_or(true);
873
874        let reader_channel_capacity = config
875            .get_parsed::<usize>("reader.channel.capacity")?
876            .unwrap_or(8192);
877
878        let backpressure_high_watermark = config
879            .get_parsed::<f64>("backpressure.high.watermark")?
880            .unwrap_or(0.8);
881
882        let backpressure_low_watermark = config
883            .get_parsed::<f64>("backpressure.low.watermark")?
884            .unwrap_or(0.25);
885
886        let max_deser_error_rate = config
887            .get_parsed::<f64>("max.deser.error.rate")?
888            .unwrap_or(0.5);
889
890        let kafka_properties = config.properties_with_prefix("kafka.");
891
892        let cfg = Self {
893            bootstrap_servers,
894            group_id,
895            subscription,
896            security_protocol,
897            sasl_mechanism,
898            sasl_username,
899            sasl_password,
900            ssl_ca_location,
901            ssl_certificate_location,
902            ssl_key_location,
903            ssl_key_password,
904            format,
905            schema_registry_url,
906            schema_registry_auth,
907            schema_compatibility,
908            schema_evolution_strategy,
909            schema_registry_ssl_ca_location,
910            schema_registry_ssl_certificate_location,
911            schema_registry_ssl_key_location,
912            schema_registry_subject_strategy,
913            schema_registry_record_name,
914            schema_registry_discovery_timeout,
915            include_metadata,
916            include_headers,
917            startup_mode,
918            auto_offset_reset,
919            isolation_level,
920            max_poll_records,
921            partition_assignment_strategy,
922            fetch_min_bytes,
923            fetch_max_bytes,
924            fetch_max_wait_ms,
925            max_partition_fetch_bytes,
926            session_timeout: Duration::from_millis(session_timeout_ms),
927            heartbeat_interval: Duration::from_millis(heartbeat_interval_ms),
928            max_poll_interval: Duration::from_millis(max_poll_interval_ms),
929            queued_max_messages_kbytes,
930            broker_commit_on_checkpoint,
931            reader_channel_capacity,
932            backpressure_high_watermark,
933            backpressure_low_watermark,
934            max_deser_error_rate,
935            kafka_properties,
936        };
937
938        cfg.validate()?;
939        Ok(cfg)
940    }
941
942    /// Validates the configuration.
943    ///
944    /// # Errors
945    ///
946    /// Returns `ConnectorError::ConfigurationError` if the configuration is invalid.
947    pub fn validate(&self) -> Result<(), ConnectorError> {
948        if self.bootstrap_servers.is_empty() {
949            return Err(ConnectorError::ConfigurationError(
950                "bootstrap.servers cannot be empty".into(),
951            ));
952        }
953        if self.group_id.is_empty() {
954            return Err(ConnectorError::ConfigurationError(
955                "group.id cannot be empty".into(),
956            ));
957        }
958
959        // Validate topic subscription
960        match &self.subscription {
961            TopicSubscription::Topics(t) if t.is_empty() => {
962                return Err(ConnectorError::ConfigurationError(
963                    "at least one topic is required (or use topic.pattern)".into(),
964                ));
965            }
966            TopicSubscription::Pattern(p) if p.is_empty() => {
967                return Err(ConnectorError::ConfigurationError(
968                    "topic.pattern cannot be empty".into(),
969                ));
970            }
971            _ => {}
972        }
973
974        if self.max_poll_records == 0 {
975            return Err(ConnectorError::ConfigurationError(
976                "max.poll.records must be > 0".into(),
977            ));
978        }
979        if self.reader_channel_capacity < self.max_poll_records {
980            return Err(ConnectorError::ConfigurationError(format!(
981                "reader.channel.capacity ({}) must be >= max.poll.records ({})",
982                self.reader_channel_capacity, self.max_poll_records
983            )));
984        }
985
986        if self.security_protocol.uses_sasl() && self.sasl_mechanism.is_none() {
987            return Err(ConnectorError::ConfigurationError(
988                "sasl.mechanism is required when security.protocol is sasl_plaintext or sasl_ssl"
989                    .into(),
990            ));
991        }
992
993        if let Some(mechanism) = &self.sasl_mechanism {
994            if mechanism.requires_credentials()
995                && (self.sasl_username.is_none() || self.sasl_password.is_none())
996            {
997                return Err(ConnectorError::ConfigurationError(format!(
998                    "sasl.username and sasl.password are required for {mechanism} mechanism"
999                )));
1000            }
1001        }
1002
1003        if self.security_protocol.uses_ssl() {
1004            if let Some(ref ca) = self.ssl_ca_location {
1005                if ca.is_empty() {
1006                    return Err(ConnectorError::ConfigurationError(
1007                        "ssl.ca.location cannot be empty when specified".into(),
1008                    ));
1009                }
1010            }
1011        }
1012
1013        if self.max_poll_interval.as_secs() < 60 {
1014            return Err(ConnectorError::ConfigurationError(format!(
1015                "max.poll.interval.ms ({}) must be >= 60000 (60s)",
1016                self.max_poll_interval.as_millis()
1017            )));
1018        }
1019
1020        // Kafka broker requires heartbeat_interval * 3 < session_timeout.
1021        if self.heartbeat_interval.as_millis() * 3 >= self.session_timeout.as_millis() {
1022            return Err(ConnectorError::ConfigurationError(format!(
1023                "heartbeat.interval.ms ({}) * 3 must be < session.timeout.ms ({})",
1024                self.heartbeat_interval.as_millis(),
1025                self.session_timeout.as_millis()
1026            )));
1027        }
1028
1029        if self.backpressure_high_watermark <= self.backpressure_low_watermark {
1030            return Err(ConnectorError::ConfigurationError(
1031                "backpressure.high.watermark must be > backpressure.low.watermark".into(),
1032            ));
1033        }
1034        if !(0.0..=1.0).contains(&self.backpressure_high_watermark) {
1035            return Err(ConnectorError::ConfigurationError(
1036                "backpressure.high.watermark must be between 0.0 and 1.0".into(),
1037            ));
1038        }
1039        if !(0.0..=1.0).contains(&self.backpressure_low_watermark) {
1040            return Err(ConnectorError::ConfigurationError(
1041                "backpressure.low.watermark must be between 0.0 and 1.0".into(),
1042            ));
1043        }
1044
1045        if !(0.0..=1.0).contains(&self.max_deser_error_rate) {
1046            return Err(ConnectorError::ConfigurationError(
1047                "max.deser.error.rate must be between 0.0 and 1.0".into(),
1048            ));
1049        }
1050
1051        if self.format == Format::Avro && self.schema_registry_url.is_none() {
1052            return Err(ConnectorError::ConfigurationError(
1053                "schema.registry.url is required when format is 'avro'".into(),
1054            ));
1055        }
1056
1057        Ok(())
1058    }
1059
1060    /// Builds an rdkafka [`ClientConfig`] from this configuration.
1061    #[must_use]
1062    pub fn to_rdkafka_config(&self) -> ClientConfig {
1063        let mut config = ClientConfig::new();
1064
1065        config.set("bootstrap.servers", &self.bootstrap_servers);
1066        config.set("group.id", &self.group_id);
1067        config.set("enable.auto.commit", "false");
1068        config.set("enable.auto.offset.store", "false");
1069        config.set("auto.offset.reset", self.auto_offset_reset.as_rdkafka_str());
1070        config.set(
1071            "partition.assignment.strategy",
1072            self.partition_assignment_strategy.as_rdkafka_str(),
1073        );
1074        config.set("security.protocol", self.security_protocol.as_rdkafka_str());
1075
1076        if let Some(ref mechanism) = self.sasl_mechanism {
1077            config.set("sasl.mechanism", mechanism.as_rdkafka_str());
1078        }
1079        if let Some(ref username) = self.sasl_username {
1080            config.set("sasl.username", username);
1081        }
1082        if let Some(ref password) = self.sasl_password {
1083            config.set("sasl.password", password);
1084        }
1085        if let Some(ref ca) = self.ssl_ca_location {
1086            config.set("ssl.ca.location", ca);
1087        }
1088        if let Some(ref cert) = self.ssl_certificate_location {
1089            config.set("ssl.certificate.location", cert);
1090        }
1091        if let Some(ref key) = self.ssl_key_location {
1092            config.set("ssl.key.location", key);
1093        }
1094        if let Some(ref key_pass) = self.ssl_key_password {
1095            config.set("ssl.key.password", key_pass);
1096        }
1097
1098        config.set("isolation.level", self.isolation_level.as_rdkafka_str());
1099        config.set(
1100            "session.timeout.ms",
1101            self.session_timeout.as_millis().to_string(),
1102        );
1103        config.set(
1104            "heartbeat.interval.ms",
1105            self.heartbeat_interval.as_millis().to_string(),
1106        );
1107        config.set(
1108            "queued.max.messages.kbytes",
1109            self.queued_max_messages_kbytes.to_string(),
1110        );
1111        config.set(
1112            "max.poll.interval.ms",
1113            self.max_poll_interval.as_millis().to_string(),
1114        );
1115
1116        if let Some(fetch_min) = self.fetch_min_bytes {
1117            config.set("fetch.min.bytes", fetch_min.to_string());
1118        }
1119
1120        if let Some(fetch_max) = self.fetch_max_bytes {
1121            config.set("fetch.max.bytes", fetch_max.to_string());
1122        }
1123
1124        if let Some(wait_ms) = self.fetch_max_wait_ms {
1125            config.set("fetch.wait.max.ms", wait_ms.to_string());
1126        }
1127
1128        if let Some(partition_max) = self.max_partition_fetch_bytes {
1129            config.set("max.partition.fetch.bytes", partition_max.to_string());
1130        }
1131
1132        // Apply pass-through properties, blocking security-critical keys
1133        // that could silently downgrade authentication or break semantics.
1134        for (key, value) in &self.kafka_properties {
1135            if is_blocked_passthrough_key(key) {
1136                tracing::warn!(
1137                    key,
1138                    "ignoring kafka.* pass-through property that overrides a security setting"
1139                );
1140                continue;
1141            }
1142            config.set(key, value);
1143        }
1144
1145        config
1146    }
1147}
1148
1149/// Returns `true` if a pass-through kafka.* key must not override explicit settings.
1150fn is_blocked_passthrough_key(key: &str) -> bool {
1151    key.starts_with("sasl.kerberos.")
1152        || matches!(
1153            key,
1154            "security.protocol"
1155                | "sasl.mechanism"
1156                | "sasl.username"
1157                | "sasl.password"
1158                | "sasl.oauthbearer.config"
1159                | "ssl.ca.location"
1160                | "ssl.certificate.location"
1161                | "ssl.key.location"
1162                | "ssl.key.password"
1163                | "ssl.endpoint.identification.algorithm"
1164                | "enable.auto.commit"
1165                | "enable.auto.offset.store"
1166                | "enable.idempotence"
1167                | "auto.offset.reset"
1168                | "session.timeout.ms"
1169                | "heartbeat.interval.ms"
1170                | "max.poll.interval.ms"
1171                | "queued.max.messages.kbytes"
1172                // librdkafka's own auto-commit interval — only meaningful
1173                // when `enable.auto.commit=true`, which we hard-disable.
1174                | "auto.commit.interval.ms"
1175        )
1176}
1177
1178/// Parses a specific offsets string in the format "partition:offset,partition:offset,...".
1179///
1180/// Example: "0:100,1:200,2:300" maps partition 0 to offset 100, partition 1 to offset 200, etc.
1181fn parse_specific_offsets(s: &str) -> Result<HashMap<i32, i64>, ConnectorError> {
1182    let mut offsets = HashMap::new();
1183
1184    for pair in s.split(',') {
1185        let pair = pair.trim();
1186        if pair.is_empty() {
1187            continue;
1188        }
1189
1190        let parts: Vec<&str> = pair.split(':').collect();
1191        if parts.len() != 2 {
1192            return Err(ConnectorError::ConfigurationError(format!(
1193                "invalid offset format '{pair}' (expected 'partition:offset')"
1194            )));
1195        }
1196
1197        let partition: i32 = parts[0].trim().parse().map_err(|_| {
1198            ConnectorError::ConfigurationError(format!(
1199                "invalid partition number '{}' in '{pair}'",
1200                parts[0]
1201            ))
1202        })?;
1203
1204        let offset: i64 = parts[1].trim().parse().map_err(|_| {
1205            ConnectorError::ConfigurationError(format!("invalid offset '{}' in '{pair}'", parts[1]))
1206        })?;
1207
1208        offsets.insert(partition, offset);
1209    }
1210
1211    if offsets.is_empty() {
1212        return Err(ConnectorError::ConfigurationError(
1213            "startup.specific.offsets cannot be empty".into(),
1214        ));
1215    }
1216
1217    Ok(offsets)
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222    use super::*;
1223
1224    fn make_config(extra: &[(&str, &str)]) -> ConnectorConfig {
1225        let mut config = ConnectorConfig::new("kafka");
1226        config.set("bootstrap.servers", "localhost:9092");
1227        config.set("group.id", "test-group");
1228        config.set("topic", "events");
1229        for (k, v) in extra {
1230            config.set(*k, *v);
1231        }
1232        config
1233    }
1234
1235    #[test]
1236    #[allow(deprecated)]
1237    fn test_parse_required_fields() {
1238        let cfg = KafkaSourceConfig::from_config(&make_config(&[])).unwrap();
1239        assert_eq!(cfg.bootstrap_servers, "localhost:9092");
1240        assert_eq!(cfg.group_id, "test-group");
1241        assert_eq!(cfg.subscription.topics().unwrap(), &["events"]);
1242        assert!(matches!(
1243            cfg.subscription,
1244            TopicSubscription::Topics(ref t) if t == &["events"]
1245        ));
1246    }
1247
1248    #[test]
1249    fn test_parse_missing_required() {
1250        let config = ConnectorConfig::new("kafka");
1251        assert!(KafkaSourceConfig::from_config(&config).is_err());
1252    }
1253
1254    #[test]
1255    #[allow(deprecated)]
1256    fn test_parse_multi_topic() {
1257        let cfg = KafkaSourceConfig::from_config(&make_config(&[("topic", "a, b, c")])).unwrap();
1258        assert_eq!(cfg.subscription.topics().unwrap(), &["a", "b", "c"]);
1259        assert!(matches!(
1260            cfg.subscription,
1261            TopicSubscription::Topics(ref t) if t == &["a", "b", "c"]
1262        ));
1263    }
1264
1265    #[test]
1266    fn test_parse_topic_pattern() {
1267        let mut config = ConnectorConfig::new("kafka");
1268        config.set("bootstrap.servers", "localhost:9092");
1269        config.set("group.id", "test-group");
1270        config.set("topic.pattern", "events-.*");
1271
1272        let cfg = KafkaSourceConfig::from_config(&config).unwrap();
1273        assert!(matches!(
1274            cfg.subscription,
1275            TopicSubscription::Pattern(ref p) if p == "events-.*"
1276        ));
1277        assert!(cfg.subscription.is_pattern());
1278        assert_eq!(cfg.subscription.pattern(), Some("events-.*"));
1279        assert!(cfg.subscription.topics().is_none());
1280    }
1281
1282    #[test]
1283    fn test_parse_defaults() {
1284        let cfg = KafkaSourceConfig::from_config(&make_config(&[])).unwrap();
1285        assert_eq!(cfg.format, Format::Json);
1286        assert_eq!(cfg.auto_offset_reset, OffsetReset::Earliest);
1287        assert_eq!(cfg.isolation_level, IsolationLevel::ReadCommitted);
1288        assert_eq!(cfg.max_poll_records, 1000);
1289        assert_eq!(cfg.partition_assignment_strategy, AssignmentStrategy::Range);
1290        assert!(!cfg.include_metadata);
1291        assert!(!cfg.include_headers);
1292        assert!(cfg.schema_registry_url.is_none());
1293        assert_eq!(cfg.security_protocol, SecurityProtocol::Plaintext);
1294        assert!(cfg.sasl_mechanism.is_none());
1295        assert!(cfg.broker_commit_on_checkpoint);
1296        assert_eq!(cfg.reader_channel_capacity, 8192);
1297        assert_eq!(cfg.backpressure_high_watermark, 0.8);
1298        assert_eq!(cfg.backpressure_low_watermark, 0.25);
1299    }
1300
1301    #[test]
1302    fn test_parse_broker_commit_on_checkpoint_disabled() {
1303        let cfg = KafkaSourceConfig::from_config(&make_config(&[(
1304            "broker.commit.on.checkpoint",
1305            "false",
1306        )]))
1307        .unwrap();
1308        assert!(!cfg.broker_commit_on_checkpoint);
1309    }
1310
1311    #[test]
1312    fn test_parse_broker_commit_interval_rejected() {
1313        let err =
1314            KafkaSourceConfig::from_config(&make_config(&[("broker.commit.interval.ms", "5000")]))
1315                .unwrap_err();
1316        let msg = format!("{err}");
1317        assert!(
1318            msg.contains("broker.commit.interval.ms"),
1319            "expected hard-error mentioning the deprecated key, got: {msg}"
1320        );
1321    }
1322
1323    #[test]
1324    fn test_parse_optional_fields() {
1325        let cfg = KafkaSourceConfig::from_config(&make_config(&[
1326            ("format", "csv"),
1327            ("auto.offset.reset", "latest"),
1328            ("max.poll.records", "500"),
1329            ("include.metadata", "true"),
1330            ("include.headers", "true"),
1331            ("partition.assignment.strategy", "roundrobin"),
1332            ("isolation.level", "read_uncommitted"),
1333        ]))
1334        .unwrap();
1335
1336        assert_eq!(cfg.format, Format::Csv);
1337        assert_eq!(cfg.auto_offset_reset, OffsetReset::Latest);
1338        assert_eq!(cfg.isolation_level, IsolationLevel::ReadUncommitted);
1339        assert_eq!(cfg.max_poll_records, 500);
1340        assert!(cfg.include_metadata);
1341        assert!(cfg.include_headers);
1342        assert_eq!(
1343            cfg.partition_assignment_strategy,
1344            AssignmentStrategy::RoundRobin
1345        );
1346    }
1347
1348    #[test]
1349    fn test_parse_security_sasl_ssl() {
1350        let cfg = KafkaSourceConfig::from_config(&make_config(&[
1351            ("security.protocol", "sasl_ssl"),
1352            ("sasl.mechanism", "SCRAM-SHA-256"),
1353            ("sasl.username", "alice"),
1354            ("sasl.password", "secret"),
1355            ("ssl.ca.location", "/etc/ssl/ca.pem"),
1356        ]))
1357        .unwrap();
1358
1359        assert_eq!(cfg.security_protocol, SecurityProtocol::SaslSsl);
1360        assert_eq!(cfg.sasl_mechanism, Some(SaslMechanism::ScramSha256));
1361        assert_eq!(cfg.sasl_username, Some("alice".to_string()));
1362        assert_eq!(cfg.sasl_password, Some("secret".to_string()));
1363        assert_eq!(cfg.ssl_ca_location, Some("/etc/ssl/ca.pem".to_string()));
1364    }
1365
1366    #[test]
1367    fn test_parse_security_ssl_only() {
1368        let cfg = KafkaSourceConfig::from_config(&make_config(&[
1369            ("security.protocol", "ssl"),
1370            ("ssl.ca.location", "/etc/ssl/ca.pem"),
1371            ("ssl.certificate.location", "/etc/ssl/client.pem"),
1372            ("ssl.key.location", "/etc/ssl/client.key"),
1373            ("ssl.key.password", "keypass"),
1374        ]))
1375        .unwrap();
1376
1377        assert_eq!(cfg.security_protocol, SecurityProtocol::Ssl);
1378        assert!(cfg.security_protocol.uses_ssl());
1379        assert!(!cfg.security_protocol.uses_sasl());
1380        assert_eq!(cfg.ssl_ca_location, Some("/etc/ssl/ca.pem".to_string()));
1381        assert_eq!(
1382            cfg.ssl_certificate_location,
1383            Some("/etc/ssl/client.pem".to_string())
1384        );
1385        assert_eq!(
1386            cfg.ssl_key_location,
1387            Some("/etc/ssl/client.key".to_string())
1388        );
1389        assert_eq!(cfg.ssl_key_password, Some("keypass".to_string()));
1390    }
1391
1392    #[test]
1393    fn test_parse_fetch_tuning() {
1394        let cfg = KafkaSourceConfig::from_config(&make_config(&[
1395            ("fetch.min.bytes", "1024"),
1396            ("fetch.max.bytes", "52428800"),
1397            ("fetch.max.wait.ms", "500"),
1398            ("max.partition.fetch.bytes", "1048576"),
1399        ]))
1400        .unwrap();
1401
1402        assert_eq!(cfg.fetch_min_bytes, Some(1024));
1403        assert_eq!(cfg.fetch_max_bytes, Some(52_428_800));
1404        assert_eq!(cfg.fetch_max_wait_ms, Some(500));
1405        assert_eq!(cfg.max_partition_fetch_bytes, Some(1_048_576));
1406    }
1407
1408    #[test]
1409    fn test_parse_kafka_passthrough() {
1410        let cfg = KafkaSourceConfig::from_config(&make_config(&[
1411            ("kafka.session.timeout.ms", "30000"),
1412            ("kafka.max.poll.interval.ms", "300000"),
1413            ("kafka.fetch.message.max.bytes", "2097152"),
1414        ]))
1415        .unwrap();
1416
1417        assert_eq!(cfg.kafka_properties.len(), 3);
1418        // session.timeout.ms and max.poll.interval.ms are passed through as
1419        // kafka_properties, but blocked by is_blocked_passthrough_key() in
1420        // to_rdkafka_config() — they won't override explicit settings.
1421        assert_eq!(
1422            cfg.kafka_properties.get("session.timeout.ms"),
1423            Some(&"30000".to_string())
1424        );
1425        assert_eq!(
1426            cfg.kafka_properties.get("max.poll.interval.ms"),
1427            Some(&"300000".to_string())
1428        );
1429    }
1430
1431    #[test]
1432    fn test_parse_schema_registry() {
1433        let cfg = KafkaSourceConfig::from_config(&make_config(&[
1434            ("format", "avro"),
1435            ("schema.registry.url", "http://localhost:8081"),
1436            ("schema.registry.username", "user"),
1437            ("schema.registry.password", "pass"),
1438            ("schema.compatibility", "FULL_TRANSITIVE"),
1439            ("schema.registry.ssl.ca.location", "/etc/ssl/sr-ca.pem"),
1440        ]))
1441        .unwrap();
1442
1443        assert_eq!(cfg.format, Format::Avro);
1444        assert_eq!(
1445            cfg.schema_registry_url,
1446            Some("http://localhost:8081".to_string())
1447        );
1448        assert!(cfg.schema_registry_auth.is_some());
1449        let auth = cfg.schema_registry_auth.unwrap();
1450        assert_eq!(auth.username, "user");
1451        assert_eq!(auth.password, "pass");
1452        assert_eq!(
1453            cfg.schema_compatibility,
1454            Some(CompatibilityLevel::FullTransitive)
1455        );
1456        assert_eq!(
1457            cfg.schema_registry_ssl_ca_location,
1458            Some("/etc/ssl/sr-ca.pem".to_string())
1459        );
1460    }
1461
1462    #[test]
1463    fn test_parse_sr_auth_partial() {
1464        let config = make_config(&[
1465            ("schema.registry.url", "http://localhost:8081"),
1466            ("schema.registry.username", "user"),
1467            // missing password
1468        ]);
1469        assert!(KafkaSourceConfig::from_config(&config).is_err());
1470    }
1471
1472    #[test]
1473    fn test_validate_avro_without_sr() {
1474        let mut cfg = KafkaSourceConfig::default();
1475        cfg.bootstrap_servers = "localhost:9092".into();
1476        cfg.group_id = "g".into();
1477        cfg.subscription = TopicSubscription::Topics(vec!["t".into()]);
1478        cfg.format = Format::Avro;
1479        // No schema_registry_url
1480        assert!(cfg.validate().is_err());
1481    }
1482
1483    #[test]
1484    fn test_validate_backpressure_watermarks() {
1485        let mut cfg = KafkaSourceConfig::default();
1486        cfg.bootstrap_servers = "localhost:9092".into();
1487        cfg.group_id = "g".into();
1488        cfg.subscription = TopicSubscription::Topics(vec!["t".into()]);
1489        cfg.backpressure_high_watermark = 0.3;
1490        cfg.backpressure_low_watermark = 0.5;
1491        assert!(cfg.validate().is_err());
1492    }
1493
1494    #[test]
1495    fn test_validate_sasl_without_mechanism() {
1496        let mut cfg = KafkaSourceConfig::default();
1497        cfg.bootstrap_servers = "localhost:9092".into();
1498        cfg.group_id = "g".into();
1499        cfg.subscription = TopicSubscription::Topics(vec!["t".into()]);
1500        cfg.security_protocol = SecurityProtocol::SaslPlaintext;
1501        // sasl_mechanism not set
1502        assert!(cfg.validate().is_err());
1503    }
1504
1505    #[test]
1506    fn test_validate_sasl_plain_without_credentials() {
1507        let mut cfg = KafkaSourceConfig::default();
1508        cfg.bootstrap_servers = "localhost:9092".into();
1509        cfg.group_id = "g".into();
1510        cfg.subscription = TopicSubscription::Topics(vec!["t".into()]);
1511        cfg.security_protocol = SecurityProtocol::SaslPlaintext;
1512        cfg.sasl_mechanism = Some(SaslMechanism::Plain);
1513        // username/password not set
1514        assert!(cfg.validate().is_err());
1515    }
1516
1517    #[test]
1518    fn test_validate_empty_topic_pattern() {
1519        let mut cfg = KafkaSourceConfig::default();
1520        cfg.bootstrap_servers = "localhost:9092".into();
1521        cfg.group_id = "g".into();
1522        cfg.subscription = TopicSubscription::Pattern(String::new());
1523        assert!(cfg.validate().is_err());
1524    }
1525
1526    #[test]
1527    fn test_rdkafka_config() {
1528        let cfg = KafkaSourceConfig::from_config(&make_config(&[
1529            ("auto.offset.reset", "latest"),
1530            ("kafka.fetch.min.bytes", "1024"),
1531        ]))
1532        .unwrap();
1533
1534        let rdkafka = cfg.to_rdkafka_config();
1535        assert_eq!(rdkafka.get("bootstrap.servers"), Some("localhost:9092"));
1536        assert_eq!(rdkafka.get("group.id"), Some("test-group"));
1537        assert_eq!(rdkafka.get("enable.auto.commit"), Some("false"));
1538        assert_eq!(rdkafka.get("auto.offset.reset"), Some("latest"));
1539        assert_eq!(rdkafka.get("fetch.min.bytes"), Some("1024"));
1540        assert_eq!(rdkafka.get("security.protocol"), Some("plaintext"));
1541        assert_eq!(rdkafka.get("isolation.level"), Some("read_committed"));
1542    }
1543
1544    #[test]
1545    fn test_rdkafka_config_with_security() {
1546        let cfg = KafkaSourceConfig::from_config(&make_config(&[
1547            ("security.protocol", "sasl_ssl"),
1548            ("sasl.mechanism", "PLAIN"),
1549            ("sasl.username", "user"),
1550            ("sasl.password", "pass"),
1551            ("ssl.ca.location", "/ca.pem"),
1552        ]))
1553        .unwrap();
1554
1555        let rdkafka = cfg.to_rdkafka_config();
1556        assert_eq!(rdkafka.get("security.protocol"), Some("sasl_ssl"));
1557        assert_eq!(rdkafka.get("sasl.mechanism"), Some("PLAIN"));
1558        assert_eq!(rdkafka.get("sasl.username"), Some("user"));
1559        assert_eq!(rdkafka.get("sasl.password"), Some("pass"));
1560        assert_eq!(rdkafka.get("ssl.ca.location"), Some("/ca.pem"));
1561    }
1562
1563    #[test]
1564    fn test_rdkafka_config_with_fetch_tuning() {
1565        let cfg = KafkaSourceConfig::from_config(&make_config(&[
1566            ("fetch.min.bytes", "1024"),
1567            ("fetch.max.bytes", "1048576"),
1568            ("fetch.max.wait.ms", "500"),
1569            ("max.partition.fetch.bytes", "262144"),
1570            ("isolation.level", "read_uncommitted"),
1571        ]))
1572        .unwrap();
1573
1574        let rdkafka = cfg.to_rdkafka_config();
1575        assert_eq!(rdkafka.get("fetch.min.bytes"), Some("1024"));
1576        assert_eq!(rdkafka.get("fetch.max.bytes"), Some("1048576"));
1577        assert_eq!(rdkafka.get("fetch.wait.max.ms"), Some("500"));
1578        assert_eq!(rdkafka.get("max.partition.fetch.bytes"), Some("262144"));
1579        assert_eq!(rdkafka.get("isolation.level"), Some("read_uncommitted"));
1580    }
1581
1582    #[test]
1583    fn test_offset_reset_parsing() {
1584        assert_eq!(
1585            "earliest".parse::<OffsetReset>().unwrap(),
1586            OffsetReset::Earliest
1587        );
1588        assert_eq!(
1589            "latest".parse::<OffsetReset>().unwrap(),
1590            OffsetReset::Latest
1591        );
1592        assert_eq!("none".parse::<OffsetReset>().unwrap(), OffsetReset::None);
1593        assert!("invalid".parse::<OffsetReset>().is_err());
1594    }
1595
1596    #[test]
1597    fn test_compatibility_level_parsing() {
1598        assert_eq!(
1599            "BACKWARD".parse::<CompatibilityLevel>().unwrap(),
1600            CompatibilityLevel::Backward
1601        );
1602        assert_eq!(
1603            "full_transitive".parse::<CompatibilityLevel>().unwrap(),
1604            CompatibilityLevel::FullTransitive
1605        );
1606        assert_eq!(
1607            "NONE".parse::<CompatibilityLevel>().unwrap(),
1608            CompatibilityLevel::None
1609        );
1610        assert!("invalid".parse::<CompatibilityLevel>().is_err());
1611    }
1612
1613    #[test]
1614    fn test_security_protocol_parsing() {
1615        assert_eq!(
1616            "plaintext".parse::<SecurityProtocol>().unwrap(),
1617            SecurityProtocol::Plaintext
1618        );
1619        assert_eq!(
1620            "SSL".parse::<SecurityProtocol>().unwrap(),
1621            SecurityProtocol::Ssl
1622        );
1623        assert_eq!(
1624            "sasl_plaintext".parse::<SecurityProtocol>().unwrap(),
1625            SecurityProtocol::SaslPlaintext
1626        );
1627        assert_eq!(
1628            "SASL_SSL".parse::<SecurityProtocol>().unwrap(),
1629            SecurityProtocol::SaslSsl
1630        );
1631        assert_eq!(
1632            "sasl-ssl".parse::<SecurityProtocol>().unwrap(),
1633            SecurityProtocol::SaslSsl
1634        );
1635        assert!("invalid".parse::<SecurityProtocol>().is_err());
1636    }
1637
1638    #[test]
1639    fn test_sasl_mechanism_parsing() {
1640        assert_eq!(
1641            "PLAIN".parse::<SaslMechanism>().unwrap(),
1642            SaslMechanism::Plain
1643        );
1644        assert_eq!(
1645            "SCRAM-SHA-256".parse::<SaslMechanism>().unwrap(),
1646            SaslMechanism::ScramSha256
1647        );
1648        assert_eq!(
1649            "scram_sha_512".parse::<SaslMechanism>().unwrap(),
1650            SaslMechanism::ScramSha512
1651        );
1652        assert_eq!(
1653            "GSSAPI".parse::<SaslMechanism>().unwrap(),
1654            SaslMechanism::Gssapi
1655        );
1656        assert_eq!(
1657            "OAUTHBEARER".parse::<SaslMechanism>().unwrap(),
1658            SaslMechanism::Oauthbearer
1659        );
1660        assert!("invalid".parse::<SaslMechanism>().is_err());
1661    }
1662
1663    #[test]
1664    fn test_isolation_level_parsing() {
1665        assert_eq!(
1666            "read_uncommitted".parse::<IsolationLevel>().unwrap(),
1667            IsolationLevel::ReadUncommitted
1668        );
1669        assert_eq!(
1670            "read_committed".parse::<IsolationLevel>().unwrap(),
1671            IsolationLevel::ReadCommitted
1672        );
1673        assert_eq!(
1674            "read-committed".parse::<IsolationLevel>().unwrap(),
1675            IsolationLevel::ReadCommitted
1676        );
1677        assert!("invalid".parse::<IsolationLevel>().is_err());
1678    }
1679
1680    #[test]
1681    fn test_topic_subscription_accessors() {
1682        let topics = TopicSubscription::Topics(vec!["a".into(), "b".into()]);
1683        assert_eq!(
1684            topics.topics(),
1685            Some(&["a".to_string(), "b".to_string()][..])
1686        );
1687        assert!(topics.pattern().is_none());
1688        assert!(!topics.is_pattern());
1689
1690        let pattern = TopicSubscription::Pattern("events-.*".into());
1691        assert!(pattern.topics().is_none());
1692        assert_eq!(pattern.pattern(), Some("events-.*"));
1693        assert!(pattern.is_pattern());
1694    }
1695
1696    #[test]
1697    fn test_security_protocol_helpers() {
1698        assert!(!SecurityProtocol::Plaintext.uses_ssl());
1699        assert!(!SecurityProtocol::Plaintext.uses_sasl());
1700
1701        assert!(SecurityProtocol::Ssl.uses_ssl());
1702        assert!(!SecurityProtocol::Ssl.uses_sasl());
1703
1704        assert!(!SecurityProtocol::SaslPlaintext.uses_ssl());
1705        assert!(SecurityProtocol::SaslPlaintext.uses_sasl());
1706
1707        assert!(SecurityProtocol::SaslSsl.uses_ssl());
1708        assert!(SecurityProtocol::SaslSsl.uses_sasl());
1709    }
1710
1711    #[test]
1712    fn test_sasl_mechanism_helpers() {
1713        assert!(SaslMechanism::Plain.requires_credentials());
1714        assert!(SaslMechanism::ScramSha256.requires_credentials());
1715        assert!(SaslMechanism::ScramSha512.requires_credentials());
1716        assert!(!SaslMechanism::Gssapi.requires_credentials());
1717        assert!(!SaslMechanism::Oauthbearer.requires_credentials());
1718    }
1719
1720    #[test]
1721    fn test_enum_display() {
1722        assert_eq!(SecurityProtocol::SaslSsl.to_string(), "sasl_ssl");
1723        assert_eq!(SaslMechanism::ScramSha256.to_string(), "SCRAM-SHA-256");
1724        assert_eq!(IsolationLevel::ReadCommitted.to_string(), "read_committed");
1725    }
1726
1727    #[test]
1728    fn test_startup_mode_parsing() {
1729        assert_eq!(
1730            "group-offsets".parse::<StartupMode>().unwrap(),
1731            StartupMode::GroupOffsets
1732        );
1733        assert_eq!(
1734            "group_offsets".parse::<StartupMode>().unwrap(),
1735            StartupMode::GroupOffsets
1736        );
1737        assert_eq!(
1738            "earliest".parse::<StartupMode>().unwrap(),
1739            StartupMode::Earliest
1740        );
1741        assert_eq!(
1742            "latest".parse::<StartupMode>().unwrap(),
1743            StartupMode::Latest
1744        );
1745        assert!("invalid".parse::<StartupMode>().is_err());
1746    }
1747
1748    #[test]
1749    fn test_startup_mode_display() {
1750        assert_eq!(StartupMode::GroupOffsets.to_string(), "group-offsets");
1751        assert_eq!(StartupMode::Earliest.to_string(), "earliest");
1752        assert_eq!(StartupMode::Latest.to_string(), "latest");
1753
1754        let specific = StartupMode::SpecificOffsets(HashMap::from([(0, 100), (1, 200)]));
1755        assert!(specific.to_string().contains("2 partitions"));
1756
1757        let ts = StartupMode::Timestamp(1234567890000);
1758        assert!(ts.to_string().contains("1234567890000"));
1759    }
1760
1761    #[test]
1762    fn test_startup_mode_latest_overrides_offset_reset() {
1763        let cfg =
1764            KafkaSourceConfig::from_config(&make_config(&[("startup.mode", "latest")])).unwrap();
1765        assert_eq!(cfg.auto_offset_reset, OffsetReset::Latest);
1766        let rdkafka = cfg.to_rdkafka_config();
1767        assert_eq!(rdkafka.get("auto.offset.reset"), Some("latest"));
1768    }
1769
1770    #[test]
1771    fn test_startup_mode_earliest_overrides_offset_reset() {
1772        let cfg =
1773            KafkaSourceConfig::from_config(&make_config(&[("startup.mode", "earliest")])).unwrap();
1774        assert_eq!(cfg.auto_offset_reset, OffsetReset::Earliest);
1775    }
1776
1777    #[test]
1778    fn test_startup_mode_group_offsets_uses_explicit_offset_reset() {
1779        // When startup.mode is group-offsets (default), the explicit
1780        // auto.offset.reset setting should be used.
1781        let cfg = KafkaSourceConfig::from_config(&make_config(&[("auto.offset.reset", "latest")]))
1782            .unwrap();
1783        assert_eq!(cfg.auto_offset_reset, OffsetReset::Latest);
1784    }
1785
1786    #[test]
1787    fn test_parse_specific_offsets() {
1788        let offsets = parse_specific_offsets("0:100,1:200,2:300").unwrap();
1789        assert_eq!(offsets.get(&0), Some(&100));
1790        assert_eq!(offsets.get(&1), Some(&200));
1791        assert_eq!(offsets.get(&2), Some(&300));
1792    }
1793
1794    #[test]
1795    fn test_parse_specific_offsets_with_spaces() {
1796        let offsets = parse_specific_offsets(" 0:100 , 1:200 ").unwrap();
1797        assert_eq!(offsets.get(&0), Some(&100));
1798        assert_eq!(offsets.get(&1), Some(&200));
1799    }
1800
1801    #[test]
1802    fn test_parse_specific_offsets_errors() {
1803        assert!(parse_specific_offsets("").is_err());
1804        assert!(parse_specific_offsets("0").is_err());
1805        assert!(parse_specific_offsets("0:abc").is_err());
1806        assert!(parse_specific_offsets("abc:100").is_err());
1807        assert!(parse_specific_offsets("0:100:extra").is_err());
1808    }
1809
1810    #[test]
1811    fn test_parse_startup_mode_from_config() {
1812        let cfg =
1813            KafkaSourceConfig::from_config(&make_config(&[("startup.mode", "earliest")])).unwrap();
1814        assert_eq!(cfg.startup_mode, StartupMode::Earliest);
1815
1816        let cfg =
1817            KafkaSourceConfig::from_config(&make_config(&[("startup.mode", "latest")])).unwrap();
1818        assert_eq!(cfg.startup_mode, StartupMode::Latest);
1819
1820        let cfg =
1821            KafkaSourceConfig::from_config(&make_config(&[("startup.mode", "group-offsets")]))
1822                .unwrap();
1823        assert_eq!(cfg.startup_mode, StartupMode::GroupOffsets);
1824    }
1825
1826    #[test]
1827    fn test_parse_startup_specific_offsets_from_config() {
1828        let cfg = KafkaSourceConfig::from_config(&make_config(&[(
1829            "startup.specific.offsets",
1830            "0:100,1:200",
1831        )]))
1832        .unwrap();
1833
1834        match cfg.startup_mode {
1835            StartupMode::SpecificOffsets(offsets) => {
1836                assert_eq!(offsets.get(&0), Some(&100));
1837                assert_eq!(offsets.get(&1), Some(&200));
1838            }
1839            _ => panic!("expected SpecificOffsets"),
1840        }
1841    }
1842
1843    #[test]
1844    fn test_parse_startup_timestamp_from_config() {
1845        let cfg =
1846            KafkaSourceConfig::from_config(&make_config(&[("startup.timestamp.ms", "1234567890")]))
1847                .unwrap();
1848
1849        assert_eq!(cfg.startup_mode, StartupMode::Timestamp(1234567890));
1850    }
1851
1852    #[test]
1853    fn test_parse_schema_registry_ssl_fields() {
1854        let cfg = KafkaSourceConfig::from_config(&make_config(&[
1855            ("schema.registry.url", "https://sr:8081"),
1856            ("schema.registry.ssl.ca.location", "/ca.pem"),
1857            ("schema.registry.ssl.certificate.location", "/cert.pem"),
1858            ("schema.registry.ssl.key.location", "/key.pem"),
1859        ]))
1860        .unwrap();
1861
1862        assert_eq!(
1863            cfg.schema_registry_ssl_ca_location,
1864            Some("/ca.pem".to_string())
1865        );
1866        assert_eq!(
1867            cfg.schema_registry_ssl_certificate_location,
1868            Some("/cert.pem".to_string())
1869        );
1870        assert_eq!(
1871            cfg.schema_registry_ssl_key_location,
1872            Some("/key.pem".to_string())
1873        );
1874    }
1875
1876    // -- startup.mode = timestamp error --
1877
1878    #[test]
1879    fn test_startup_mode_timestamp_error() {
1880        let config = make_config(&[("startup.mode", "timestamp")]);
1881        let err = KafkaSourceConfig::from_config(&config).unwrap_err();
1882        let msg = err.to_string();
1883        assert!(
1884            msg.contains("startup.timestamp.ms"),
1885            "error should mention startup.timestamp.ms, got: {msg}"
1886        );
1887    }
1888
1889    // -- session timeout / heartbeat interval --
1890
1891    #[test]
1892    fn test_session_timeout_heartbeat_defaults() {
1893        let cfg = KafkaSourceConfig::from_config(&make_config(&[])).unwrap();
1894        assert_eq!(cfg.session_timeout, Duration::from_secs(45));
1895        assert_eq!(cfg.heartbeat_interval, Duration::from_secs(10));
1896    }
1897
1898    #[test]
1899    fn test_session_timeout_heartbeat_custom() {
1900        let cfg = KafkaSourceConfig::from_config(&make_config(&[
1901            ("session.timeout.ms", "60000"),
1902            ("heartbeat.interval.ms", "15000"),
1903        ]))
1904        .unwrap();
1905        assert_eq!(cfg.session_timeout, Duration::from_secs(60));
1906        assert_eq!(cfg.heartbeat_interval, Duration::from_secs(15));
1907    }
1908
1909    #[test]
1910    fn test_session_timeout_heartbeat_validation_fails() {
1911        // heartbeat=20s * 3 = 60s >= session=45s → error
1912        let config = make_config(&[
1913            ("session.timeout.ms", "45000"),
1914            ("heartbeat.interval.ms", "20000"),
1915        ]);
1916        let err = KafkaSourceConfig::from_config(&config).unwrap_err();
1917        let msg = err.to_string();
1918        assert!(msg.contains("heartbeat.interval.ms"), "got: {msg}");
1919    }
1920
1921    #[test]
1922    fn test_session_timeout_heartbeat_validation_passes() {
1923        // heartbeat=10s * 3 = 30s < session=45s → ok
1924        let cfg = KafkaSourceConfig::from_config(&make_config(&[
1925            ("session.timeout.ms", "45000"),
1926            ("heartbeat.interval.ms", "10000"),
1927        ]))
1928        .unwrap();
1929        assert_eq!(cfg.session_timeout, Duration::from_secs(45));
1930        assert_eq!(cfg.heartbeat_interval, Duration::from_secs(10));
1931    }
1932
1933    #[test]
1934    fn test_session_timeout_heartbeat_in_rdkafka_config() {
1935        let cfg = KafkaSourceConfig::from_config(&make_config(&[
1936            ("session.timeout.ms", "60000"),
1937            ("heartbeat.interval.ms", "15000"),
1938        ]))
1939        .unwrap();
1940        let rdkafka = cfg.to_rdkafka_config();
1941        assert_eq!(rdkafka.get("session.timeout.ms"), Some("60000"));
1942        assert_eq!(rdkafka.get("heartbeat.interval.ms"), Some("15000"));
1943    }
1944
1945    #[test]
1946    fn test_session_timeout_passthrough_blocked() {
1947        let cfg =
1948            KafkaSourceConfig::from_config(&make_config(&[("kafka.session.timeout.ms", "99999")]))
1949                .unwrap();
1950        // The pass-through should be blocked; rdkafka config should use the default (45000).
1951        let rdkafka = cfg.to_rdkafka_config();
1952        assert_eq!(rdkafka.get("session.timeout.ms"), Some("45000"));
1953    }
1954
1955    // -- queued.max.messages.kbytes --
1956
1957    #[test]
1958    fn test_queued_max_messages_kbytes_default() {
1959        let cfg = KafkaSourceConfig::from_config(&make_config(&[])).unwrap();
1960        assert_eq!(cfg.queued_max_messages_kbytes, 16384);
1961    }
1962
1963    #[test]
1964    fn test_queued_max_messages_kbytes_custom() {
1965        let cfg = KafkaSourceConfig::from_config(&make_config(&[(
1966            "queued.max.messages.kbytes",
1967            "32768",
1968        )]))
1969        .unwrap();
1970        assert_eq!(cfg.queued_max_messages_kbytes, 32768);
1971    }
1972
1973    #[test]
1974    fn test_queued_max_messages_kbytes_in_rdkafka_config() {
1975        let cfg =
1976            KafkaSourceConfig::from_config(&make_config(&[("queued.max.messages.kbytes", "8192")]))
1977                .unwrap();
1978        let rdkafka = cfg.to_rdkafka_config();
1979        assert_eq!(rdkafka.get("queued.max.messages.kbytes"), Some("8192"));
1980    }
1981
1982    // ── Schema Registry subject-name strategy ──
1983
1984    #[test]
1985    fn resolve_subject_topic_name() {
1986        assert_eq!(
1987            resolve_value_subject(SubjectNameStrategy::TopicName, None, "orders"),
1988            "orders-value"
1989        );
1990    }
1991
1992    #[test]
1993    fn resolve_subject_record_name() {
1994        assert_eq!(
1995            resolve_value_subject(
1996                SubjectNameStrategy::RecordName,
1997                Some("com.acme.Order"),
1998                "orders"
1999            ),
2000            "com.acme.Order-value"
2001        );
2002    }
2003
2004    #[test]
2005    fn resolve_subject_topic_record_name() {
2006        assert_eq!(
2007            resolve_value_subject(
2008                SubjectNameStrategy::TopicRecordName,
2009                Some("com.acme.Order"),
2010                "orders"
2011            ),
2012            "orders-com.acme.Order-value"
2013        );
2014    }
2015
2016    #[test]
2017    fn parse_subject_strategy_from_config() {
2018        let cfg = KafkaSourceConfig::from_config(&make_config(&[
2019            ("schema.registry.url", "http://sr:8081"),
2020            ("schema.registry.subject.name.strategy", "record-name"),
2021            ("schema.registry.record.name", "com.acme.Order"),
2022        ]))
2023        .unwrap();
2024        assert_eq!(
2025            cfg.schema_registry_subject_strategy,
2026            SubjectNameStrategy::RecordName
2027        );
2028        assert_eq!(
2029            cfg.schema_registry_record_name.as_deref(),
2030            Some("com.acme.Order")
2031        );
2032    }
2033
2034    #[test]
2035    fn parse_subject_strategy_rejects_missing_record_name() {
2036        let err = KafkaSourceConfig::from_config(&make_config(&[
2037            ("schema.registry.url", "http://sr:8081"),
2038            ("schema.registry.subject.name.strategy", "record-name"),
2039        ]))
2040        .unwrap_err();
2041        assert!(matches!(err, ConnectorError::ConfigurationError(_)));
2042    }
2043
2044    #[test]
2045    fn parse_discovery_timeout_default_ten_seconds() {
2046        let cfg =
2047            KafkaSourceConfig::from_config(&make_config(&[("schema.registry.url", "http://sr")]))
2048                .unwrap();
2049        assert_eq!(
2050            cfg.schema_registry_discovery_timeout,
2051            Duration::from_secs(10)
2052        );
2053    }
2054
2055    #[test]
2056    fn parse_discovery_timeout_override() {
2057        let cfg = KafkaSourceConfig::from_config(&make_config(&[
2058            ("schema.registry.url", "http://sr"),
2059            ("schema.registry.discovery.timeout.ms", "25000"),
2060        ]))
2061        .unwrap();
2062        assert_eq!(
2063            cfg.schema_registry_discovery_timeout,
2064            Duration::from_secs(25)
2065        );
2066    }
2067}