Skip to main content

codemem_engine/index/
api_surface.rs

1//! API surface detection: endpoint definitions and HTTP client calls.
2//!
3//! Post-processing pass on extracted symbols to detect REST/HTTP endpoints
4//! and client calls for cross-service linking.
5
6use crate::index::symbol::{Reference, ReferenceKind, Symbol, SymbolKind};
7use std::sync::LazyLock;
8
9// ── Precompiled regexes ─────────────────────────────────────────────────────
10
11static RE_QUOTED_STRING: LazyLock<regex::Regex> =
12    LazyLock::new(|| regex::Regex::new(r#"["']([^"']+)["']"#).unwrap());
13
14static RE_METHODS_PARAM: LazyLock<regex::Regex> =
15    LazyLock::new(|| regex::Regex::new(r#"methods\s*=\s*\[([^\]]+)\]"#).unwrap());
16
17static RE_NESTJS_METHOD: LazyLock<regex::Regex> =
18    LazyLock::new(|| regex::Regex::new(r"^@(Get|Post|Put|Delete|Patch|Head|Options)\b").unwrap());
19
20static RE_FLASK_PARAM: LazyLock<regex::Regex> =
21    LazyLock::new(|| regex::Regex::new(r"<(?:\w+:)?(\w+)>").unwrap());
22
23static RE_EXPRESS_PARAM: LazyLock<regex::Regex> =
24    LazyLock::new(|| regex::Regex::new(r":(\w+)").unwrap());
25
26/// A detected API endpoint.
27#[derive(Debug, Clone, PartialEq)]
28pub struct DetectedEndpoint {
29    /// Endpoint ID: "ep:{namespace}:{method}:{path}"
30    pub id: String,
31    /// HTTP method (GET, POST, PUT, DELETE, PATCH, etc.) or None for catch-all.
32    pub method: Option<String>,
33    /// URL path pattern, normalized (e.g., "/api/users/{id}").
34    pub path: String,
35    /// Handler symbol qualified name.
36    pub handler: String,
37    /// File path of the handler.
38    pub file_path: String,
39    /// Line number.
40    pub line: usize,
41}
42
43/// A detected HTTP client call.
44#[derive(Debug, Clone, PartialEq)]
45pub struct DetectedClientCall {
46    /// Symbol making the HTTP call.
47    pub caller: String,
48    /// HTTP method if detectable.
49    pub method: Option<String>,
50    /// URL pattern extracted from the call (may be partial/relative).
51    pub url_pattern: Option<String>,
52    /// The HTTP client library being used.
53    pub client_library: String,
54    /// File path.
55    pub file_path: String,
56    /// Line number.
57    pub line: usize,
58}
59
60/// A detected event channel interaction (publish or subscribe).
61#[derive(Debug, Clone, PartialEq)]
62pub struct DetectedEventCall {
63    /// Symbol making the event call.
64    pub caller: String,
65    /// Channel/topic name (if extractable from the call target).
66    pub channel: Option<String>,
67    /// "publish" or "subscribe".
68    pub direction: String,
69    /// Protocol: kafka, rabbitmq, redis, sqs, sns, nats.
70    pub protocol: String,
71    /// File path.
72    pub file_path: String,
73    /// Line number.
74    pub line: usize,
75}
76
77/// Result of API surface detection.
78#[derive(Debug, Default)]
79pub struct ApiSurfaceResult {
80    pub endpoints: Vec<DetectedEndpoint>,
81    pub client_calls: Vec<DetectedClientCall>,
82    pub event_calls: Vec<DetectedEventCall>,
83}
84
85/// Detect API endpoints from extracted symbols.
86///
87/// Scans symbol attributes/decorators for framework-specific route patterns:
88/// - Python: `@app.route`, `@router.get`, `@api_view`, `@GetMapping` (for Django views, Flask, FastAPI)
89/// - TypeScript: `@Get`, `@Post` (NestJS), `app.get` (Express) — detected from call references
90/// - Java: `@GetMapping`, `@PostMapping`, `@RequestMapping`
91/// - Go: detected via call patterns (`http.HandleFunc`, `router.GET`, etc.)
92pub fn detect_endpoints(symbols: &[Symbol], namespace: &str) -> Vec<DetectedEndpoint> {
93    let mut endpoints = Vec::new();
94
95    for sym in symbols {
96        // Check attributes/decorators for route patterns
97        for attr in &sym.attributes {
98            if let Some(ep) = parse_route_decorator(attr, sym, namespace) {
99                endpoints.push(ep);
100            }
101        }
102
103        // Check for Django URL pattern-style views (class-based views)
104        if is_django_view_class(sym) {
105            // Django CBVs: methods like get(), post() on View subclasses
106            // The URL pattern linking happens elsewhere; here we just mark the handler
107            for method in &["get", "post", "put", "patch", "delete"] {
108                if sym.kind == SymbolKind::Method && sym.name == *method {
109                    if let Some(parent) = &sym.parent {
110                        endpoints.push(DetectedEndpoint {
111                            id: format!("ep:{namespace}:{}:view:{parent}", method.to_uppercase()),
112                            method: Some(method.to_uppercase()),
113                            path: format!("view:{parent}"), // placeholder until URL conf resolved
114                            handler: sym.qualified_name.clone(),
115                            file_path: sym.file_path.clone(),
116                            line: sym.line_start,
117                        });
118                    }
119                }
120            }
121        }
122    }
123
124    endpoints
125}
126
127/// Parse a route decorator/annotation string into an endpoint.
128///
129/// Handles patterns like:
130/// - `@app.route("/users")` or `@app.route("/users", methods=["GET", "POST"])`
131/// - `@router.get("/users/{id}")` or `@app.get("/users/<int:id>")`
132/// - `@GetMapping("/users")` or `@RequestMapping(value="/users", method=RequestMethod.GET)`
133/// - `@Get("/users")` (NestJS)
134/// - `@api_view(["GET"])` (DRF — path comes from urls.py, not decorator)
135fn parse_route_decorator(attr: &str, sym: &Symbol, namespace: &str) -> Option<DetectedEndpoint> {
136    let attr_lower = attr.to_lowercase();
137
138    // Flask/FastAPI style: @app.route("/path") or @router.get("/path")
139    if attr_lower.contains("route(")
140        || attr_lower.contains(".get(")
141        || attr_lower.contains(".post(")
142        || attr_lower.contains(".put(")
143        || attr_lower.contains(".delete(")
144        || attr_lower.contains(".patch(")
145    {
146        let method = extract_http_method_from_decorator(attr);
147        let path = extract_path_from_decorator(attr)?;
148        let normalized_path = normalize_path_pattern(&path);
149
150        return Some(DetectedEndpoint {
151            id: format!(
152                "ep:{namespace}:{}:{normalized_path}",
153                method.as_deref().unwrap_or("ANY")
154            ),
155            method,
156            path: normalized_path,
157            handler: sym.qualified_name.clone(),
158            file_path: sym.file_path.clone(),
159            line: sym.line_start,
160        });
161    }
162
163    // Spring style: @GetMapping("/path"), @PostMapping("/path"), @RequestMapping(...)
164    if attr_lower.contains("mapping(") || attr_lower.contains("mapping\"") {
165        let method = extract_spring_method(attr);
166        let path = extract_path_from_decorator(attr)?;
167        let normalized_path = normalize_path_pattern(&path);
168
169        return Some(DetectedEndpoint {
170            id: format!(
171                "ep:{namespace}:{}:{normalized_path}",
172                method.as_deref().unwrap_or("ANY")
173            ),
174            method,
175            path: normalized_path,
176            handler: sym.qualified_name.clone(),
177            file_path: sym.file_path.clone(),
178            line: sym.line_start,
179        });
180    }
181
182    // NestJS style: @Get("/path"), @Post("/path")
183    if let Some(method) = extract_nestjs_method(attr) {
184        let path = extract_path_from_decorator(attr).unwrap_or_else(|| "/".to_string());
185        let normalized_path = normalize_path_pattern(&path);
186
187        return Some(DetectedEndpoint {
188            id: format!("ep:{namespace}:{method}:{normalized_path}"),
189            method: Some(method),
190            path: normalized_path,
191            handler: sym.qualified_name.clone(),
192            file_path: sym.file_path.clone(),
193            line: sym.line_start,
194        });
195    }
196
197    None
198}
199
200/// Detect HTTP client calls from extracted references.
201///
202/// Scans call references for known HTTP client patterns:
203/// - Python: `requests.get`/`post`/..., `httpx.get`/`post`/..., `aiohttp`
204/// - TS/JS: `fetch`, `axios.get`/`post`/..., `got`
205/// - Java: `RestTemplate`, `WebClient`, `HttpClient`
206/// - Go: `http.Get`, `http.Post`, `http.NewRequest`
207pub fn detect_client_calls(references: &[Reference]) -> Vec<DetectedClientCall> {
208    let mut calls = Vec::new();
209
210    for r in references {
211        if r.kind != ReferenceKind::Call {
212            continue;
213        }
214
215        if let Some(call) = parse_client_call(&r.target_name, r) {
216            calls.push(call);
217        }
218    }
219
220    calls
221}
222
223fn parse_client_call(target: &str, reference: &Reference) -> Option<DetectedClientCall> {
224    let target_lower = target.to_lowercase();
225
226    // Python: requests.get, requests.post, httpx.get, etc.
227    if target_lower.starts_with("requests.") || target_lower.starts_with("httpx.") {
228        let parts: Vec<&str> = target.splitn(2, '.').collect();
229        let library = parts[0].to_string();
230        let method = parts.get(1).and_then(|m| http_method_from_name(m));
231
232        return Some(DetectedClientCall {
233            caller: reference.source_qualified_name.clone(),
234            method,
235            url_pattern: None, // would need string literal analysis
236            client_library: library,
237            file_path: reference.file_path.clone(),
238            line: reference.line,
239        });
240    }
241
242    // TS/JS: fetch (global function)
243    if target_lower == "fetch" {
244        return Some(DetectedClientCall {
245            caller: reference.source_qualified_name.clone(),
246            method: None, // determined by options argument
247            url_pattern: None,
248            client_library: "fetch".to_string(),
249            file_path: reference.file_path.clone(),
250            line: reference.line,
251        });
252    }
253
254    // TS/JS: axios.get, axios.post, etc.
255    if target_lower.starts_with("axios.") {
256        let method = target.split('.').nth(1).and_then(http_method_from_name);
257        return Some(DetectedClientCall {
258            caller: reference.source_qualified_name.clone(),
259            method,
260            url_pattern: None,
261            client_library: "axios".to_string(),
262            file_path: reference.file_path.clone(),
263            line: reference.line,
264        });
265    }
266
267    // Go: http.Get, http.Post, http.NewRequest
268    if target_lower.starts_with("http.")
269        && (target.contains("Get")
270            || target.contains("Post")
271            || target.contains("NewRequest")
272            || target.contains("Do"))
273    {
274        let method = if target.contains("Get") {
275            Some("GET".to_string())
276        } else if target.contains("Post") {
277            Some("POST".to_string())
278        } else {
279            None
280        };
281        return Some(DetectedClientCall {
282            caller: reference.source_qualified_name.clone(),
283            method,
284            url_pattern: None,
285            client_library: "net/http".to_string(),
286            file_path: reference.file_path.clone(),
287            line: reference.line,
288        });
289    }
290
291    // Java: RestTemplate, WebClient
292    if target_lower.contains("resttemplate")
293        || target_lower.contains("webclient")
294        || target_lower.contains("httpclient")
295    {
296        return Some(DetectedClientCall {
297            caller: reference.source_qualified_name.clone(),
298            method: None,
299            url_pattern: None,
300            client_library: target.split('.').next().unwrap_or(target).to_string(),
301            file_path: reference.file_path.clone(),
302            line: reference.line,
303        });
304    }
305
306    None
307}
308
309// ── Helper functions ──
310
311/// Extract the first quoted string from a decorator (the path argument).
312fn extract_path_from_decorator(attr: &str) -> Option<String> {
313    RE_QUOTED_STRING.captures(attr).map(|c| c[1].to_string())
314}
315
316/// Extract HTTP method from a decorator like `@app.get(...)` or `@router.post(...)`
317fn extract_http_method_from_decorator(attr: &str) -> Option<String> {
318    let attr_lower = attr.to_lowercase();
319    for method in &["get", "post", "put", "delete", "patch", "head", "options"] {
320        // Match .get( or .post( etc
321        if attr_lower.contains(&format!(".{method}(")) {
322            return Some(method.to_uppercase());
323        }
324    }
325    // @app.route with methods= parameter
326    if attr_lower.contains("route(") {
327        if let Some(methods) = extract_methods_param(attr) {
328            return methods.first().cloned();
329        }
330    }
331    None
332}
333
334/// Extract `methods=["GET", "POST"]` from a route decorator.
335fn extract_methods_param(attr: &str) -> Option<Vec<String>> {
336    let caps = RE_METHODS_PARAM.captures(attr)?;
337    let methods_str = &caps[1];
338    let methods: Vec<String> = methods_str
339        .split(',')
340        .map(|m| {
341            m.trim()
342                .trim_matches(|c| c == '"' || c == '\'')
343                .to_uppercase()
344        })
345        .filter(|m| !m.is_empty())
346        .collect();
347    if methods.is_empty() {
348        None
349    } else {
350        Some(methods)
351    }
352}
353
354/// Extract HTTP method from Spring annotations.
355fn extract_spring_method(attr: &str) -> Option<String> {
356    let attr_lower = attr.to_lowercase();
357    if attr_lower.contains("getmapping") {
358        return Some("GET".to_string());
359    }
360    if attr_lower.contains("postmapping") {
361        return Some("POST".to_string());
362    }
363    if attr_lower.contains("putmapping") {
364        return Some("PUT".to_string());
365    }
366    if attr_lower.contains("deletemapping") {
367        return Some("DELETE".to_string());
368    }
369    if attr_lower.contains("patchmapping") {
370        return Some("PATCH".to_string());
371    }
372    // @RequestMapping with method= parameter
373    if attr_lower.contains("requestmapping") {
374        if attr_lower.contains("get") {
375            return Some("GET".to_string());
376        }
377        if attr_lower.contains("post") {
378            return Some("POST".to_string());
379        }
380    }
381    None
382}
383
384/// Extract HTTP method from NestJS decorators.
385fn extract_nestjs_method(attr: &str) -> Option<String> {
386    // NestJS: @Get, @Post, @Put, @Delete, @Patch
387    // These are standalone decorators (not method calls on an object)
388    RE_NESTJS_METHOD.captures(attr).map(|c| c[1].to_uppercase())
389}
390
391/// Normalize a URL path pattern:
392/// - Flask: `/users/<int:id>` -> `/users/{id}`
393/// - Express: `/users/:id` -> `/users/{id}`
394/// - Spring: `/users/{id}` -> already normalized
395/// - Go: `/users/{id}` -> already normalized
396pub fn normalize_path_pattern(path: &str) -> String {
397    let mut result = path.to_string();
398
399    // Flask: <type:name> or <name> → {name}
400    result = RE_FLASK_PARAM.replace_all(&result, "{$1}").to_string();
401
402    // Express: :name → {name}
403    let express_re = &*RE_EXPRESS_PARAM;
404    result = express_re.replace_all(&result, "{$1}").to_string();
405
406    // Ensure leading slash
407    if !result.starts_with('/') {
408        result = format!("/{result}");
409    }
410
411    // Remove trailing slash (unless it's just "/")
412    if result.len() > 1 && result.ends_with('/') {
413        result.pop();
414    }
415
416    result
417}
418
419/// Check if a symbol looks like a Django class-based view.
420fn is_django_view_class(sym: &Symbol) -> bool {
421    if sym.kind != SymbolKind::Method {
422        return false;
423    }
424    // Check if parent class has View-like attributes
425    sym.parent
426        .as_ref()
427        .is_some_and(|p| p.ends_with("View") || p.ends_with("ViewSet") || p.ends_with("APIView"))
428}
429
430/// Map a method name to HTTP method string.
431fn http_method_from_name(name: &str) -> Option<String> {
432    match name.to_lowercase().as_str() {
433        "get" => Some("GET".to_string()),
434        "post" => Some("POST".to_string()),
435        "put" => Some("PUT".to_string()),
436        "delete" => Some("DELETE".to_string()),
437        "patch" => Some("PATCH".to_string()),
438        "head" => Some("HEAD".to_string()),
439        "options" => Some("OPTIONS".to_string()),
440        _ => None,
441    }
442}
443
444/// Match a client call URL against registered endpoints.
445///
446/// Returns the best matching endpoint with confidence.
447pub fn match_endpoint<'a>(
448    url_path: &str,
449    method: Option<&str>,
450    endpoints: &'a [DetectedEndpoint],
451) -> Option<(&'a DetectedEndpoint, f64)> {
452    let normalized = normalize_path_pattern(url_path);
453    let mut best: Option<(&DetectedEndpoint, f64)> = None;
454
455    for ep in endpoints {
456        // Base confidence from path matching
457        let base_confidence: f64 = if ep.path == normalized {
458            1.0
459        } else if paths_match_with_params(&normalized, &ep.path) {
460            0.9
461        } else if normalized.starts_with(&ep.path) || ep.path.starts_with(&normalized) {
462            0.7
463        } else {
464            continue;
465        };
466
467        let mut confidence = base_confidence;
468
469        // Method match bonus
470        if let (Some(call_method), Some(ep_method)) = (method, ep.method.as_deref()) {
471            if call_method.eq_ignore_ascii_case(ep_method) {
472                confidence += 0.05;
473            } else {
474                confidence -= 0.1;
475            }
476        }
477
478        confidence = confidence.clamp(0.0, 1.0);
479
480        if best.is_none() || confidence > best.unwrap().1 {
481            best = Some((ep, confidence));
482        }
483    }
484
485    // Only return matches above threshold
486    best.filter(|(_, c)| *c >= 0.5)
487}
488
489/// Check if two paths match allowing parameter substitution.
490/// e.g., "/users/123" matches "/users/{id}"
491fn paths_match_with_params(actual: &str, pattern: &str) -> bool {
492    let actual_parts: Vec<&str> = actual.split('/').collect();
493    let pattern_parts: Vec<&str> = pattern.split('/').collect();
494
495    if actual_parts.len() != pattern_parts.len() {
496        return false;
497    }
498
499    actual_parts
500        .iter()
501        .zip(pattern_parts.iter())
502        .all(|(a, p)| a == p || (p.starts_with('{') && p.ends_with('}')))
503}
504
505// ── Feature 4: Go/Express endpoint detection from call references ──────────
506
507/// Route-registration call patterns: (target substring, framework).
508const ROUTE_REGISTRATION_PATTERNS: &[(&str, &str)] = &[
509    // Go stdlib
510    ("http.HandleFunc", "go-http"),
511    ("http.Handle", "go-http"),
512    // Go Gin
513    ("router.GET", "gin"),
514    ("router.POST", "gin"),
515    ("router.PUT", "gin"),
516    ("router.DELETE", "gin"),
517    ("router.PATCH", "gin"),
518    // Go Echo
519    ("e.GET", "echo"),
520    ("e.POST", "echo"),
521    ("e.PUT", "echo"),
522    ("e.DELETE", "echo"),
523    ("e.PATCH", "echo"),
524    // Go Chi / gorilla mux
525    ("mux.Handle", "mux"),
526    ("mux.HandleFunc", "mux"),
527    // Express.js
528    ("app.get", "express"),
529    ("app.post", "express"),
530    ("app.put", "express"),
531    ("app.delete", "express"),
532    ("app.patch", "express"),
533    ("router.get", "express"),
534    ("router.post", "express"),
535    ("router.put", "express"),
536    ("router.delete", "express"),
537    ("router.patch", "express"),
538];
539
540/// Detect API endpoints from call references (Go, Express.js, etc.).
541///
542/// These frameworks register routes via function calls rather than decorators,
543/// so they can't be detected from `Symbol.attributes`. Instead we scan
544/// `Reference` entries for known route-registration call targets.
545pub fn detect_endpoints_from_references(
546    references: &[Reference],
547    namespace: &str,
548) -> Vec<DetectedEndpoint> {
549    let mut endpoints = Vec::new();
550
551    for r in references {
552        if r.kind != ReferenceKind::Call {
553            continue;
554        }
555
556        for &(pattern, _framework) in ROUTE_REGISTRATION_PATTERNS {
557            if r.target_name == pattern
558                || r.target_name.ends_with(&format!(
559                    ".{}",
560                    pattern.split('.').next_back().unwrap_or("")
561                ))
562            {
563                let method = extract_method_from_call_target(&r.target_name);
564                // Path is not available from reference data (would need string
565                // literal analysis). Store handler-based identifier so the
566                // endpoint is at least registered for cross-service matching.
567                let handler_path = format!("handler:{}", r.source_qualified_name);
568                endpoints.push(DetectedEndpoint {
569                    id: format!(
570                        "ep:{namespace}:{}:{handler_path}",
571                        method.as_deref().unwrap_or("ANY")
572                    ),
573                    method,
574                    path: handler_path,
575                    handler: r.source_qualified_name.clone(),
576                    file_path: r.file_path.clone(),
577                    line: r.line,
578                });
579                break;
580            }
581        }
582    }
583
584    endpoints
585}
586
587/// Extract HTTP method from a route-registration call target.
588fn extract_method_from_call_target(target: &str) -> Option<String> {
589    let last_segment = target.rsplit('.').next().unwrap_or(target);
590    match last_segment {
591        "GET" | "get" => Some("GET".to_string()),
592        "POST" | "post" => Some("POST".to_string()),
593        "PUT" | "put" => Some("PUT".to_string()),
594        "DELETE" | "delete" => Some("DELETE".to_string()),
595        "PATCH" | "patch" => Some("PATCH".to_string()),
596        // HandleFunc, Handle — method not determinable from call target
597        _ => None,
598    }
599}
600
601// ── Feature 3: Event framework pattern detection ──────────────────────────
602
603/// Event framework call patterns: (target pattern, protocol, direction).
604const EVENT_PATTERNS: &[(&str, &str, &str)] = &[
605    // Kafka
606    ("producer.send", "kafka", "publish"),
607    ("producer.produce", "kafka", "publish"),
608    ("consumer.subscribe", "kafka", "subscribe"),
609    ("consumer.poll", "kafka", "subscribe"),
610    // RabbitMQ
611    ("channel.basic_publish", "rabbitmq", "publish"),
612    ("channel.basic_consume", "rabbitmq", "subscribe"),
613    ("channel.publish", "rabbitmq", "publish"),
614    ("channel.consume", "rabbitmq", "subscribe"),
615    // Redis pub/sub
616    ("redis.publish", "redis", "publish"),
617    ("redis.subscribe", "redis", "subscribe"),
618    ("client.publish", "redis", "publish"),
619    ("client.subscribe", "redis", "subscribe"),
620    // AWS SQS
621    ("sqs.send_message", "sqs", "publish"),
622    ("sqs.receive_message", "sqs", "subscribe"),
623    ("sqs.sendMessage", "sqs", "publish"),
624    ("sqs.receiveMessage", "sqs", "subscribe"),
625    // AWS SNS
626    ("sns.publish", "sns", "publish"),
627    // NATS
628    ("nc.publish", "nats", "publish"),
629    ("nc.subscribe", "nats", "subscribe"),
630    ("nats.publish", "nats", "publish"),
631    ("nats.subscribe", "nats", "subscribe"),
632    // EventEmitter / generic
633    ("emitter.emit", "event", "publish"),
634    ("emitter.on", "event", "subscribe"),
635];
636
637/// Java/Kotlin annotation patterns that indicate event listeners.
638const EVENT_ANNOTATION_PATTERNS: &[(&str, &str, &str)] = &[
639    ("KafkaListener", "kafka", "subscribe"),
640    ("RabbitListener", "rabbitmq", "subscribe"),
641    ("SqsListener", "sqs", "subscribe"),
642    ("JmsListener", "jms", "subscribe"),
643    ("EventListener", "event", "subscribe"),
644];
645
646/// Detect event channel interactions from call references and symbol annotations.
647pub fn detect_event_calls(references: &[Reference], symbols: &[Symbol]) -> Vec<DetectedEventCall> {
648    let mut events = Vec::new();
649
650    // Scan call references for event framework patterns
651    for r in references {
652        if r.kind != ReferenceKind::Call {
653            continue;
654        }
655
656        for &(pattern, protocol, direction) in EVENT_PATTERNS {
657            if r.target_name == pattern || r.target_name.ends_with(pattern) {
658                events.push(DetectedEventCall {
659                    caller: r.source_qualified_name.clone(),
660                    channel: None, // Would need string literal analysis
661                    direction: direction.to_string(),
662                    protocol: protocol.to_string(),
663                    file_path: r.file_path.clone(),
664                    line: r.line,
665                });
666                break;
667            }
668        }
669    }
670
671    // Scan symbol annotations for event listener patterns (Java/Kotlin style)
672    for sym in symbols {
673        for attr in &sym.attributes {
674            for &(annotation, protocol, direction) in EVENT_ANNOTATION_PATTERNS {
675                if attr.contains(annotation) {
676                    events.push(DetectedEventCall {
677                        caller: sym.qualified_name.clone(),
678                        channel: extract_path_from_decorator(attr),
679                        direction: direction.to_string(),
680                        protocol: protocol.to_string(),
681                        file_path: sym.file_path.clone(),
682                        line: sym.line_start,
683                    });
684                    break;
685                }
686            }
687        }
688    }
689
690    events
691}
692
693// ── Feature 5: Cross-service edge matching ────────────────────────────────
694
695/// Match event producers to consumers on the same channel and protocol.
696/// Returns pairs of (producer_caller, consumer_caller, channel, protocol, confidence).
697pub fn match_event_channels(
698    producers: &[DetectedEventCall],
699    consumers: &[DetectedEventCall],
700) -> Vec<(String, String, String, String, f64)> {
701    let mut matches = Vec::new();
702
703    for producer in producers {
704        for consumer in consumers {
705            if producer.protocol != consumer.protocol {
706                continue;
707            }
708            // Require at least one side to have a known channel name.
709            // Protocol-only matching (both channels unknown) would create O(n²)
710            // spurious edges between all producers and consumers of the same
711            // protocol within a namespace.
712            match (&producer.channel, &consumer.channel) {
713                (Some(pc), Some(cc)) => {
714                    let confidence = if pc == cc {
715                        0.95
716                    } else if channels_match_pattern(pc, cc) {
717                        0.8
718                    } else {
719                        continue;
720                    };
721                    matches.push((
722                        producer.caller.clone(),
723                        consumer.caller.clone(),
724                        pc.clone(),
725                        producer.protocol.clone(),
726                        confidence,
727                    ));
728                }
729                // One side has a channel, other doesn't — skip (insufficient data)
730                // Both unknown — skip (would be O(n²) noise)
731                _ => continue,
732            }
733        }
734    }
735
736    matches
737}
738
739/// Check if two channel names match, accounting for wildcards.
740/// e.g., "orders.*" matches "orders.created"
741fn channels_match_pattern(a: &str, b: &str) -> bool {
742    if a.contains('*') || a.contains('#') {
743        let prefix = a.trim_end_matches(['*', '#', '.']);
744        b.starts_with(prefix)
745    } else if b.contains('*') || b.contains('#') {
746        let prefix = b.trim_end_matches(['*', '#', '.']);
747        a.starts_with(prefix)
748    } else {
749        false
750    }
751}
752
753#[cfg(test)]
754#[path = "tests/api_surface_tests.rs"]
755mod tests;