Skip to main content

code_system_graph_core/
events.rs

1//! Conservative extraction of event contracts from `AsyncAPI` and source boundaries.
2
3use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8type Mapping = serde_json::Map<String, Value>;
9use thiserror::Error;
10
11use crate::SourceLanguage;
12
13const MAX_ASYNCAPI_BYTES: usize = 4 * 1024 * 1024;
14const MAX_SOURCE_BYTES: usize = 1024 * 1024;
15const MAX_OBSERVATIONS: usize = 1_024;
16const MAX_SCHEMA_FIELDS: usize = 256;
17const MAX_EVIDENCE_TEXT_CHARS: usize = 160;
18const MAX_ERROR_CHARS: usize = 512;
19const MAX_REFERENCE_DEPTH: usize = 16;
20
21/// Event transport family identified by direct configuration or source evidence.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum EventBroker {
25    /// Apache Kafka or a protocol-compatible Kafka service.
26    Kafka,
27    /// `RabbitMQ` or another explicitly AMQP-backed `RabbitMQ` deployment.
28    RabbitMq,
29    /// Amazon Simple Notification Service.
30    AwsSns,
31    /// Amazon Simple Queue Service.
32    AwsSqs,
33    /// NATS or `JetStream`.
34    Nats,
35    /// Google Cloud Pub/Sub.
36    GooglePubSub,
37    /// A literal publish/subscribe API without broker-specific evidence.
38    Generic,
39}
40
41/// Repository role represented by an event observation.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum EventRole {
45    /// Code or a contract sends events to a channel.
46    Publisher,
47    /// Code or a contract receives events from a channel.
48    Subscriber,
49    /// A channel is declared without an associated send or receive operation.
50    Declaration,
51}
52
53/// Delivery guarantee explicitly stated by a contract or source call.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum DeliverySemantics {
57    /// A message may be lost but is not intentionally redelivered.
58    AtMostOnce,
59    /// A message may be redelivered until it is acknowledged.
60    AtLeastOnce,
61    /// The declaration explicitly claims exactly-once processing or delivery.
62    ExactlyOnce,
63    /// The declaration explicitly describes best-effort delivery.
64    BestEffort,
65}
66
67/// Bounded, source-free evidence identifying where a fact was observed.
68///
69/// `text` is an extractor-generated label, not a copy of source code or a message payload.
70#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
71pub struct EventEvidenceLine {
72    /// One-based line containing the direct evidence.
73    pub line: u32,
74    /// Bounded description of the recognized declaration.
75    pub text: String,
76}
77
78/// One top-level field from a statically visible event payload schema.
79#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
80pub struct EventSchemaField {
81    /// Field name exactly represented by the schema.
82    pub name: String,
83    /// Declared scalar, object, array, or referenced type when available.
84    pub field_type: Option<String>,
85    /// Whether the containing schema lists this field as required.
86    pub required: bool,
87}
88
89/// Bounded schema metadata attached to an event observation.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct EventSchemaDefinition {
92    /// Message or schema name when explicitly declared.
93    pub name: Option<String>,
94    /// Schema version when explicitly declared.
95    pub version: Option<String>,
96    /// `AsyncAPI` schema format or media type when explicitly declared.
97    pub schema_format: Option<String>,
98    /// Deterministically ordered top-level payload fields.
99    pub fields: Vec<EventSchemaField>,
100}
101
102/// One conservative publisher, subscriber, or channel declaration observation.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104pub struct EventObservation {
105    /// Broker family supported by direct evidence.
106    pub broker: EventBroker,
107    /// Publisher, subscriber, or declaration role.
108    pub role: EventRole,
109    /// Exact channel, topic, queue, or subject; absent for dynamic expressions.
110    pub channel: Option<String>,
111    /// Exact server namespace when represented by a non-templated literal.
112    pub namespace: Option<String>,
113    /// Exact transport protocol when represented by the contract.
114    pub protocol: Option<String>,
115    /// Event or message name when statically visible.
116    pub event_type: Option<String>,
117    /// Bounded payload schema metadata.
118    pub schema: Option<EventSchemaDefinition>,
119    /// Exact partition key declaration when statically visible.
120    pub partition_key: Option<String>,
121    /// Exact routing key declaration when statically visible.
122    pub routing_key: Option<String>,
123    /// Explicit delivery guarantee; never inferred from broker defaults.
124    pub delivery_semantics: Option<DeliverySemantics>,
125    /// Exact dead-letter topic, queue, or channel when explicitly visible.
126    pub dead_letter_channel: Option<String>,
127    /// Source language for source-boundary observations.
128    pub language: Option<SourceLanguage>,
129    /// Direct, bounded evidence lines without source or payload bodies.
130    pub evidence: Vec<EventEvidenceLine>,
131    /// Normalized confidence from zero to one.
132    pub confidence: f32,
133    /// Whether missing or dynamic information prevents a complete exact fact.
134    pub incomplete: bool,
135    /// Deterministically ordered human-readable limitations.
136    pub warnings: Vec<String>,
137}
138
139/// Deterministic event facts and extraction limitations for one input.
140#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
141pub struct EventDocument {
142    /// Repository-relative source path for declarative contracts.
143    pub source_path: Option<String>,
144    /// Contract family, currently `asyncapi`, when applicable.
145    pub specification: Option<String>,
146    /// Exact `AsyncAPI` version when applicable.
147    pub specification_version: Option<String>,
148    /// Sorted and deduplicated event observations.
149    pub observations: Vec<EventObservation>,
150    /// Sorted and deduplicated document-level limitations.
151    pub warnings: Vec<String>,
152    /// Whether truncation or ambiguity prevents complete extraction.
153    pub incomplete: bool,
154}
155
156/// Failure to safely parse an `AsyncAPI` event contract.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
158#[non_exhaustive]
159pub enum EventExtractionError {
160    /// Input exceeds the fixed extraction budget.
161    #[error("event contract `{source_path}` exceeds the {limit_bytes}-byte input limit")]
162    InputTooLarge {
163        /// Repository-relative source path.
164        source_path: String,
165        /// Maximum accepted input size.
166        limit_bytes: usize,
167    },
168    /// YAML or JSON syntax is malformed.
169    #[error("invalid AsyncAPI document `{source_path}`: {message}")]
170    InvalidSyntax {
171        /// Repository-relative source path.
172        source_path: String,
173        /// Bounded parser explanation.
174        message: String,
175    },
176    /// The top-level value is not an object.
177    #[error("AsyncAPI document `{source_path}` must contain a top-level object")]
178    InvalidRoot {
179        /// Repository-relative source path.
180        source_path: String,
181    },
182    /// The `asyncapi` field is missing, dynamic, or outside supported major versions.
183    #[error("unsupported AsyncAPI version in `{source_path}`: {version}")]
184    UnsupportedVersion {
185        /// Repository-relative source path.
186        source_path: String,
187        /// Bounded observed value or `missing`.
188        version: String,
189    },
190    /// A mandatory `AsyncAPI` collection has an invalid shape.
191    #[error("invalid AsyncAPI structure in `{source_path}`: {message}")]
192    InvalidStructure {
193        /// Repository-relative source path.
194        source_path: String,
195        /// Bounded structural explanation.
196        message: String,
197    },
198}
199
200/// Extracts static event facts from an `AsyncAPI` 2.x or 3.x YAML/JSON document.
201///
202/// The extractor resolves only local component references. Templated channel addresses, server
203/// namespaces, unresolved references, and conflicting server protocols remain explicit warnings.
204/// Message payloads are reduced to bounded schema metadata and are never retained as source.
205///
206/// # Errors
207///
208/// Returns [`EventExtractionError`] for oversized or malformed input, a non-object root, an
209/// unsupported `AsyncAPI` version, or object collections with invalid shapes.
210pub fn extract_asyncapi(
211    source_path: &str,
212    input: &str,
213) -> Result<EventDocument, EventExtractionError> {
214    if input.len() > MAX_ASYNCAPI_BYTES {
215        return Err(EventExtractionError::InputTooLarge {
216            source_path: source_path.to_owned(),
217            limit_bytes: MAX_ASYNCAPI_BYTES,
218        });
219    }
220    let root = parse_asyncapi_value(source_path, input)?;
221    let root_map = root
222        .as_object()
223        .ok_or_else(|| EventExtractionError::InvalidRoot {
224            source_path: source_path.to_owned(),
225        })?;
226    let version = mapping_string(root_map, "asyncapi").ok_or_else(|| {
227        EventExtractionError::UnsupportedVersion {
228            source_path: source_path.to_owned(),
229            version: "missing".to_owned(),
230        }
231    })?;
232    let major = if version.starts_with("2.") {
233        2
234    } else if version.starts_with("3.") {
235        3
236    } else {
237        return Err(EventExtractionError::UnsupportedVersion {
238            source_path: source_path.to_owned(),
239            version: bounded_text(version, MAX_ERROR_CHARS),
240        });
241    };
242
243    let mut context = AsyncApiContext::new(source_path, input, &root, version);
244    if major == 2 {
245        extract_asyncapi_v2(&mut context)?;
246    } else {
247        extract_asyncapi_v3(&mut context)?;
248    }
249    Ok(context.finish())
250}
251
252fn parse_asyncapi_value(source_path: &str, input: &str) -> Result<Value, EventExtractionError> {
253    let result = if input.trim_start().starts_with(['{', '[']) {
254        serde_json::from_str(input).map_err(|error| error.to_string())
255    } else {
256        crate::yaml::from_str(input).map_err(|error| error.to_string())
257    };
258    result.map_err(|message| EventExtractionError::InvalidSyntax {
259        source_path: source_path.to_owned(),
260        message: bounded_text(&message, MAX_ERROR_CHARS),
261    })
262}
263
264/// Extracts exact literal event API calls from one supported source language.
265///
266/// Recognition is intentionally narrow. A recognized publisher or subscriber call with a
267/// computed channel is emitted with `channel = None`, zero confidence, and an explicit warning.
268/// At most one mebibyte and 1,024 observations are inspected and returned.
269#[must_use]
270pub fn parse_event_source(language: SourceLanguage, input: &str) -> EventDocument {
271    let (source, truncated) = bounded_source(input);
272    let sanitized = sanitize_source(language, source);
273    let mut document = EventDocument::default();
274    if truncated {
275        document.incomplete = true;
276        document.warnings.push(format!(
277            "source input truncated at {MAX_SOURCE_BYTES} bytes"
278        ));
279    }
280    if input.contains('\0') {
281        document.incomplete = true;
282        document
283            .warnings
284            .push("source input contains a NUL byte".to_owned());
285    }
286
287    for index in 0..sanitized.len() {
288        if document.observations.len() >= MAX_OBSERVATIONS {
289            document.incomplete = true;
290            document.warnings.push(format!(
291                "observations truncated at {MAX_OBSERVATIONS} items"
292            ));
293            break;
294        }
295        let Some(candidate) = source_candidate(&sanitized, index) else {
296            continue;
297        };
298        let Some(recognition) = recognize_source_call(&candidate) else {
299            continue;
300        };
301        let line_number = u32::try_from(index + 1).unwrap_or(u32::MAX);
302        append_source_observations(
303            &mut document.observations,
304            language,
305            line_number,
306            &candidate,
307            recognition,
308        );
309    }
310    finish_document(&mut document);
311    document
312}
313
314struct AsyncApiContext<'a> {
315    source_path: &'a str,
316    input: &'a str,
317    root: &'a Value,
318    version: &'a str,
319    observations: Vec<EventObservation>,
320    warnings: Vec<String>,
321    incomplete: bool,
322    server: ServerFacts,
323}
324
325impl<'a> AsyncApiContext<'a> {
326    fn new(source_path: &'a str, input: &'a str, root: &'a Value, version: &'a str) -> Self {
327        let (server, warnings) = global_server_facts(root);
328        Self {
329            source_path,
330            input,
331            root,
332            version,
333            observations: Vec::new(),
334            incomplete: !warnings.is_empty(),
335            warnings,
336            server,
337        }
338    }
339
340    fn push(&mut self, mut observation: EventObservation) {
341        if self.observations.len() >= MAX_OBSERVATIONS {
342            self.incomplete = true;
343            self.warnings.push(format!(
344                "observations truncated at {MAX_OBSERVATIONS} items"
345            ));
346            return;
347        }
348        normalize_observation(&mut observation);
349        self.observations.push(observation);
350    }
351
352    fn finish(self) -> EventDocument {
353        let mut document = EventDocument {
354            source_path: Some(self.source_path.to_owned()),
355            specification: Some("asyncapi".to_owned()),
356            specification_version: Some(self.version.to_owned()),
357            observations: self.observations,
358            warnings: self.warnings,
359            incomplete: self.incomplete,
360        };
361        finish_document(&mut document);
362        document
363    }
364}
365
366#[derive(Debug, Clone, Default)]
367struct ServerFacts {
368    broker: Option<EventBroker>,
369    protocol: Option<String>,
370    namespace: Option<String>,
371    ambiguous: bool,
372}
373
374fn extract_asyncapi_v2(context: &mut AsyncApiContext<'_>) -> Result<(), EventExtractionError> {
375    let Some(channels) = value_get(context.root, "channels") else {
376        context.incomplete = true;
377        context
378            .warnings
379            .push("AsyncAPI document has no channels object".to_owned());
380        return Ok(());
381    };
382    let channel_map =
383        channels
384            .as_object()
385            .ok_or_else(|| EventExtractionError::InvalidStructure {
386                source_path: context.source_path.to_owned(),
387                message: "`channels` must be an object".to_owned(),
388            })?;
389    for (channel_key, channel_item) in channel_map {
390        let raw_channel = channel_key.as_str();
391        let resolved_item = resolve_local(context.root, channel_item);
392        let item = resolved_item.value;
393        let mut emitted = false;
394        for (operation_name, role) in [
395            ("publish", EventRole::Publisher),
396            ("subscribe", EventRole::Subscriber),
397        ] {
398            let Some(operation) = value_get(item, operation_name) else {
399                continue;
400            };
401            emitted = true;
402            append_asyncapi_operation(
403                context,
404                raw_channel,
405                role,
406                operation,
407                Some(item),
408                &resolved_item.warnings,
409            );
410        }
411        if !emitted {
412            let mut observation = asyncapi_observation(
413                context,
414                raw_channel,
415                EventRole::Declaration,
416                None,
417                Some(item),
418                None,
419            );
420            observation.warnings.extend(resolved_item.warnings);
421            context.push(observation);
422        }
423    }
424    Ok(())
425}
426
427fn extract_asyncapi_v3(context: &mut AsyncApiContext<'_>) -> Result<(), EventExtractionError> {
428    let channel_map = value_get(context.root, "channels")
429        .and_then(Value::as_object)
430        .ok_or_else(|| EventExtractionError::InvalidStructure {
431            source_path: context.source_path.to_owned(),
432            message: "`channels` must be an object".to_owned(),
433        })?;
434    let mut used_channels = BTreeSet::new();
435    if let Some(operations) = value_get(context.root, "operations") {
436        let operation_map =
437            operations
438                .as_object()
439                .ok_or_else(|| EventExtractionError::InvalidStructure {
440                    source_path: context.source_path.to_owned(),
441                    message: "`operations` must be an object".to_owned(),
442                })?;
443        for (operation_name, operation) in operation_map {
444            let operation_label = operation_name.as_str();
445            let resolved_operation = resolve_local(context.root, operation);
446            let Some(action) =
447                value_get(resolved_operation.value, "action").and_then(Value::as_str)
448            else {
449                context.incomplete = true;
450                context.warnings.push(format!(
451                    "AsyncAPI operation `{operation_label}` has no literal action"
452                ));
453                continue;
454            };
455            let role = match action {
456                "send" => EventRole::Publisher,
457                "receive" => EventRole::Subscriber,
458                other => {
459                    context.incomplete = true;
460                    context.warnings.push(format!(
461                        "AsyncAPI operation `{operation_label}` uses unsupported action `{other}`"
462                    ));
463                    continue;
464                }
465            };
466            let Some(channel_value) = value_get(resolved_operation.value, "channel") else {
467                context.incomplete = true;
468                context.warnings.push(format!(
469                    "AsyncAPI operation `{operation_label}` has no channel reference"
470                ));
471                continue;
472            };
473            let channel_key = reference_component_name(channel_value, "channels");
474            let resolved_channel = resolve_local(context.root, channel_value);
475            let channel = value_get(resolved_channel.value, "address").and_then(literal_string);
476            let raw_channel = channel.as_deref().unwrap_or("{dynamic-channel}");
477            if let Some(key) = channel_key {
478                used_channels.insert(key);
479            }
480            let mut reference_warnings = resolved_operation.warnings;
481            reference_warnings.extend(resolved_channel.warnings);
482            append_asyncapi_operation(
483                context,
484                raw_channel,
485                role,
486                resolved_operation.value,
487                Some(resolved_channel.value),
488                &reference_warnings,
489            );
490        }
491    }
492
493    for (channel_key, channel_item) in channel_map {
494        let name = channel_key.as_str();
495        if used_channels.contains(name) {
496            continue;
497        }
498        let resolved = resolve_local(context.root, channel_item);
499        append_asyncapi_declaration(context, resolved);
500    }
501    Ok(())
502}
503
504fn append_asyncapi_declaration(context: &mut AsyncApiContext<'_>, resolved: ResolvedValue<'_>) {
505    let raw_channel = value_get(resolved.value, "address")
506        .and_then(literal_string)
507        .unwrap_or_else(|| "{dynamic-channel}".to_owned());
508    let messages = operation_messages(context.root, resolved.value, Some(resolved.value));
509    if messages.is_empty() {
510        let mut observation = asyncapi_observation(
511            context,
512            &raw_channel,
513            EventRole::Declaration,
514            None,
515            Some(resolved.value),
516            None,
517        );
518        observation.warnings.extend(resolved.warnings);
519        context.push(observation);
520        return;
521    }
522    for message in messages {
523        let mut observation = asyncapi_observation(
524            context,
525            &raw_channel,
526            EventRole::Declaration,
527            None,
528            Some(resolved.value),
529            Some(message.value),
530        );
531        observation
532            .warnings
533            .extend(resolved.warnings.iter().cloned());
534        observation.warnings.extend(message.warnings);
535        context.push(observation);
536    }
537}
538
539fn append_asyncapi_operation(
540    context: &mut AsyncApiContext<'_>,
541    raw_channel: &str,
542    role: EventRole,
543    operation: &Value,
544    channel_item: Option<&Value>,
545    reference_warnings: &[String],
546) {
547    let resolved_operation = resolve_local(context.root, operation);
548    let messages = operation_messages(context.root, resolved_operation.value, channel_item);
549    if messages.is_empty() {
550        let mut observation = asyncapi_observation(
551            context,
552            raw_channel,
553            role,
554            Some(resolved_operation.value),
555            channel_item,
556            None,
557        );
558        observation
559            .warnings
560            .extend(reference_warnings.iter().cloned());
561        observation
562            .warnings
563            .extend(resolved_operation.warnings.iter().cloned());
564        observation.incomplete = true;
565        observation
566            .warnings
567            .push("operation has no statically resolvable message".to_owned());
568        context.push(observation);
569        return;
570    }
571    for message in messages {
572        let mut observation = asyncapi_observation(
573            context,
574            raw_channel,
575            role,
576            Some(resolved_operation.value),
577            channel_item,
578            Some(message.value),
579        );
580        observation
581            .warnings
582            .extend(reference_warnings.iter().cloned());
583        observation
584            .warnings
585            .extend(resolved_operation.warnings.iter().cloned());
586        observation.warnings.extend(message.warnings);
587        context.push(observation);
588    }
589}
590
591struct ResolvedValue<'a> {
592    value: &'a Value,
593    warnings: Vec<String>,
594}
595
596fn operation_messages<'a>(
597    root: &'a Value,
598    operation: &'a Value,
599    channel_item: Option<&'a Value>,
600) -> Vec<ResolvedValue<'a>> {
601    let message_value = value_get(operation, "message").or_else(|| {
602        value_get(operation, "messages")
603            .or_else(|| channel_item.and_then(|channel| value_get(channel, "messages")))
604    });
605    let Some(message_value) = message_value else {
606        return Vec::new();
607    };
608    let mut output = Vec::new();
609    collect_messages(root, message_value, &mut output);
610    output
611}
612
613fn collect_messages<'a>(root: &'a Value, value: &'a Value, output: &mut Vec<ResolvedValue<'a>>) {
614    if let Some(items) = value_get(value, "oneOf").and_then(Value::as_array) {
615        for item in items {
616            output.push(resolve_local(root, item));
617        }
618        return;
619    }
620    if let Some(sequence) = value.as_array() {
621        for item in sequence {
622            output.push(resolve_local(root, item));
623        }
624        return;
625    }
626    if let Some(mapping) = value.as_object()
627        && !mapping.contains_key("$ref")
628        && !mapping.contains_key("payload")
629        && !mapping.contains_key("name")
630    {
631        for item in mapping.values() {
632            output.push(resolve_local(root, item));
633        }
634        return;
635    }
636    output.push(resolve_local(root, value));
637}
638
639fn asyncapi_observation(
640    context: &AsyncApiContext<'_>,
641    raw_channel: &str,
642    role: EventRole,
643    operation: Option<&Value>,
644    channel_item: Option<&Value>,
645    message: Option<&Value>,
646) -> EventObservation {
647    let mut warnings = Vec::new();
648    if context.server.ambiguous {
649        warnings.push("server selection or namespace is ambiguous".to_owned());
650    }
651    let channel = literal_channel(raw_channel);
652    if channel.is_none() {
653        warnings.push("templated or empty channel is not an exact channel".to_owned());
654    }
655    let protocol = context.server.protocol.clone();
656    let broker = context
657        .server
658        .broker
659        .or_else(|| {
660            protocol
661                .as_deref()
662                .map(broker_from_protocol)
663                .filter(|broker| *broker != EventBroker::Generic)
664        })
665        .or_else(|| channel_item.and_then(broker_from_bindings))
666        .or_else(|| operation.and_then(broker_from_bindings))
667        .or_else(|| message.and_then(broker_from_bindings))
668        .unwrap_or(EventBroker::Generic);
669    let event_type = message.and_then(message_name);
670    let schema = message.and_then(|value| schema_definition(context.root, value, &mut warnings));
671    let partition_key = exact_metadata(
672        [message, operation, channel_item],
673        &["partitionKey", "partition_key", "x-partition-key"],
674    );
675    let routing_key = exact_metadata(
676        [operation, channel_item, message],
677        &["routingKey", "routing_key", "x-routing-key"],
678    );
679    let delivery_semantics = [operation, channel_item, message]
680        .into_iter()
681        .flatten()
682        .find_map(explicit_delivery_semantics);
683    let dead_letter_channel = exact_metadata(
684        [operation, channel_item, message],
685        &[
686            "deadLetterChannel",
687            "deadLetterQueue",
688            "deadLetterTopic",
689            "dead_letter_channel",
690            "x-dead-letter-channel",
691        ],
692    );
693    let evidence = evidence_for_token(
694        context.input,
695        channel.as_deref().unwrap_or(raw_channel),
696        &format!("AsyncAPI {role:?} channel"),
697    );
698    EventObservation {
699        broker,
700        role,
701        channel,
702        namespace: context.server.namespace.clone(),
703        protocol,
704        event_type,
705        schema,
706        partition_key,
707        routing_key,
708        delivery_semantics,
709        dead_letter_channel,
710        language: None,
711        evidence: vec![evidence],
712        confidence: if warnings.is_empty() { 1.0 } else { 0.0 },
713        incomplete: !warnings.is_empty(),
714        warnings,
715    }
716}
717
718fn schema_definition(
719    root: &Value,
720    message: &Value,
721    warnings: &mut Vec<String>,
722) -> Option<EventSchemaDefinition> {
723    let payload = value_get(message, "payload")?;
724    let resolved = resolve_local(root, payload);
725    warnings.extend(resolved.warnings);
726    let schema = resolved.value;
727    let name = message_name(message).or_else(|| reference_name(payload));
728    let version = exact_string_recursive(
729        message,
730        &["schemaVersion", "schema_version", "x-schema-version"],
731        3,
732    )
733    .or_else(|| {
734        exact_string_recursive(
735            schema,
736            &["schemaVersion", "schema_version", "x-schema-version"],
737            2,
738        )
739    });
740    let schema_format = value_get(message, "schemaFormat")
741        .and_then(literal_string)
742        .or_else(|| value_get(message, "contentType").and_then(literal_string))
743        .or_else(|| value_get(schema, "$schema").and_then(literal_string));
744    let required = value_get(schema, "required")
745        .and_then(Value::as_array)
746        .map(|items| {
747            items
748                .iter()
749                .filter_map(Value::as_str)
750                .map(str::to_owned)
751                .collect::<BTreeSet<_>>()
752        })
753        .unwrap_or_default();
754    let mut fields = Vec::new();
755    if let Some(properties) = value_get(schema, "properties").and_then(Value::as_object) {
756        for (field_name, field_schema) in properties {
757            if fields.len() >= MAX_SCHEMA_FIELDS {
758                warnings.push(format!(
759                    "payload fields truncated at {MAX_SCHEMA_FIELDS} items"
760                ));
761                break;
762            }
763            let field_name = field_name.as_str();
764            let resolved_field = resolve_local(root, field_schema);
765            warnings.extend(resolved_field.warnings);
766            fields.push(EventSchemaField {
767                name: field_name.to_owned(),
768                field_type: schema_type(resolved_field.value)
769                    .or_else(|| reference_name(field_schema)),
770                required: required.contains(field_name),
771            });
772        }
773    }
774    fields.sort();
775    fields.dedup();
776    Some(EventSchemaDefinition {
777        name,
778        version,
779        schema_format,
780        fields,
781    })
782}
783
784fn schema_type(value: &Value) -> Option<String> {
785    value_get(value, "type")
786        .and_then(Value::as_str)
787        .map(str::to_owned)
788        .or_else(|| {
789            value_get(value, "format")
790                .and_then(Value::as_str)
791                .map(str::to_owned)
792        })
793}
794
795fn message_name(message: &Value) -> Option<String> {
796    value_get(message, "name")
797        .and_then(literal_string)
798        .or_else(|| value_get(message, "title").and_then(literal_string))
799        .or_else(|| reference_name(message))
800}
801
802fn global_server_facts(root: &Value) -> (ServerFacts, Vec<String>) {
803    let Some(servers) = value_get(root, "servers").and_then(Value::as_object) else {
804        return (ServerFacts::default(), Vec::new());
805    };
806    let mut protocols = BTreeSet::new();
807    let mut namespaces = BTreeSet::new();
808    let mut warnings = Vec::new();
809    for server in servers.values() {
810        let resolved = resolve_local(root, server);
811        warnings.extend(resolved.warnings);
812        if let Some(protocol) = value_get(resolved.value, "protocol").and_then(literal_string) {
813            protocols.insert(protocol.to_ascii_lowercase());
814        }
815        let namespace = server_namespace(resolved.value);
816        if let Some(namespace) = namespace {
817            namespaces.insert(namespace);
818        } else if value_get(resolved.value, "url").is_some()
819            || value_get(resolved.value, "host").is_some()
820        {
821            warnings
822                .push("templated server namespace was not promoted to an exact value".to_owned());
823        }
824    }
825    let protocol = unique_value(&protocols);
826    let namespace = unique_value(&namespaces);
827    if protocols.len() > 1 {
828        warnings.push("multiple server protocols make broker selection ambiguous".to_owned());
829    }
830    if namespaces.len() > 1 {
831        warnings.push("multiple server namespaces make namespace selection ambiguous".to_owned());
832    }
833    let broker = protocol
834        .as_deref()
835        .map(broker_from_protocol)
836        .filter(|broker| *broker != EventBroker::Generic);
837    let ambiguous = !warnings.is_empty();
838    (
839        ServerFacts {
840            broker,
841            protocol,
842            namespace,
843            ambiguous,
844        },
845        warnings,
846    )
847}
848
849fn server_namespace(server: &Value) -> Option<String> {
850    if let Some(namespace) = value_get(server, "namespace").and_then(literal_string) {
851        return literal_namespace(&namespace);
852    }
853    if let Some(url) = value_get(server, "url").and_then(literal_string) {
854        return literal_namespace(&url);
855    }
856    let host = value_get(server, "host").and_then(literal_string)?;
857    let pathname = value_get(server, "pathname")
858        .and_then(literal_string)
859        .unwrap_or_default();
860    literal_namespace(&format!("{host}{pathname}"))
861}
862
863fn unique_value(values: &BTreeSet<String>) -> Option<String> {
864    (values.len() == 1)
865        .then(|| values.iter().next().cloned())
866        .flatten()
867}
868
869fn literal_namespace(value: &str) -> Option<String> {
870    let trimmed = value.trim();
871    if trimmed.is_empty()
872        || trimmed.contains(['{', '}'])
873        || trimmed.contains("${")
874        || trimmed.chars().any(char::is_whitespace)
875    {
876        return None;
877    }
878    let without_scheme = trimmed
879        .split_once("://")
880        .map_or(trimmed, |(_, remainder)| remainder);
881    let without_credentials = without_scheme
882        .rsplit_once('@')
883        .map_or(without_scheme, |(_, remainder)| remainder);
884    let namespace = without_credentials
885        .split(['?', '#'])
886        .next()
887        .unwrap_or_default()
888        .trim_end_matches('/');
889    (!namespace.is_empty()).then(|| namespace.to_owned())
890}
891
892fn broker_from_protocol(protocol: &str) -> EventBroker {
893    match protocol.to_ascii_lowercase().as_str() {
894        "kafka" | "kafka-secure" => EventBroker::Kafka,
895        "amqp" | "amqps" => EventBroker::RabbitMq,
896        "sns" | "aws-sns" => EventBroker::AwsSns,
897        "sqs" | "aws-sqs" => EventBroker::AwsSqs,
898        "nats" | "nats-secure" => EventBroker::Nats,
899        "googlepubsub" | "google-pubsub" | "gcp-pubsub" | "pubsub" => EventBroker::GooglePubSub,
900        _ => EventBroker::Generic,
901    }
902}
903
904fn broker_from_bindings(value: &Value) -> Option<EventBroker> {
905    let bindings = value_get(value, "bindings")?.as_object()?;
906    for key in bindings.keys() {
907        let broker = match key.to_ascii_lowercase().as_str() {
908            "kafka" => Some(EventBroker::Kafka),
909            "amqp" => Some(EventBroker::RabbitMq),
910            "sns" => Some(EventBroker::AwsSns),
911            "sqs" => Some(EventBroker::AwsSqs),
912            "nats" => Some(EventBroker::Nats),
913            "googlepubsub" | "google-pubsub" | "pubsub" => Some(EventBroker::GooglePubSub),
914            _ => None,
915        };
916        if broker.is_some() {
917            return broker;
918        }
919    }
920    None
921}
922
923fn explicit_delivery_semantics(value: &Value) -> Option<DeliverySemantics> {
924    let raw = exact_string_recursive(
925        value,
926        &[
927            "deliverySemantics",
928            "delivery_semantics",
929            "deliveryGuarantee",
930            "x-delivery-semantics",
931        ],
932        4,
933    )?;
934    parse_delivery_semantics(&raw)
935}
936
937fn parse_delivery_semantics(value: &str) -> Option<DeliverySemantics> {
938    let canonical = value
939        .chars()
940        .filter(char::is_ascii_alphanumeric)
941        .flat_map(char::to_lowercase)
942        .collect::<String>();
943    match canonical.as_str() {
944        "atmostonce" => Some(DeliverySemantics::AtMostOnce),
945        "atleastonce" => Some(DeliverySemantics::AtLeastOnce),
946        "exactlyonce" => Some(DeliverySemantics::ExactlyOnce),
947        "besteffort" => Some(DeliverySemantics::BestEffort),
948        _ => None,
949    }
950}
951
952fn exact_metadata<const N: usize>(values: [Option<&Value>; N], keys: &[&str]) -> Option<String> {
953    values
954        .into_iter()
955        .flatten()
956        .find_map(|value| exact_string_recursive(value, keys, 4))
957}
958
959fn exact_string_recursive(value: &Value, keys: &[&str], depth: usize) -> Option<String> {
960    if depth == 0 {
961        return None;
962    }
963    let mapping = value.as_object()?;
964    for key in keys {
965        if let Some(found) = mapping.get(*key)
966            && let Some(literal) = literal_string(found)
967        {
968            return Some(literal);
969        }
970    }
971    mapping
972        .values()
973        .find_map(|child| exact_string_recursive(child, keys, depth - 1))
974}
975
976fn resolve_local<'a>(root: &'a Value, value: &'a Value) -> ResolvedValue<'a> {
977    let mut current = value;
978    let mut warnings = Vec::new();
979    let mut visited = BTreeSet::new();
980    for _ in 0..MAX_REFERENCE_DEPTH {
981        let Some(reference) = value_get(current, "$ref").and_then(Value::as_str) else {
982            return ResolvedValue {
983                value: current,
984                warnings,
985            };
986        };
987        if !reference.starts_with("#/") {
988            warnings.push(format!(
989                "external reference `{}` was not resolved",
990                bounded_text(reference, MAX_EVIDENCE_TEXT_CHARS)
991            ));
992            return ResolvedValue {
993                value: current,
994                warnings,
995            };
996        }
997        if !visited.insert(reference.to_owned()) {
998            warnings.push(format!(
999                "cyclic local reference `{}` was not resolved",
1000                bounded_text(reference, MAX_EVIDENCE_TEXT_CHARS)
1001            ));
1002            return ResolvedValue {
1003                value: current,
1004                warnings,
1005            };
1006        }
1007        let Some(next) = json_pointer(root, reference) else {
1008            warnings.push(format!(
1009                "unresolved local reference `{}`",
1010                bounded_text(reference, MAX_EVIDENCE_TEXT_CHARS)
1011            ));
1012            return ResolvedValue {
1013                value: current,
1014                warnings,
1015            };
1016        };
1017        current = next;
1018    }
1019    warnings.push(format!(
1020        "local reference depth exceeded {MAX_REFERENCE_DEPTH}"
1021    ));
1022    ResolvedValue {
1023        value: current,
1024        warnings,
1025    }
1026}
1027
1028fn json_pointer<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
1029    let mut current = root;
1030    for component in reference.strip_prefix("#/")?.split('/') {
1031        let decoded = component.replace("~1", "/").replace("~0", "~");
1032        current = current.as_object()?.get(&decoded)?;
1033    }
1034    Some(current)
1035}
1036
1037fn reference_component_name(value: &Value, component: &str) -> Option<String> {
1038    let reference = value_get(value, "$ref")?.as_str()?;
1039    let prefix = format!("#/{component}/");
1040    reference
1041        .strip_prefix(&prefix)
1042        .map(|name| name.replace("~1", "/").replace("~0", "~"))
1043}
1044
1045fn reference_name(value: &Value) -> Option<String> {
1046    value_get(value, "$ref")
1047        .and_then(Value::as_str)
1048        .and_then(|reference| reference.rsplit('/').next())
1049        .filter(|name| !name.is_empty())
1050        .map(|name| name.replace("~1", "/").replace("~0", "~"))
1051}
1052
1053fn value_get<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
1054    value.as_object()?.get(key)
1055}
1056
1057fn mapping_string<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a str> {
1058    mapping.get(key).and_then(Value::as_str)
1059}
1060
1061fn literal_string(value: &Value) -> Option<String> {
1062    value
1063        .as_str()
1064        .map(str::trim)
1065        .filter(|value| !value.is_empty())
1066        .map(str::to_owned)
1067}
1068
1069fn literal_channel(value: &str) -> Option<String> {
1070    let trimmed = value.trim();
1071    (!trimmed.is_empty() && !trimmed.contains(['{', '}']) && !trimmed.contains("${"))
1072        .then(|| trimmed.to_owned())
1073}
1074
1075fn evidence_for_token(input: &str, token: &str, label: &str) -> EventEvidenceLine {
1076    let line = input
1077        .lines()
1078        .position(|source| source.contains(token))
1079        .and_then(|index| u32::try_from(index + 1).ok())
1080        .unwrap_or(1);
1081    EventEvidenceLine {
1082        line,
1083        text: bounded_text(label, MAX_EVIDENCE_TEXT_CHARS),
1084    }
1085}
1086
1087#[derive(Debug, Clone, Copy)]
1088struct SourceRecognition {
1089    broker: EventBroker,
1090    role: EventRole,
1091    call: &'static str,
1092}
1093
1094fn recognize_source_call(statement: &str) -> Option<SourceRecognition> {
1095    let masked = mask_string_contents(statement);
1096    if looks_like_function_declaration(&masked) {
1097        return None;
1098    }
1099    let compact = compact_lowercase(&masked);
1100    if let Some(role) = recognize_sns(&compact) {
1101        return Some(SourceRecognition {
1102            broker: EventBroker::AwsSns,
1103            role,
1104            call: "AWS SNS",
1105        });
1106    }
1107    if let Some(role) = recognize_sqs(&compact) {
1108        return Some(SourceRecognition {
1109            broker: EventBroker::AwsSqs,
1110            role,
1111            call: "AWS SQS",
1112        });
1113    }
1114    if let Some(role) = recognize_google_pubsub(&compact) {
1115        return Some(SourceRecognition {
1116            broker: EventBroker::GooglePubSub,
1117            role,
1118            call: "Google Pub/Sub",
1119        });
1120    }
1121    if let Some(role) = recognize_rabbitmq(&compact) {
1122        return Some(SourceRecognition {
1123            broker: EventBroker::RabbitMq,
1124            role,
1125            call: "RabbitMQ",
1126        });
1127    }
1128    if let Some(role) = recognize_kafka(&compact) {
1129        return Some(SourceRecognition {
1130            broker: EventBroker::Kafka,
1131            role,
1132            call: "Kafka",
1133        });
1134    }
1135    if let Some(role) = recognize_nats(&compact) {
1136        return Some(SourceRecognition {
1137            broker: EventBroker::Nats,
1138            role,
1139            call: "NATS",
1140        });
1141    }
1142    recognize_generic(&compact).map(|role| SourceRecognition {
1143        broker: EventBroker::Generic,
1144        role,
1145        call: "generic pub/sub",
1146    })
1147}
1148
1149fn recognize_sns(source: &str) -> Option<EventRole> {
1150    let identified = source.contains("topicarn")
1151        || source.contains("topic_arn")
1152        || source.contains("sns.")
1153        || source.contains("snsclient");
1154    if !identified {
1155        return None;
1156    }
1157    if contains_call(source, &["createtopic", "create_topic"]) {
1158        Some(EventRole::Declaration)
1159    } else if contains_call(source, &["publish", "send"]) {
1160        Some(EventRole::Publisher)
1161    } else if contains_call(source, &["subscribe"]) {
1162        Some(EventRole::Subscriber)
1163    } else {
1164        None
1165    }
1166}
1167
1168fn recognize_sqs(source: &str) -> Option<EventRole> {
1169    let identified = source.contains("queueurl")
1170        || source.contains("queue_url")
1171        || source.contains("sqs.")
1172        || source.contains("sqsclient")
1173        || source.contains("sendmessage")
1174        || source.contains("send_message")
1175        || source.contains("receivemessage")
1176        || source.contains("receive_message");
1177    if !identified {
1178        return None;
1179    }
1180    if contains_call(source, &["createqueue", "create_queue"]) {
1181        Some(EventRole::Declaration)
1182    } else if contains_call(source, &["sendmessage", "send_message"]) {
1183        Some(EventRole::Publisher)
1184    } else if contains_call(
1185        source,
1186        &[
1187            "receivemessage",
1188            "receive_message",
1189            "startmessagepoller",
1190            "start_message_poller",
1191            "sqslistener",
1192        ],
1193    ) {
1194        Some(EventRole::Subscriber)
1195    } else {
1196        None
1197    }
1198}
1199
1200fn recognize_google_pubsub(source: &str) -> Option<EventRole> {
1201    let identified = source.contains("pubsub")
1202        || source.contains("publisherclient")
1203        || source.contains("subscriberclient")
1204        || source.contains("topicpath")
1205        || source.contains("topic_path")
1206        || source.contains("subscriptionpath")
1207        || source.contains("subscription_path");
1208    if !identified {
1209        return None;
1210    }
1211    if contains_call(
1212        source,
1213        &[
1214            "createtopic",
1215            "create_topic",
1216            "createsubscription",
1217            "create_subscription",
1218        ],
1219    ) {
1220        Some(EventRole::Declaration)
1221    } else if contains_call(source, &["publish", "publishmessage", "publish_message"]) {
1222        Some(EventRole::Publisher)
1223    } else if contains_call(
1224        source,
1225        &[
1226            "subscribe",
1227            "pull",
1228            "streamingpull",
1229            "streaming_pull",
1230            "receive",
1231            "pubsubsubscription",
1232        ],
1233    ) {
1234        Some(EventRole::Subscriber)
1235    } else {
1236        None
1237    }
1238}
1239
1240fn recognize_rabbitmq(source: &str) -> Option<EventRole> {
1241    let identified = source.contains("rabbit")
1242        || source.contains("amqp")
1243        || source.contains("basicpublish")
1244        || source.contains("basic_publish")
1245        || source.contains("basicconsume")
1246        || source.contains("basic_consume")
1247        || source.contains("queuedeclare")
1248        || source.contains("queue_declare")
1249        || source.contains("exchangedeclare")
1250        || source.contains("exchange_declare");
1251    if !identified {
1252        return None;
1253    }
1254    if contains_call(
1255        source,
1256        &[
1257            "queuedeclare",
1258            "queue_declare",
1259            "exchangedeclare",
1260            "exchange_declare",
1261            "assertqueue",
1262            "assertexchange",
1263        ],
1264    ) {
1265        Some(EventRole::Declaration)
1266    } else if contains_call(
1267        source,
1268        &["basicpublish", "basic_publish", "publish", "send"],
1269    ) {
1270        Some(EventRole::Publisher)
1271    } else if contains_call(
1272        source,
1273        &[
1274            "basicconsume",
1275            "basic_consume",
1276            "consume",
1277            "get",
1278            "rabbitlistener",
1279        ],
1280    ) {
1281        Some(EventRole::Subscriber)
1282    } else {
1283        None
1284    }
1285}
1286
1287fn recognize_kafka(source: &str) -> Option<EventRole> {
1288    let identified = source.contains("kafka")
1289        || source.contains("kafkatemplate")
1290        || source.contains("kafkaproducer")
1291        || source.contains("kafkaconsumer")
1292        || source.contains("baserecord::to")
1293        || source.contains("futurerecord::to")
1294        || source.contains("newtopic")
1295        || (source.contains("producer.") && source.contains("topic"))
1296        || (source.contains("consumer.") && source.contains("subscribe"));
1297    if !identified {
1298        return None;
1299    }
1300    if contains_call(source, &["newtopic", "createtopics", "create_topics"]) {
1301        Some(EventRole::Declaration)
1302    } else if contains_call(
1303        source,
1304        &[
1305            "send",
1306            "publish",
1307            "produce",
1308            "writemessages",
1309            "write_messages",
1310        ],
1311    ) {
1312        Some(EventRole::Publisher)
1313    } else if contains_call(
1314        source,
1315        &[
1316            "subscribe",
1317            "subscribetopics",
1318            "subscribe_topics",
1319            "assign",
1320            "poll",
1321            "kafkalistener",
1322        ],
1323    ) {
1324        Some(EventRole::Subscriber)
1325    } else {
1326        None
1327    }
1328}
1329
1330fn recognize_nats(source: &str) -> Option<EventRole> {
1331    let identified = source.contains("nats")
1332        || source.contains("jetstream")
1333        || source.starts_with("nc.")
1334        || source.contains(" nc.")
1335        || source.starts_with("js.")
1336        || source.contains(" js.");
1337    if !identified {
1338        return None;
1339    }
1340    if contains_call(
1341        source,
1342        &["addstream", "add_stream", "createstream", "create_stream"],
1343    ) {
1344        Some(EventRole::Declaration)
1345    } else if contains_call(source, &["publish", "request"]) {
1346        Some(EventRole::Publisher)
1347    } else if contains_call(source, &["subscribe", "queuesubscribe", "queue_subscribe"]) {
1348        Some(EventRole::Subscriber)
1349    } else {
1350        None
1351    }
1352}
1353
1354fn recognize_generic(source: &str) -> Option<EventRole> {
1355    if contains_call(
1356        source,
1357        &[
1358            "declarechannel",
1359            "declare_channel",
1360            "createchannel",
1361            "create_channel",
1362        ],
1363    ) {
1364        Some(EventRole::Declaration)
1365    } else if contains_call(source, &["publish", "publisher.publish"]) {
1366        Some(EventRole::Publisher)
1367    } else if contains_call(source, &["subscribe", "subscriber.subscribe"]) {
1368        Some(EventRole::Subscriber)
1369    } else {
1370        None
1371    }
1372}
1373
1374fn contains_call(source: &str, names: &[&str]) -> bool {
1375    names.iter().any(|name| {
1376        let needle = format!("{name}(");
1377        let mut offset = 0;
1378        while let Some(relative) = source[offset..].find(&needle) {
1379            let position = offset + relative;
1380            if !source[..position]
1381                .chars()
1382                .next_back()
1383                .is_some_and(is_identifier_character)
1384            {
1385                return true;
1386            }
1387            offset = position.saturating_add(needle.len());
1388            if offset >= source.len() {
1389                break;
1390            }
1391        }
1392        false
1393    })
1394}
1395
1396fn looks_like_function_declaration(source: &str) -> bool {
1397    let trimmed = source.trim_start().to_ascii_lowercase();
1398    ["fn ", "def ", "func ", "function "]
1399        .iter()
1400        .any(|prefix| trimmed.starts_with(prefix))
1401        || ["public ", "private ", "protected ", "static "]
1402            .iter()
1403            .any(|prefix| {
1404                trimmed.starts_with(prefix)
1405                    && trimmed
1406                        .split_once('(')
1407                        .is_some_and(|(head, _)| !head.contains(['.', '=']))
1408            })
1409}
1410
1411fn append_source_observations(
1412    output: &mut Vec<EventObservation>,
1413    language: SourceLanguage,
1414    line: u32,
1415    statement: &str,
1416    recognition: SourceRecognition,
1417) {
1418    let channels = source_channels(statement, recognition);
1419    let partition_key = literal_after_named(statement, &["partitionKey", "partition_key", "key"]);
1420    let routing_key = literal_after_named(statement, &["routingKey", "routing_key"]);
1421    let delivery_semantics = literal_after_named(
1422        statement,
1423        &[
1424            "deliverySemantics",
1425            "delivery_semantics",
1426            "deliveryGuarantee",
1427        ],
1428    )
1429    .and_then(|value| parse_delivery_semantics(&value));
1430    let dead_letter_channel = literal_after_named(
1431        statement,
1432        &[
1433            "deadLetterChannel",
1434            "dead_letter_channel",
1435            "deadLetterQueue",
1436            "dead_letter_queue",
1437            "deadLetterTopic",
1438            "dead_letter_topic",
1439            "dlq",
1440        ],
1441    );
1442    let event_type = literal_after_named(statement, &["eventType", "event_type", "messageType"]);
1443    let channels = if channels.is_empty() {
1444        vec![None]
1445    } else {
1446        channels.into_iter().map(Some).collect()
1447    };
1448    for channel in channels {
1449        let mut warnings = Vec::new();
1450        if channel.is_none() {
1451            warnings
1452                .push("dynamic channel expression was not promoted to an exact value".to_owned());
1453        }
1454        let exact = channel.is_some();
1455        output.push(EventObservation {
1456            broker: recognition.broker,
1457            role: recognition.role,
1458            channel,
1459            namespace: None,
1460            protocol: None,
1461            event_type: event_type.clone(),
1462            schema: None,
1463            partition_key: partition_key.clone(),
1464            routing_key: routing_key.clone(),
1465            delivery_semantics,
1466            dead_letter_channel: dead_letter_channel.clone(),
1467            language: Some(language),
1468            evidence: vec![EventEvidenceLine {
1469                line,
1470                text: bounded_text(
1471                    &format!("{} {:?} literal call", recognition.call, recognition.role),
1472                    MAX_EVIDENCE_TEXT_CHARS,
1473                ),
1474            }],
1475            confidence: if exact { 1.0 } else { 0.0 },
1476            incomplete: !exact,
1477            warnings,
1478        });
1479    }
1480}
1481
1482fn source_channels(statement: &str, recognition: SourceRecognition) -> Vec<String> {
1483    let named_keys: &[&str] = match recognition.broker {
1484        EventBroker::Kafka => &["topic", "topics", "Topic"],
1485        EventBroker::RabbitMq => {
1486            if recognition.role == EventRole::Publisher {
1487                &["exchange", "exchangeName", "exchange_name"]
1488            } else {
1489                &["queue", "queueName", "queue_name"]
1490            }
1491        }
1492        EventBroker::AwsSns => &["TopicArn", "topicArn", "topic_arn"],
1493        EventBroker::AwsSqs => &["QueueUrl", "queueUrl", "queue_url"],
1494        EventBroker::Nats => &["subject", "subjects"],
1495        EventBroker::GooglePubSub => {
1496            if recognition.role == EventRole::Subscriber {
1497                &["subscription", "subscriptionName", "subscription_name"]
1498            } else {
1499                &["topic", "topicName", "topic_name"]
1500            }
1501        }
1502        EventBroker::Generic => &["channel", "topic", "queue", "subject"],
1503    };
1504    let mut channels = literals_after_named(statement, named_keys);
1505    if channels.is_empty() {
1506        channels = call_argument_literals(statement, recognition, 0);
1507    }
1508    if recognition.broker == EventBroker::RabbitMq
1509        && recognition.role == EventRole::Publisher
1510        && channels.first().is_some_and(String::is_empty)
1511    {
1512        channels = call_argument_literals(statement, recognition, 1);
1513    }
1514    channels.retain(|channel| !channel.is_empty());
1515    channels.sort();
1516    channels.dedup();
1517    channels
1518}
1519
1520fn call_argument_literals(
1521    statement: &str,
1522    recognition: SourceRecognition,
1523    argument_index: usize,
1524) -> Vec<String> {
1525    let markers: &[&str] = match (recognition.broker, recognition.role) {
1526        (EventBroker::Kafka, EventRole::Publisher) => &[
1527            "BaseRecord::to",
1528            "FutureRecord::to",
1529            "kafkaTemplate.send",
1530            ".send",
1531            ".produce",
1532            "WriteMessages",
1533        ],
1534        (EventBroker::Kafka, EventRole::Subscriber) => &[
1535            ".subscribe",
1536            "SubscribeTopics",
1537            "subscribe_topics",
1538            "KafkaListener",
1539        ],
1540        (EventBroker::RabbitMq, EventRole::Publisher) => {
1541            &["basic_publish", "basicPublish", ".publish", ".Publish"]
1542        }
1543        (EventBroker::RabbitMq, EventRole::Subscriber) => &[
1544            "basic_consume",
1545            "basicConsume",
1546            ".consume",
1547            ".Consume",
1548            "RabbitListener",
1549        ],
1550        (EventBroker::RabbitMq, EventRole::Declaration) => {
1551            &["queue_declare", "queueDeclare", "assertQueue"]
1552        }
1553        (EventBroker::AwsSns | EventBroker::GooglePubSub, EventRole::Publisher) => {
1554            &[".publish", "publish"]
1555        }
1556        (EventBroker::AwsSns, EventRole::Subscriber) => &[".subscribe", "subscribe"],
1557        (EventBroker::AwsSns, EventRole::Declaration) => &["create_topic", "createTopic"],
1558        (EventBroker::AwsSqs, EventRole::Publisher) => &["send_message", "sendMessage"],
1559        (EventBroker::AwsSqs, EventRole::Subscriber) => {
1560            &["receive_message", "receiveMessage", "SqsListener"]
1561        }
1562        (EventBroker::AwsSqs, EventRole::Declaration) => &["create_queue", "createQueue"],
1563        (EventBroker::Nats | EventBroker::Generic, EventRole::Publisher) => {
1564            &[".publish", "publish", ".Publish", "Publish"]
1565        }
1566        (EventBroker::Nats | EventBroker::Generic, EventRole::Subscriber) => {
1567            &[".subscribe", "subscribe", ".Subscribe", "Subscribe"]
1568        }
1569        (EventBroker::Nats, EventRole::Declaration) => &["add_stream", "addStream"],
1570        (EventBroker::GooglePubSub, EventRole::Subscriber) => &[
1571            ".subscribe",
1572            "subscribe",
1573            ".pull",
1574            "pull",
1575            "PubSubSubscription",
1576        ],
1577        (EventBroker::GooglePubSub, EventRole::Declaration) => {
1578            &["create_topic", "createTopic", "create_subscription"]
1579        }
1580        (EventBroker::Generic, EventRole::Declaration) => {
1581            &["declare_channel", "declareChannel", "create_channel"]
1582        }
1583        _ => &[],
1584    };
1585    markers
1586        .iter()
1587        .find_map(|marker| {
1588            argument_expression(statement, marker, argument_index)
1589                .map(static_literals_from_expression)
1590        })
1591        .unwrap_or_default()
1592}
1593
1594fn argument_expression<'a>(source: &'a str, marker: &str, target: usize) -> Option<&'a str> {
1595    let marker_start = source.find(marker)?;
1596    let after_marker = &source[marker_start + marker.len()..];
1597    let open_offset = after_marker.find('(')?;
1598    let arguments = &after_marker[open_offset + 1..];
1599    let mut quote = None;
1600    let mut escaped = false;
1601    let mut depth = 0_u32;
1602    let mut start = 0;
1603    let mut index = 0;
1604    for (offset, character) in arguments.char_indices() {
1605        if let Some(delimiter) = quote {
1606            if escaped {
1607                escaped = false;
1608            } else if character == '\\' {
1609                escaped = true;
1610            } else if character == delimiter {
1611                quote = None;
1612            }
1613            continue;
1614        }
1615        match character {
1616            '"' | '\'' | '`' => quote = Some(character),
1617            '(' | '[' | '{' => depth = depth.saturating_add(1),
1618            ')' if depth == 0 => {
1619                return (index == target).then(|| &arguments[start..offset]);
1620            }
1621            ')' | ']' | '}' => depth = depth.saturating_sub(1),
1622            ',' if depth == 0 => {
1623                if index == target {
1624                    return Some(&arguments[start..offset]);
1625                }
1626                index += 1;
1627                start = offset + character.len_utf8();
1628            }
1629            _ => {}
1630        }
1631    }
1632    (index == target).then(|| &arguments[start..])
1633}
1634
1635fn literals_after_named(source: &str, keys: &[&str]) -> Vec<String> {
1636    let mut output = Vec::new();
1637    for key in keys {
1638        let mut offset = 0;
1639        while let Some(relative) = source[offset..].find(key) {
1640            let position = offset + relative;
1641            if is_identifier_boundary(source, position, key.len()) {
1642                let after = &source[position + key.len()..];
1643                let expression = after
1644                    .trim_start()
1645                    .strip_prefix([':', '='])
1646                    .or_else(|| after.trim_start().strip_prefix('('));
1647                if let Some(expression) = expression {
1648                    output.extend(static_literals_from_expression(expression));
1649                }
1650            }
1651            offset = position.saturating_add(key.len());
1652            if offset >= source.len() {
1653                break;
1654            }
1655        }
1656    }
1657    output.sort();
1658    output.dedup();
1659    output
1660}
1661
1662fn literal_after_named(source: &str, keys: &[&str]) -> Option<String> {
1663    literals_after_named(source, keys).into_iter().next()
1664}
1665
1666fn static_literals_from_expression(expression: &str) -> Vec<String> {
1667    let trimmed = expression.trim_start_matches(|character: char| {
1668        character.is_whitespace() || matches!(character, '&' | '*')
1669    });
1670    if trimmed.starts_with("format!")
1671        || trimmed.starts_with("format(")
1672        || trimmed.starts_with("f\"")
1673        || trimmed.starts_with("f'")
1674        || trimmed.starts_with("F\"")
1675        || trimmed.starts_with("F'")
1676    {
1677        return Vec::new();
1678    }
1679    if let Some(inner) = trimmed.strip_prefix('[') {
1680        return split_literal_list(inner, ']');
1681    }
1682    if let Some(inner) = trimmed.strip_prefix('(') {
1683        return split_literal_list(inner, ')');
1684    }
1685    let Some((value, consumed)) = parse_static_literal(trimmed) else {
1686        return Vec::new();
1687    };
1688    let remainder = trimmed.get(consumed..).unwrap_or_default().trim_start();
1689    if remainder.is_empty() || remainder.starts_with([',', ')', ']', '}', ';', '.']) {
1690        vec![value]
1691    } else {
1692        Vec::new()
1693    }
1694}
1695
1696fn split_literal_list(source: &str, closing: char) -> Vec<String> {
1697    let mut output = Vec::new();
1698    let mut remainder = source;
1699    loop {
1700        remainder = remainder.trim_start();
1701        if remainder.starts_with(closing) || remainder.is_empty() {
1702            break;
1703        }
1704        let Some((value, consumed)) = parse_static_literal(remainder) else {
1705            return Vec::new();
1706        };
1707        output.push(value);
1708        remainder = remainder.get(consumed..).unwrap_or_default().trim_start();
1709        if remainder.starts_with(',') {
1710            remainder = &remainder[1..];
1711        } else if !remainder.starts_with(closing) {
1712            return Vec::new();
1713        }
1714    }
1715    output
1716}
1717
1718fn parse_static_literal(source: &str) -> Option<(String, usize)> {
1719    let (prefix, delimiter, raw) = if source.starts_with("r#\"") {
1720        ("r#\"", '"', true)
1721    } else if source.starts_with("r\"") {
1722        ("r\"", '"', true)
1723    } else if source.starts_with('"') {
1724        ("\"", '"', false)
1725    } else if source.starts_with('\'') {
1726        ("'", '\'', false)
1727    } else if source.starts_with('`') {
1728        ("`", '`', false)
1729    } else {
1730        return None;
1731    };
1732    let mut escaped = false;
1733    let mut value = String::new();
1734    let content_start = prefix.len();
1735    for (relative, character) in source[content_start..].char_indices() {
1736        if raw && character == delimiter {
1737            let end = content_start + relative;
1738            let suffix = if prefix == "r#\"" { "\"#" } else { "\"" };
1739            if source[end..].starts_with(suffix) {
1740                return Some((value, end + suffix.len()));
1741            }
1742            value.push(character);
1743            continue;
1744        }
1745        if !raw && escaped {
1746            value.push(match character {
1747                'n' => '\n',
1748                'r' => '\r',
1749                't' => '\t',
1750                other => other,
1751            });
1752            escaped = false;
1753            continue;
1754        }
1755        if !raw && character == '\\' {
1756            escaped = true;
1757            continue;
1758        }
1759        if !raw && character == delimiter {
1760            if delimiter == '`' && value.contains("${") {
1761                return None;
1762            }
1763            return Some((value, content_start + relative + character.len_utf8()));
1764        }
1765        value.push(character);
1766    }
1767    None
1768}
1769
1770fn is_identifier_boundary(source: &str, position: usize, length: usize) -> bool {
1771    let before = source[..position].chars().next_back();
1772    let after = source[position + length..].chars().next();
1773    !before.is_some_and(is_identifier_character) && !after.is_some_and(is_identifier_character)
1774}
1775
1776fn is_identifier_character(character: char) -> bool {
1777    character.is_ascii_alphanumeric() || character == '_'
1778}
1779
1780fn source_candidate(lines: &[String], start: usize) -> Option<String> {
1781    let first = lines.get(start)?.trim();
1782    if first.is_empty() || !possible_event_line(first) {
1783        return None;
1784    }
1785    let mut candidate = String::new();
1786    let mut balance = 0_i32;
1787    for line in lines.iter().skip(start).take(12) {
1788        if !candidate.is_empty() {
1789            candidate.push(' ');
1790        }
1791        candidate.push_str(line.trim());
1792        balance += delimiter_balance(line);
1793        if balance <= 0 && candidate.contains('(') {
1794            break;
1795        }
1796    }
1797    Some(candidate)
1798}
1799
1800fn possible_event_line(line: &str) -> bool {
1801    let lower = line.to_ascii_lowercase();
1802    [
1803        "publish",
1804        "subscribe",
1805        "send",
1806        "receive",
1807        "consume",
1808        "produce",
1809        "queue",
1810        "topic",
1811        "writemessages",
1812        "basic_",
1813        "createstream",
1814        "create_stream",
1815        "pull(",
1816    ]
1817    .iter()
1818    .any(|marker| lower.contains(marker))
1819}
1820
1821fn delimiter_balance(line: &str) -> i32 {
1822    let mut balance = 0_i32;
1823    let mut quote = None;
1824    let mut escaped = false;
1825    for character in line.chars() {
1826        if let Some(delimiter) = quote {
1827            if escaped {
1828                escaped = false;
1829            } else if character == '\\' {
1830                escaped = true;
1831            } else if character == delimiter {
1832                quote = None;
1833            }
1834            continue;
1835        }
1836        match character {
1837            '"' | '\'' | '`' => quote = Some(character),
1838            '(' | '[' | '{' => balance += 1,
1839            ')' | ']' | '}' => balance -= 1,
1840            _ => {}
1841        }
1842    }
1843    balance
1844}
1845
1846fn sanitize_source(language: SourceLanguage, input: &str) -> Vec<String> {
1847    let hash_comments = matches!(language, SourceLanguage::Python);
1848    let mut block_comment = false;
1849    input
1850        .lines()
1851        .map(|line| sanitize_line(line, hash_comments, &mut block_comment))
1852        .collect()
1853}
1854
1855fn sanitize_line(line: &str, hash_comments: bool, block_comment: &mut bool) -> String {
1856    let characters = line.char_indices().collect::<Vec<_>>();
1857    let mut output = String::with_capacity(line.len());
1858    let mut index = 0;
1859    let mut quote = None;
1860    let mut escaped = false;
1861    while let Some(&(offset, character)) = characters.get(index) {
1862        let next = characters.get(index + 1).map(|(_, value)| *value);
1863        if *block_comment {
1864            if character == '*' && next == Some('/') {
1865                *block_comment = false;
1866                index += 2;
1867            } else {
1868                index += 1;
1869            }
1870            continue;
1871        }
1872        if let Some(delimiter) = quote {
1873            output.push(character);
1874            if escaped {
1875                escaped = false;
1876            } else if character == '\\' {
1877                escaped = true;
1878            } else if character == delimiter {
1879                quote = None;
1880            }
1881            index += 1;
1882            continue;
1883        }
1884        if matches!(character, '"' | '\'' | '`') {
1885            quote = Some(character);
1886            output.push(character);
1887            index += 1;
1888            continue;
1889        }
1890        if character == '/' && next == Some('/') {
1891            break;
1892        }
1893        if character == '/' && next == Some('*') {
1894            *block_comment = true;
1895            index += 2;
1896            continue;
1897        }
1898        if hash_comments && character == '#' {
1899            break;
1900        }
1901        output.push_str(&line[offset..offset + character.len_utf8()]);
1902        index += 1;
1903    }
1904    output
1905}
1906
1907fn mask_string_contents(source: &str) -> String {
1908    let mut output = String::with_capacity(source.len());
1909    let mut quote = None;
1910    let mut escaped = false;
1911    for character in source.chars() {
1912        if let Some(delimiter) = quote {
1913            if escaped {
1914                escaped = false;
1915                output.push(' ');
1916            } else if character == '\\' {
1917                escaped = true;
1918                output.push(' ');
1919            } else if character == delimiter {
1920                quote = None;
1921                output.push(character);
1922            } else {
1923                output.push(' ');
1924            }
1925        } else if matches!(character, '"' | '\'' | '`') {
1926            quote = Some(character);
1927            output.push(character);
1928        } else {
1929            output.push(character);
1930        }
1931    }
1932    output
1933}
1934
1935fn compact_lowercase(source: &str) -> String {
1936    source
1937        .chars()
1938        .filter(|character| !character.is_whitespace())
1939        .flat_map(char::to_lowercase)
1940        .collect()
1941}
1942
1943fn bounded_source(input: &str) -> (&str, bool) {
1944    if input.len() <= MAX_SOURCE_BYTES {
1945        return (input, false);
1946    }
1947    let boundary = input.floor_char_boundary(MAX_SOURCE_BYTES);
1948    (&input[..boundary], true)
1949}
1950
1951fn bounded_text(value: &str, max_chars: usize) -> String {
1952    let mut output = value.chars().take(max_chars).collect::<String>();
1953    if value.chars().count() > max_chars {
1954        output.push_str("...");
1955    }
1956    output
1957}
1958
1959fn normalize_observation(observation: &mut EventObservation) {
1960    observation.warnings.sort();
1961    observation.warnings.dedup();
1962    observation.evidence.sort();
1963    observation.evidence.dedup();
1964    if let Some(schema) = &mut observation.schema {
1965        schema.fields.sort();
1966        schema.fields.dedup();
1967    }
1968    if !observation.warnings.is_empty() {
1969        observation.incomplete = true;
1970    }
1971}
1972
1973fn finish_document(document: &mut EventDocument) {
1974    for observation in &mut document.observations {
1975        normalize_observation(observation);
1976    }
1977    document.observations.sort_by_key(observation_sort_key);
1978    let mut deduplicated = Vec::<EventObservation>::new();
1979    for observation in std::mem::take(&mut document.observations) {
1980        if let Some(previous) = deduplicated.last_mut()
1981            && observation_sort_key(previous) == observation_sort_key(&observation)
1982        {
1983            previous.evidence.extend(observation.evidence);
1984            normalize_observation(previous);
1985        } else {
1986            deduplicated.push(observation);
1987        }
1988    }
1989    document.observations = deduplicated;
1990    document.warnings.sort();
1991    document.warnings.dedup();
1992    document.incomplete |= document
1993        .observations
1994        .iter()
1995        .any(|observation| observation.incomplete);
1996}
1997
1998fn observation_sort_key(observation: &EventObservation) -> String {
1999    format!(
2000        "{:?}\u{0}{:?}\u{0}{}\u{0}{}\u{0}{}\u{0}{}\u{0}{:?}\u{0}{:?}\u{0}{}\u{0}{}\u{0}{:?}\u{0}{}\u{0}{:?}\u{0}{}\u{0}{:?}",
2001        observation.broker,
2002        observation.role,
2003        observation.channel.as_deref().unwrap_or_default(),
2004        observation.event_type.as_deref().unwrap_or_default(),
2005        observation.namespace.as_deref().unwrap_or_default(),
2006        observation.protocol.as_deref().unwrap_or_default(),
2007        observation.schema,
2008        observation.partition_key,
2009        observation.routing_key.as_deref().unwrap_or_default(),
2010        observation
2011            .dead_letter_channel
2012            .as_deref()
2013            .unwrap_or_default(),
2014        observation.delivery_semantics,
2015        observation.confidence,
2016        observation.language,
2017        observation.incomplete,
2018        observation.warnings
2019    )
2020}
2021
2022#[cfg(test)]
2023mod tests {
2024    use super::*;
2025
2026    fn source_observation(language: SourceLanguage, source: &str) -> EventObservation {
2027        parse_event_source(language, source)
2028            .observations
2029            .into_iter()
2030            .next()
2031            .expect("fixture should produce an observation")
2032    }
2033
2034    #[test]
2035    fn asyncapi_two_extracts_kafka_message_schema_and_bindings() {
2036        let source = r#"
2037asyncapi: 2.6.0
2038servers:
2039  production:
2040    url: kafka.example.test:9092/orders
2041    protocol: kafka
2042channels:
2043  orders.created:
2044    bindings:
2045      kafka:
2046        topicConfiguration: {}
2047    publish:
2048      bindings:
2049        kafka:
2050          x-partition-key: tenant_id
2051      message:
2052        name: OrderCreated
2053        schemaFormat: application/schema+json;version=draft-07
2054        x-schema-version: "2"
2055        payload:
2056          type: object
2057          required: [id]
2058          properties:
2059            tenant_id: { type: string }
2060            id: { type: string }
2061"#;
2062        let document = extract_asyncapi("asyncapi.yaml", source).expect("fixture should parse");
2063
2064        assert!(matches!(
2065            document.observations.as_slice(),
2066            [observation]
2067                if observation.broker == EventBroker::Kafka
2068                    && observation.role == EventRole::Publisher
2069                    && observation.channel.as_deref() == Some("orders.created")
2070                    && observation.partition_key.as_deref() == Some("tenant_id")
2071                    && observation.schema.as_ref().is_some_and(|schema|
2072                        schema.name.as_deref() == Some("OrderCreated")
2073                            && schema.version.as_deref() == Some("2")
2074                            && schema.fields.len() == 2)
2075        ));
2076    }
2077
2078    #[test]
2079    fn asyncapi_three_extracts_send_and_receive_operations() {
2080        let source = r"
2081asyncapi: 3.0.0
2082servers:
2083  broker:
2084    host: nats.example.test
2085    protocol: nats
2086channels:
2087  jobs:
2088    address: jobs.ready
2089    messages:
2090      Job:
2091        name: Job
2092        payload:
2093          type: object
2094          properties:
2095            id: { type: string }
2096operations:
2097  sendJob:
2098    action: send
2099    channel:
2100      $ref: '#/channels/jobs'
2101  receiveJob:
2102    action: receive
2103    channel:
2104      $ref: '#/channels/jobs'
2105";
2106        let document = extract_asyncapi("asyncapi.json", source).expect("fixture should parse");
2107
2108        assert!(
2109            document.observations.len() == 2
2110                && document.observations.iter().all(|observation| {
2111                    observation.channel.as_deref() == Some("jobs.ready")
2112                        && observation.namespace.as_deref() == Some("nats.example.test")
2113                        && observation.broker == EventBroker::Nats
2114                })
2115        );
2116    }
2117
2118    #[test]
2119    fn asyncapi_multiple_server_protocols_remain_ambiguous() {
2120        let source = r"
2121asyncapi: 2.6.0
2122servers:
2123  kafka:
2124    url: kafka.example.test
2125    protocol: kafka
2126  amqp:
2127    url: rabbit.example.test
2128    protocol: amqp
2129channels:
2130  events:
2131    publish:
2132      message:
2133        name: Event
2134";
2135        let document = extract_asyncapi("asyncapi.yaml", source).expect("fixture should parse");
2136
2137        assert!(
2138            document.incomplete
2139                && document
2140                    .warnings
2141                    .iter()
2142                    .any(|warning| { warning.contains("multiple server protocols") })
2143        );
2144    }
2145
2146    #[test]
2147    fn asyncapi_templated_channel_is_not_exact() {
2148        let source = r"
2149asyncapi: 2.6.0
2150channels:
2151  orders.{tenant}:
2152    subscribe:
2153      message:
2154        name: Order
2155";
2156        let document = extract_asyncapi("asyncapi.yaml", source).expect("fixture should parse");
2157
2158        assert!(matches!(
2159            document.observations.as_slice(),
2160            [observation]
2161                if observation.channel.is_none()
2162                    && observation.incomplete
2163                    && observation.confidence == 0.0
2164        ));
2165    }
2166
2167    #[test]
2168    fn asyncapi_supports_json_and_dead_letter_metadata() {
2169        let source = r#"{
2170          "asyncapi": "2.6.0",
2171          "servers": {"queue": {"url": "queue.example.test", "protocol": "sqs"}},
2172          "channels": {
2173            "jobs": {
2174              "subscribe": {
2175                "x-delivery-semantics": "at-least-once",
2176                "deadLetterQueue": "jobs-dead",
2177                "message": {"name": "Job"}
2178              }
2179            }
2180          }
2181        }"#;
2182        let document = extract_asyncapi("asyncapi.json", source).expect("fixture should parse");
2183
2184        assert!(matches!(
2185            document.observations.as_slice(),
2186            [observation]
2187                if observation.broker == EventBroker::AwsSqs
2188                    && observation.delivery_semantics
2189                        == Some(DeliverySemantics::AtLeastOnce)
2190                    && observation.dead_letter_channel.as_deref() == Some("jobs-dead")
2191        ));
2192    }
2193
2194    #[test]
2195    fn kafka_literal_call_is_exact() {
2196        let observation = source_observation(
2197            SourceLanguage::Rust,
2198            r#"producer.send(FutureRecord::to("orders.created").payload(body));"#,
2199        );
2200
2201        assert!(
2202            observation.broker == EventBroker::Kafka
2203                && observation.channel.as_deref() == Some("orders.created")
2204                && observation.role == EventRole::Publisher
2205        );
2206    }
2207
2208    #[test]
2209    fn rabbitmq_literal_call_extracts_exchange() {
2210        let observation = source_observation(
2211            SourceLanguage::Python,
2212            r#"channel.basic_publish(exchange="orders", routing_key="created", body=data)"#,
2213        );
2214
2215        assert!(
2216            observation.broker == EventBroker::RabbitMq
2217                && observation.channel.as_deref() == Some("orders")
2218                && observation.routing_key.as_deref() == Some("created")
2219        );
2220    }
2221
2222    #[test]
2223    fn sns_literal_call_extracts_topic_arn() {
2224        let observation = source_observation(
2225            SourceLanguage::TypeScript,
2226            r#"sns.publish({ TopicArn: "arn:aws:sns:us-east-1:123:orders", Message: body });"#,
2227        );
2228
2229        assert!(
2230            observation.broker == EventBroker::AwsSns
2231                && observation.channel.as_deref() == Some("arn:aws:sns:us-east-1:123:orders")
2232        );
2233    }
2234
2235    #[test]
2236    fn sqs_literal_call_extracts_queue_url() {
2237        let observation = source_observation(
2238            SourceLanguage::JavaScript,
2239            r#"sqs.sendMessage({ QueueUrl: "https://sqs.example.test/orders", MessageBody: body });"#,
2240        );
2241
2242        assert!(
2243            observation.broker == EventBroker::AwsSqs
2244                && observation.channel.as_deref() == Some("https://sqs.example.test/orders")
2245        );
2246    }
2247
2248    #[test]
2249    fn nats_literal_call_extracts_subject() {
2250        let observation = source_observation(
2251            SourceLanguage::Go,
2252            r#"natsConnection.Publish("orders.created", payload)"#,
2253        );
2254
2255        assert!(
2256            observation.broker == EventBroker::Nats
2257                && observation.channel.as_deref() == Some("orders.created")
2258        );
2259    }
2260
2261    #[test]
2262    fn google_pubsub_literal_call_extracts_topic() {
2263        let observation = source_observation(
2264            SourceLanguage::Java,
2265            r#"pubsubPublisher.publish("projects/demo/topics/orders", message);"#,
2266        );
2267
2268        assert!(
2269            observation.broker == EventBroker::GooglePubSub
2270                && observation.channel.as_deref() == Some("projects/demo/topics/orders")
2271        );
2272    }
2273
2274    #[test]
2275    fn generic_literal_subscriber_is_exact() {
2276        let observation = source_observation(
2277            SourceLanguage::Python,
2278            r#"event_bus.subscribe("inventory.changed", handler)"#,
2279        );
2280
2281        assert!(
2282            observation.broker == EventBroker::Generic
2283                && observation.role == EventRole::Subscriber
2284                && observation.channel.as_deref() == Some("inventory.changed")
2285        );
2286    }
2287
2288    #[test]
2289    fn dynamic_channel_remains_incomplete_without_exact_value() {
2290        let observation = source_observation(
2291            SourceLanguage::TypeScript,
2292            "eventBus.publish(topicName, payload);",
2293        );
2294
2295        assert!(
2296            observation.channel.is_none()
2297                && observation.incomplete
2298                && observation.confidence == 0.0
2299                && observation
2300                    .warnings
2301                    .iter()
2302                    .any(|warning| warning.contains("dynamic channel"))
2303        );
2304    }
2305
2306    #[test]
2307    fn interpolated_literal_remains_dynamic() {
2308        let observation = source_observation(
2309            SourceLanguage::JavaScript,
2310            "nats.publish(`orders.${tenant}`, payload);",
2311        );
2312
2313        assert!(observation.channel.is_none() && observation.incomplete);
2314    }
2315
2316    #[test]
2317    fn concatenated_literal_remains_dynamic() {
2318        let observation = source_observation(
2319            SourceLanguage::Java,
2320            r#"eventBus.publish("orders." + tenant, payload);"#,
2321        );
2322
2323        assert!(observation.channel.is_none() && observation.incomplete);
2324    }
2325
2326    #[test]
2327    fn call_text_inside_string_is_not_observed() {
2328        let document = parse_event_source(
2329            SourceLanguage::Rust,
2330            r#"let example = "event_bus.publish(\"orders\", payload)";"#,
2331        );
2332
2333        assert!(document.observations.is_empty());
2334    }
2335
2336    #[test]
2337    fn evidence_does_not_retain_source_or_payload() {
2338        let secret = "private-message-body";
2339        let document = parse_event_source(
2340            SourceLanguage::JavaScript,
2341            &format!(r#"eventBus.publish("orders", "{secret}");"#),
2342        );
2343        let serialized = serde_json::to_string(&document).expect("event document should serialize");
2344
2345        assert!(!serialized.contains(secret));
2346    }
2347
2348    #[test]
2349    fn source_results_are_sorted_and_deduplicated() {
2350        let document = parse_event_source(
2351            SourceLanguage::Python,
2352            "bus.publish(\"z\", body)\nbus.publish(\"a\", body)\nbus.publish(\"a\", body)",
2353        );
2354
2355        assert!(matches!(
2356            document.observations.as_slice(),
2357            [first, second]
2358                if first.channel.as_deref() == Some("a")
2359                    && second.channel.as_deref() == Some("z")
2360        ));
2361    }
2362}