Skip to main content

agentstatedeveloper_rust/
lib.rs

1//! Rust language adapter for AgentStateDeveloper.
2//!
3//! Implements [`LanguageAdapter`](agentstatedeveloper_core::LanguageAdapter)
4//! on top of `tree-sitter-rust`. Parses top-level functions, methods (inside
5//! `impl` blocks), structs, enums, and traits, and runs a substring-based
6//! effect inference pass.
7
8use std::collections::{HashMap, HashSet};
9
10use agentstatedeveloper_core::adapter::{
11    CallEdge, LanguageAdapter, ParsedSymbol, WorkspaceSymbols,
12};
13use agentstatedeveloper_core::cross_service::{
14    DetectedEndpoint, Direction, Transport, http_contract,
15};
16use agentstatedeveloper_core::error::{AsdError, Result};
17use agentstatedeveloper_core::schema::{Effect, EffectCategory, SymbolKind};
18use serde_json::json;
19use tree_sitter::{Node, Parser};
20
21/// Rust language adapter.
22#[derive(Debug, Default, Clone, Copy)]
23pub struct RustAdapter;
24
25impl RustAdapter {
26    pub fn new() -> Self {
27        Self
28    }
29}
30
31impl LanguageAdapter for RustAdapter {
32    fn language(&self) -> &str {
33        "rust"
34    }
35
36    fn file_extensions(&self) -> &'static [&'static str] {
37        &["rs"]
38    }
39
40    fn parse_symbols(&self, file: &str, source: &str) -> Result<Vec<ParsedSymbol>> {
41        let mut parser = Parser::new();
42        parser
43            .set_language(&tree_sitter_rust::LANGUAGE.into())
44            .map_err(|e| AsdError::Parse(format!("failed to set rust language: {e}")))?;
45
46        let src_bytes = source.as_bytes();
47        let tree = parser
48            .parse(src_bytes, None)
49            .ok_or_else(|| AsdError::Parse(format!("failed to parse {file}")))?;
50
51        let module_prefix = module_qname_prefix(file);
52        let root = tree.root_node();
53        let mut out = Vec::new();
54        walk(root, src_bytes, &module_prefix, &[], &mut out);
55        Ok(out)
56    }
57
58    fn infer_effects(&self, _source: &str, symbol: &ParsedSymbol) -> Vec<Effect> {
59        infer_effects_from_body(&symbol.body)
60    }
61
62    fn infer_service_endpoints(
63        &self,
64        file: &str,
65        source: &str,
66        symbols: &[ParsedSymbol],
67    ) -> Vec<DetectedEndpoint> {
68        infer_service_endpoints_in_rust(file, source, symbols)
69    }
70
71    fn extract_call_edges(
72        &self,
73        file: &str,
74        source: &str,
75        symbols: &[ParsedSymbol],
76        workspace: &WorkspaceSymbols,
77    ) -> Vec<CallEdge> {
78        extract_call_edges_impl(file, source, symbols, workspace)
79    }
80}
81
82/// Walk path components and return the tail after the first `src` segment.
83/// Falls back to the full path for crates without that convention.
84///
85/// Examples:
86/// - `src/engine.rs`            → `engine.rs`
87/// - `crates/foo/src/lib.rs`    → `lib.rs`
88/// - `main.rs`                  → `main.rs`  (no `src` segment, unchanged)
89fn strip_src_prefix(path: &str) -> &str {
90    let mut offset = 0usize;
91    for part in path.split('/') {
92        if part == "src" {
93            let after = offset + part.len() + 1;
94            if after < path.len() {
95                return &path[after..];
96            }
97        }
98        offset += part.len() + 1;
99    }
100    path
101}
102
103/// Derive the dotted module prefix for a file path.
104///
105/// Anchors at the `src/` boundary so the prefix is stable regardless of
106/// which directory `asd index` was invoked from.
107///
108/// `src/engine.rs`              -> `engine`
109/// `crates/mylib/src/lib.rs`    -> `mylib.lib`   (crate-name disambiguates)
110/// `crates/mylib/src/main.rs`   -> `mylib.main`
111/// `./foo/bar.rs`               -> `foo.bar`  (no src segment, fallback)
112/// `main.rs`                    -> `main`
113/// `lib.rs`                     -> `lib`
114fn module_qname_prefix(file: &str) -> String {
115    let mut s = file;
116    if let Some(stripped) = s.strip_prefix("./") {
117        s = stripped;
118    }
119    let s = s.strip_suffix(".rs").unwrap_or(s);
120    let after_src = strip_src_prefix(s);
121    // For lib.rs / main.rs, the module name alone is not unique across crates.
122    // Prepend the crate directory name (the segment before `src/`).
123    if after_src == "lib" || after_src == "main" {
124        if let Some(crate_name) = crate_name_from_path(s) {
125            return format!("{}.{}", crate_name.replace('-', "_"), after_src);
126        }
127    }
128    after_src.replace('\\', "/").replace('/', ".")
129}
130
131/// Extract the crate name from a path like `crates/my-crate/src/lib.rs`
132/// by finding the segment immediately before a `src` component.
133fn crate_name_from_path(path: &str) -> Option<&str> {
134    let parts: Vec<&str> = path.split('/').collect();
135    for (i, &part) in parts.iter().enumerate() {
136        if part == "src" && i > 0 {
137            return Some(parts[i - 1]);
138        }
139    }
140    None
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144enum ScopeKind {
145    Impl,
146    Trait,
147    Mod,
148}
149
150/// Walk the tree. We enumerate:
151/// - `function_item`          → Function (or Method when inside an impl/trait scope)
152/// - `impl_item`              → emit the implementing type as Class; recurse into body
153/// - `struct_item`            → Class
154/// - `enum_item`              → Class
155/// - `trait_item`             → Class; recurse to capture default methods
156/// - `mod_item` (inline only) → push module name onto scope; recurse
157fn walk(
158    node: Node<'_>,
159    src: &[u8],
160    module_prefix: &str,
161    scope: &[(String, ScopeKind)],
162    out: &mut Vec<ParsedSymbol>,
163) {
164    match node.kind() {
165        "function_item" => {
166            let name = node_field_text(node, "name", src).unwrap_or_else(|| "<anon>".to_string());
167            let qname = build_qname(module_prefix, scope, &name);
168            let symbol_kind = if scope
169                .last()
170                .map(|s| s.1 == ScopeKind::Impl || s.1 == ScopeKind::Trait)
171                .unwrap_or(false)
172            {
173                SymbolKind::Method
174            } else {
175                SymbolKind::Function
176            };
177            let signature = extract_fn_signature(node, src, &name);
178            out.push(make_parsed_symbol(node, src, qname, symbol_kind, signature));
179            // Don't recurse into function bodies — nested fns are too noisy.
180        }
181        "impl_item" => {
182            // Extract the name of the type being implemented.
183            let type_name = impl_type_name(node, src).unwrap_or_else(|| "<anon>".to_string());
184            let qname = build_qname(module_prefix, scope, &type_name);
185            // Emit the impl type as a Class symbol so the index has an entry for it.
186            out.push(make_parsed_symbol(
187                node,
188                src,
189                qname,
190                SymbolKind::Class,
191                Some(type_name.clone()),
192            ));
193            // Recurse into the impl body with the type on scope.
194            let mut new_scope = scope.to_vec();
195            new_scope.push((type_name, ScopeKind::Impl));
196            if let Some(body) = node.child_by_field_name("body") {
197                let mut cursor = body.walk();
198                for child in body.children(&mut cursor) {
199                    walk(child, src, module_prefix, &new_scope, out);
200                }
201            }
202        }
203        "struct_item" | "enum_item" => {
204            let name = node_field_text(node, "name", src).unwrap_or_else(|| "<anon>".to_string());
205            let qname = build_qname(module_prefix, scope, &name);
206            let signature = Some(format!(
207                "{} {name}",
208                if node.kind() == "struct_item" {
209                    "struct"
210                } else {
211                    "enum"
212                }
213            ));
214            out.push(make_parsed_symbol(
215                node,
216                src,
217                qname,
218                SymbolKind::Class,
219                signature,
220            ));
221            // Don't recurse — struct fields aren't callable symbols.
222        }
223        "trait_item" => {
224            let name = node_field_text(node, "name", src).unwrap_or_else(|| "<anon>".to_string());
225            let qname = build_qname(module_prefix, scope, &name);
226            out.push(make_parsed_symbol(
227                node,
228                src,
229                qname,
230                SymbolKind::Class,
231                Some(format!("trait {name}")),
232            ));
233            // Recurse to capture default method implementations.
234            let mut new_scope = scope.to_vec();
235            new_scope.push((name, ScopeKind::Trait));
236            if let Some(body) = node.child_by_field_name("body") {
237                let mut cursor = body.walk();
238                for child in body.children(&mut cursor) {
239                    walk(child, src, module_prefix, &new_scope, out);
240                }
241            }
242        }
243        "mod_item" => {
244            // Only recurse into inline modules (`mod foo { ... }`); skip
245            // module declarations (`mod foo;`) which have no body.
246            if let Some(body) = node.child_by_field_name("body") {
247                let mod_name =
248                    node_field_text(node, "name", src).unwrap_or_else(|| "<anon>".to_string());
249                let mut new_scope = scope.to_vec();
250                new_scope.push((mod_name, ScopeKind::Mod));
251                let mut cursor = body.walk();
252                for child in body.children(&mut cursor) {
253                    walk(child, src, module_prefix, &new_scope, out);
254                }
255            }
256        }
257        _ => {
258            let mut cursor = node.walk();
259            for child in node.children(&mut cursor) {
260                walk(child, src, module_prefix, scope, out);
261            }
262        }
263    }
264}
265
266/// Extract the implementing type name from an `impl_item` node.
267///
268/// Handles `impl Foo`, `impl Foo<T>`, `impl Trait for Foo`, and generic
269/// `impl<T> Trait for Foo<T>` — we always return the *self type* (after
270/// `for` when present, otherwise the `type` field).
271fn impl_type_name(node: Node<'_>, src: &[u8]) -> Option<String> {
272    let type_node = node.child_by_field_name("type")?;
273    base_type_name(type_node, src)
274}
275
276/// Collapse a potentially generic type node to its base identifier.
277///
278/// `Foo` → `"Foo"`, `Foo<T>` → `"Foo"`, `(dyn Trait)` → raw text.
279fn base_type_name(node: Node<'_>, src: &[u8]) -> Option<String> {
280    match node.kind() {
281        "type_identifier" | "identifier" => node_text(node, src),
282        "generic_type" => {
283            // `generic_type` has a `type` field pointing at the base type.
284            let inner = node.child_by_field_name("type")?;
285            base_type_name(inner, src)
286        }
287        _ => node_text(node, src),
288    }
289}
290
291fn build_qname(module_prefix: &str, scope: &[(String, ScopeKind)], name: &str) -> String {
292    let mut parts = Vec::with_capacity(scope.len() + 2);
293    if !module_prefix.is_empty() {
294        parts.push(module_prefix.to_string());
295    }
296    for (s, _) in scope {
297        parts.push(s.clone());
298    }
299    parts.push(name.to_string());
300    parts.join(".")
301}
302
303fn node_field_text(node: Node<'_>, field: &str, src: &[u8]) -> Option<String> {
304    node.child_by_field_name(field)
305        .and_then(|n| node_text(n, src))
306}
307
308fn node_text(node: Node<'_>, src: &[u8]) -> Option<String> {
309    src.get(node.byte_range())
310        .map(|b| String::from_utf8_lossy(b).into_owned())
311}
312
313fn make_parsed_symbol(
314    node: Node<'_>,
315    src: &[u8],
316    qname: String,
317    kind: SymbolKind,
318    signature: Option<String>,
319) -> ParsedSymbol {
320    let start = node.start_position();
321    let end = node.end_position();
322    let body = node_text(node, src).unwrap_or_default();
323    ParsedSymbol {
324        qname,
325        kind,
326        start_line: (start.row as u32) + 1,
327        start_col: (start.column as u32) + 1,
328        end_line: (end.row as u32) + 1,
329        end_col: (end.column as u32) + 1,
330        body,
331        signature,
332        doc: None,
333    }
334}
335
336fn extract_fn_signature(node: Node<'_>, src: &[u8], name: &str) -> Option<String> {
337    let params = node.child_by_field_name("parameters")?;
338    let params_text = node_text(params, src)?;
339    let ret = node
340        .child_by_field_name("return_type")
341        .and_then(|n| node_text(n, src))
342        .map(|s| format!(" -> {}", s.trim_start_matches("-> ").trim()))
343        .unwrap_or_default();
344    Some(format!("fn {name}{params_text}{ret}"))
345}
346
347// -----------------------------------------------------------------------------
348// Effect inference
349// -----------------------------------------------------------------------------
350
351// -----------------------------------------------------------------------------
352// Cross-service endpoint detection (t-015) — actix + axum + reqwest.
353//
354// Inbound: actix attribute macros `#[get("/x")]`; axum `.route("/x",
355// get(h).post(h2))` (path from the first string arg, methods from the
356// method-router constructors). Outbound: reqwest `reqwest::get("url")` and
357// `client.get("url")` (the generic form requires a full URL with `://` so
358// `map.get("/k")` doesn't match). actix `web::scope` prefixes are deferred.
359// -----------------------------------------------------------------------------
360
361const RS_VERBS: &[&str] = &["get", "post", "put", "patch", "delete", "head"];
362
363fn infer_service_endpoints_in_rust(
364    file: &str,
365    source: &str,
366    symbols: &[ParsedSymbol],
367) -> Vec<DetectedEndpoint> {
368    let mut out = Vec::new();
369    for (idx, raw) in source.lines().enumerate() {
370        let line_no = (idx + 1) as u32;
371        let line = raw.trim();
372
373        // actix attribute macro `#[get("/x")]`.
374        if let Some((method, path)) = actix_route(line) {
375            if let Some(owner) = rs_owner_for_annotation(symbols, line_no) {
376                out.push(rs_in(file, line_no, &http_contract(&method, &path), owner));
377            }
378        }
379        // axum `.route("/x", get(h).post(h2))`.
380        for (method, path) in axum_routes(line) {
381            if let Some(owner) = rs_owner_for_body(symbols, line_no) {
382                out.push(rs_in(file, line_no, &http_contract(&method, &path), owner));
383            }
384        }
385        // reqwest clients.
386        for (method, url) in rust_clients(line) {
387            if let Some(owner) = rs_owner_for_body(symbols, line_no) {
388                out.push(rs_out(file, line_no, &http_contract(&method, &url), owner));
389            }
390        }
391    }
392    out
393}
394
395fn rs_in(file: &str, line: u32, contract: &str, owner: &ParsedSymbol) -> DetectedEndpoint {
396    DetectedEndpoint {
397        transport: Transport::Http,
398        direction: Direction::Inbound,
399        contract: contract.to_string(),
400        owner_qname: owner.qname.clone(),
401        file: file.to_string(),
402        line,
403        confidence: 0.9,
404        note: None,
405    }
406}
407fn rs_out(file: &str, line: u32, contract: &str, owner: &ParsedSymbol) -> DetectedEndpoint {
408    DetectedEndpoint {
409        transport: Transport::Http,
410        direction: Direction::Outbound,
411        contract: contract.to_string(),
412        owner_qname: owner.qname.clone(),
413        file: file.to_string(),
414        line,
415        confidence: 0.85,
416        note: None,
417    }
418}
419
420fn rs_owner_for_body(symbols: &[ParsedSymbol], line: u32) -> Option<&ParsedSymbol> {
421    symbols
422        .iter()
423        .filter(|s| s.start_line <= line && line <= s.end_line)
424        .max_by_key(|s| s.start_line)
425}
426fn rs_owner_for_annotation(symbols: &[ParsedSymbol], line: u32) -> Option<&ParsedSymbol> {
427    if let Some(s) = rs_owner_for_body(symbols, line) {
428        return Some(s);
429    }
430    symbols
431        .iter()
432        .filter(|s| s.start_line > line && s.start_line <= line + 6)
433        .min_by_key(|s| s.start_line)
434}
435
436fn actix_route(line: &str) -> Option<(String, String)> {
437    for verb in RS_VERBS {
438        if let Some(rest) = line.strip_prefix(&format!("#[{verb}(")) {
439            if let Some(path) = first_rust_string(rest.trim_start()) {
440                return Some((verb.to_uppercase(), path));
441            }
442        }
443    }
444    None
445}
446
447fn axum_routes(line: &str) -> Vec<(String, String)> {
448    let mut out = Vec::new();
449    let Some(pos) = line.find(".route(") else {
450        return out;
451    };
452    let args = &line[pos + ".route(".len()..];
453    let Some(path) = first_rust_string(args.trim_start()) else {
454        return out;
455    };
456    // Methods come from method-router constructors after the path: get(h).post(h2).
457    for verb in RS_VERBS {
458        if contains_call_token(args, verb) {
459            out.push((verb.to_uppercase(), path.clone()));
460        }
461    }
462    out
463}
464
465/// Whether `args` contains `<verb>(` at a word boundary (so handler names like
466/// `get_user(` don't match the `get` verb).
467fn contains_call_token(args: &str, verb: &str) -> bool {
468    let needle = format!("{verb}(");
469    let mut from = 0;
470    while let Some(rel) = args[from..].find(&needle) {
471        let at = from + rel;
472        let boundary = at == 0
473            || !args[..at]
474                .chars()
475                .next_back()
476                .map(|c| c.is_alphanumeric() || c == '_')
477                .unwrap_or(false);
478        if boundary {
479            return true;
480        }
481        from = at + needle.len();
482    }
483    false
484}
485
486fn rust_clients(line: &str) -> Vec<(String, String)> {
487    let mut out = Vec::new();
488    for verb in ["get", "post", "put", "patch", "delete"] {
489        // reqwest::get("url") — the path crate is a strong signal.
490        let q = format!("reqwest::{verb}(");
491        if let Some(pos) = line.find(&q) {
492            if let Some(url) = first_rust_string(line[pos + q.len()..].trim_start()) {
493                if rs_looks_like_url(&url) {
494                    out.push((verb.to_uppercase(), url));
495                }
496            }
497        }
498        // client.get("url") — generic; require a full URL to avoid map.get("/k").
499        let m = format!(".{verb}(");
500        if let Some(pos) = line.find(&m) {
501            if let Some(url) = first_rust_string(line[pos + m.len()..].trim_start()) {
502                if url.contains("://") {
503                    out.push((verb.to_uppercase(), url));
504                }
505            }
506        }
507    }
508    out
509}
510fn rs_looks_like_url(s: &str) -> bool {
511    s.starts_with('/') || s.contains("://")
512}
513
514/// First `"…"` or raw `r"…"` string literal.
515fn first_rust_string(s: &str) -> Option<String> {
516    let s = s.trim_start();
517    let s = if s.starts_with("r\"") { &s[1..] } else { s };
518    if !s.starts_with('"') {
519        return None;
520    }
521    let inner = &s[1..];
522    let end = inner.find('"')?;
523    Some(inner[..end].to_string())
524}
525
526fn infer_effects_from_body(body: &str) -> Vec<Effect> {
527    let mut effects: Vec<Effect> = Vec::new();
528
529    // File system — std::fs, tokio::fs, std::io, File::open / File::create
530    let fs_read_needles = [
531        "fs::read(",
532        "fs::read_to_string(",
533        "fs::read_dir(",
534        "File::open(",
535        "OpenOptions::new(",
536        "BufReader::new(",
537    ];
538    let fs_write_needles = [
539        "fs::write(",
540        "fs::create_dir",
541        "fs::remove_file(",
542        "fs::rename(",
543        "File::create(",
544        "BufWriter::new(",
545        ".write_all(",
546        ".write_bytes(",
547    ];
548    if let Some(note) = first_match_note(body, &fs_read_needles) {
549        effects.push(Effect {
550            effect: EffectCategory::IoFsRead,
551            qualifiers: serde_json::Value::Null,
552            note: Some(note),
553            ..Default::default()
554        });
555    }
556    if let Some(note) = first_match_note(body, &fs_write_needles) {
557        effects.push(Effect {
558            effect: EffectCategory::IoFsWrite,
559            qualifiers: serde_json::Value::Null,
560            note: Some(note),
561            ..Default::default()
562        });
563    }
564
565    // Network — reqwest, hyper, ureq, surf, isahc, tonic (gRPC)
566    let net_needles = [
567        "reqwest::",
568        "hyper::",
569        "ureq::",
570        "surf::",
571        "isahc::",
572        "tonic::",
573        "TcpStream::connect(",
574        "UdpSocket::bind(",
575    ];
576    let mut net_hosts: Vec<String> = Vec::new();
577    let mut net_note: Option<String> = None;
578    for needle in net_needles {
579        if body.contains(needle) {
580            if net_note.is_none() {
581                net_note = first_matching_line(body, &[needle]);
582            }
583            // Try to find URL literals near the call. Clamp the window to the
584            // body length and snap to a char boundary — a call within 200 bytes
585            // of the end (or near a multi-byte char) must not panic the slice.
586            for call_site in find_calls(body, &format!("{needle}")) {
587                let mut end = call_site.saturating_add(200).min(body.len());
588                while end < body.len() && !body.is_char_boundary(end) {
589                    end += 1;
590                }
591                let snippet = &body[call_site..end];
592                if let Some(host) = extract_url_host(snippet) {
593                    if !net_hosts.contains(&host) {
594                        net_hosts.push(host);
595                    }
596                }
597            }
598        }
599    }
600    if net_note.is_some() {
601        let qualifiers = if net_hosts.is_empty() {
602            serde_json::Value::Null
603        } else {
604            json!({ "hosts": net_hosts })
605        };
606        effects.push(Effect {
607            effect: EffectCategory::IoNetOut,
608            qualifiers,
609            note: net_note,
610            ..Default::default()
611        });
612    }
613
614    // Database — sqlx, diesel, rusqlite, sea-orm, tokio-postgres
615    let db_read_needles = [
616        "sqlx::query",
617        ".fetch(",
618        ".fetch_one(",
619        ".fetch_all(",
620        ".fetch_optional(",
621    ];
622    let db_write_needles = [
623        "sqlx::query",
624        ".execute(",
625        "diesel::insert",
626        "diesel::update",
627        "diesel::delete",
628    ];
629    // For DB we do a simpler presence check — SQL direction is hard to infer
630    // from Rust's query builder APIs without evaluating the query string.
631    let db_needles = [
632        "sqlx::",
633        "diesel::",
634        "rusqlite::",
635        "Connection::open(",
636        "tokio_postgres::",
637        "sea_orm::",
638        "rbatis::",
639    ];
640    if let Some(note) = first_match_note(body, &db_needles) {
641        // Emit both read and write conservatively unless we can see clear direction.
642        let has_read = first_match_note(body, &db_read_needles).is_some();
643        let has_write = first_match_note(body, &db_write_needles).is_some();
644        if has_read || (!has_read && !has_write) {
645            effects.push(Effect {
646                effect: EffectCategory::IoDbRead,
647                qualifiers: serde_json::Value::Null,
648                note: Some(note.clone()),
649                ..Default::default()
650            });
651        }
652        if has_write || (!has_read && !has_write) {
653            effects.push(Effect {
654                effect: EffectCategory::IoDbWrite,
655                qualifiers: serde_json::Value::Null,
656                note: Some(note),
657                ..Default::default()
658            });
659        }
660    }
661
662    // Process spawn — std::process::Command, tokio::process::Command
663    let proc_needles = ["Command::new(", "std::process::", "process::exit("];
664    if let Some(note) = first_match_note(body, &proc_needles) {
665        effects.push(Effect {
666            effect: EffectCategory::ProcSpawn,
667            qualifiers: serde_json::Value::Null,
668            note: Some(note),
669            ..Default::default()
670        });
671    }
672
673    // Env read — std::env::var, env::args
674    let env_needles = ["env::var(", "env::var_os(", "env::args(", "std::env::"];
675    if let Some(note) = first_match_note(body, &env_needles) {
676        let mut vars: Vec<String> = Vec::new();
677        for call_site in find_calls(body, "env::var(") {
678            let args = &body[call_site..];
679            if let Some(v) = extract_first_string_literal(args) {
680                if !vars.contains(&v) {
681                    vars.push(v);
682                }
683            }
684        }
685        let qualifiers = if vars.is_empty() {
686            serde_json::Value::Null
687        } else {
688            json!({ "vars": vars })
689        };
690        effects.push(Effect {
691            effect: EffectCategory::EnvRead,
692            qualifiers,
693            note: Some(note),
694            ..Default::default()
695        });
696    }
697
698    // Logging — println!, eprintln!, log::*, tracing::*, slog::*
699    let log_needles = [
700        "println!(",
701        "eprintln!(",
702        "print!(",
703        "eprint!(",
704        "log::debug!",
705        "log::info!",
706        "log::warn!",
707        "log::error!",
708        "tracing::debug!",
709        "tracing::info!",
710        "tracing::warn!",
711        "tracing::error!",
712        "tracing::trace!",
713        "debug!(",
714        "info!(",
715        "warn!(",
716        "error!(",
717        "trace!(",
718    ];
719    if let Some(note) = first_match_note(body, &log_needles) {
720        effects.push(Effect {
721            effect: EffectCategory::Log,
722            qualifiers: serde_json::Value::Null,
723            note: Some(note),
724            ..Default::default()
725        });
726    }
727
728    // Sleep — thread::sleep, tokio::time::sleep, async_std::task::sleep
729    let sleep_needles = [
730        "thread::sleep(",
731        "tokio::time::sleep(",
732        "async_std::task::sleep(",
733        "time::sleep(",
734    ];
735    if let Some(note) = first_match_note(body, &sleep_needles) {
736        effects.push(Effect {
737            effect: EffectCategory::TimeSleep,
738            qualifiers: serde_json::Value::Null,
739            note: Some(note),
740            ..Default::default()
741        });
742    }
743
744    // Time read — std::time::Instant, SystemTime, chrono::Utc
745    let time_read_needles = [
746        "Instant::now()",
747        "SystemTime::now()",
748        "chrono::Utc::now()",
749        "Utc::now()",
750        "Local::now()",
751        "time::OffsetDateTime::now_utc(",
752        "OffsetDateTime::now_utc(",
753    ];
754    if let Some(note) = first_match_note(body, &time_read_needles) {
755        effects.push(Effect {
756            effect: EffectCategory::TimeRead,
757            qualifiers: serde_json::Value::Null,
758            note: Some(note),
759            ..Default::default()
760        });
761    }
762
763    // Random — rand crate
764    let random_needles = [
765        "rand::",
766        "thread_rng()",
767        "rng.gen",
768        "Rng::gen",
769        "random::<",
770        "OsRng",
771        "Uuid::new_v4(",
772        "uuid::Uuid::new_v4(",
773    ];
774    if let Some(note) = first_match_note(body, &random_needles) {
775        effects.push(Effect {
776            effect: EffectCategory::Random,
777            qualifiers: serde_json::Value::Null,
778            note: Some(note),
779            ..Default::default()
780        });
781    }
782
783    // Throw — panic!, explicit unwrap (as a proxy for "may abort on error")
784    if has_panic_or_unwrap(body) {
785        let note = first_matching_line(body, &["panic!(", ".unwrap(", ".expect("]);
786        effects.push(Effect {
787            effect: EffectCategory::Throw,
788            qualifiers: serde_json::Value::Null,
789            note,
790            ..Default::default()
791        });
792    }
793
794    effects
795}
796
797fn has_panic_or_unwrap(body: &str) -> bool {
798    body.contains("panic!(") || body.contains(".unwrap()") || body.contains(".expect(")
799}
800
801// -----------------------------------------------------------------------------
802// Simple call-site helpers (needle-based, no paren matching needed for effects)
803// -----------------------------------------------------------------------------
804
805/// Return the byte offsets of every occurrence of `needle` in `body`.
806fn find_calls(body: &str, needle: &str) -> Vec<usize> {
807    let mut out = Vec::new();
808    let mut from = 0;
809    while let Some(idx) = body[from..].find(needle) {
810        out.push(from + idx + needle.len());
811        from += idx + needle.len();
812    }
813    out
814}
815
816fn extract_first_string_literal(s: &str) -> Option<String> {
817    let trimmed = s.trim_start();
818    let bytes = trimmed.as_bytes();
819    if bytes.is_empty() {
820        return None;
821    }
822    let q = bytes[0];
823    if q != b'"' {
824        return None;
825    }
826    let mut j = 1;
827    let mut out = String::new();
828    while j < bytes.len() {
829        let c = bytes[j];
830        if c == b'\\' && j + 1 < bytes.len() {
831            out.push(bytes[j + 1] as char);
832            j += 2;
833            continue;
834        }
835        if c == q {
836            return Some(out);
837        }
838        out.push(c as char);
839        j += 1;
840    }
841    None
842}
843
844fn extract_url_host(s: &str) -> Option<String> {
845    for scheme in ["https://", "http://"] {
846        if let Some(idx) = s.find(scheme) {
847            let tail = &s[idx + scheme.len()..];
848            let end = tail
849                .find(|c: char| c == '/' || c == '"' || c == '\'' || c == ')' || c.is_whitespace())
850                .unwrap_or(tail.len());
851            let host = &tail[..end];
852            if !host.is_empty() {
853                return Some(host.to_string());
854            }
855        }
856    }
857    None
858}
859
860fn first_matching_line(body: &str, needles: &[&str]) -> Option<String> {
861    for line in body.lines() {
862        for n in needles {
863            if line.contains(n) {
864                return Some(line.trim().to_string());
865            }
866        }
867    }
868    None
869}
870
871fn first_match_note(body: &str, needles: &[&str]) -> Option<String> {
872    for n in needles {
873        if body.contains(n) {
874            return first_matching_line(body, needles);
875        }
876    }
877    None
878}
879
880// -----------------------------------------------------------------------------
881// Call-edge extraction
882// -----------------------------------------------------------------------------
883
884fn extract_call_edges_impl(
885    file: &str,
886    source: &str,
887    symbols: &[ParsedSymbol],
888    workspace: &WorkspaceSymbols,
889) -> Vec<CallEdge> {
890    let module_prefix = module_qname_prefix(file);
891    let known: HashSet<&str> = symbols.iter().map(|s| s.qname.as_str()).collect();
892
893    // simple_name -> qname for intra-module resolution.
894    let mut by_simple: HashMap<String, String> = HashMap::new();
895    for s in symbols {
896        let simple = s.qname.rsplit('.').next().unwrap_or(&s.qname).to_string();
897        let module_level = if module_prefix.is_empty() {
898            s.qname == simple
899        } else {
900            s.qname == format!("{}.{}", module_prefix, simple)
901        };
902        if module_level {
903            by_simple.insert(simple, s.qname.clone());
904        } else {
905            by_simple.entry(simple).or_insert_with(|| s.qname.clone());
906        }
907    }
908
909    let mut parser = Parser::new();
910    if parser
911        .set_language(&tree_sitter_rust::LANGUAGE.into())
912        .is_err()
913    {
914        return Vec::new();
915    }
916
917    let imports = parse_use_declarations(source, &mut parser);
918
919    let mut edges: HashSet<CallEdge> = HashSet::new();
920
921    for sym in symbols {
922        if !matches!(sym.kind, SymbolKind::Function | SymbolKind::Method) {
923            continue;
924        }
925        let src_bytes = sym.body.as_bytes();
926        let tree = match parser.parse(src_bytes, None) {
927            Some(t) => t,
928            None => continue,
929        };
930        let enclosing_type = enclosing_type_qname(&sym.qname, &module_prefix, &known);
931        collect_calls(
932            tree.root_node(),
933            src_bytes,
934            sym,
935            &module_prefix,
936            &by_simple,
937            &known,
938            &imports,
939            workspace,
940            enclosing_type.as_deref(),
941            &mut edges,
942        );
943    }
944
945    let mut out: Vec<CallEdge> = edges.into_iter().collect();
946    out.sort_by(|a, b| {
947        a.caller_qname
948            .cmp(&b.caller_qname)
949            .then_with(|| a.callee_qname.cmp(&b.callee_qname))
950    });
951    out
952}
953
954/// Import binding: local name → qname prefix in ASD dotted form.
955#[derive(Debug, Clone)]
956struct UseBinding {
957    qname_prefix: String,
958}
959
960/// Walk top-level `use_declaration` nodes and collect bindings.
961///
962/// Supports:
963/// - `use foo::bar;`              → `bar` → `foo.bar`
964/// - `use foo::bar as b;`         → `b`   → `foo.bar`
965/// - `use foo::{Bar, baz as z};`  → `Bar` → `foo.Bar`, `z` → `foo.baz`
966/// - `use foo::*;`                → skipped (can't statically enumerate)
967fn parse_use_declarations(source: &str, parser: &mut Parser) -> HashMap<String, UseBinding> {
968    let mut out = HashMap::new();
969    let src_bytes = source.as_bytes();
970    let tree = match parser.parse(src_bytes, None) {
971        Some(t) => t,
972        None => return out,
973    };
974    let root = tree.root_node();
975    let mut cursor = root.walk();
976    for child in root.children(&mut cursor) {
977        if child.kind() == "use_declaration" {
978            if let Some(arg) = child.child_by_field_name("argument") {
979                collect_use_tree(arg, src_bytes, "", &mut out);
980            }
981        }
982    }
983    out
984}
985
986/// Recursively collect from a `use_tree` node.
987fn collect_use_tree(
988    node: Node<'_>,
989    src: &[u8],
990    prefix: &str,
991    out: &mut HashMap<String, UseBinding>,
992) {
993    match node.kind() {
994        "use_wildcard" => { /* skip */ }
995        "scoped_use_list" => {
996            // `foo::{Bar, baz}` — the `path` child is the prefix, the `list` child contains items.
997            let path = node
998                .child_by_field_name("path")
999                .and_then(|n| node_text(n, src))
1000                .map(|s| s.replace("::", "."))
1001                .unwrap_or_default();
1002            let new_prefix = if prefix.is_empty() {
1003                path
1004            } else if path.is_empty() {
1005                prefix.to_string()
1006            } else {
1007                format!("{prefix}.{path}")
1008            };
1009            if let Some(list) = node.child_by_field_name("list") {
1010                let mut cursor = list.walk();
1011                for child in list.children(&mut cursor) {
1012                    collect_use_tree(child, src, &new_prefix, out);
1013                }
1014            }
1015        }
1016        "use_as_clause" => {
1017            // `foo as bar` — bind `bar` to the resolved qname of `foo`.
1018            let name = node
1019                .child_by_field_name("path")
1020                .and_then(|n| node_text(n, src))
1021                .map(|s| s.replace("::", "."))
1022                .unwrap_or_default();
1023            let alias = node
1024                .child_by_field_name("alias")
1025                .and_then(|n| node_text(n, src))
1026                .unwrap_or_default();
1027            if !alias.is_empty() && !name.is_empty() {
1028                let qname = if prefix.is_empty() {
1029                    name
1030                } else {
1031                    format!("{prefix}.{name}")
1032                };
1033                out.insert(
1034                    alias,
1035                    UseBinding {
1036                        qname_prefix: qname,
1037                    },
1038                );
1039            }
1040        }
1041        "scoped_identifier" => {
1042            // `foo::bar` — bind `bar` to `foo.bar`.
1043            let text = node_text(node, src)
1044                .map(|s| s.replace("::", "."))
1045                .unwrap_or_default();
1046            let full = if prefix.is_empty() {
1047                text.clone()
1048            } else {
1049                format!("{prefix}.{text}")
1050            };
1051            let local = text.rsplit('.').next().unwrap_or(&text).to_string();
1052            if !local.is_empty() {
1053                out.insert(local, UseBinding { qname_prefix: full });
1054            }
1055        }
1056        "identifier" => {
1057            let name = node_text(node, src).unwrap_or_default();
1058            if !name.is_empty() && name != "self" && name != "super" && name != "crate" {
1059                let qname = if prefix.is_empty() {
1060                    name.clone()
1061                } else {
1062                    format!("{prefix}.{name}")
1063                };
1064                out.insert(
1065                    name,
1066                    UseBinding {
1067                        qname_prefix: qname,
1068                    },
1069                );
1070            }
1071        }
1072        _ => {
1073            // Recurse into other container nodes (use_list, etc.).
1074            let mut cursor = node.walk();
1075            for child in node.children(&mut cursor) {
1076                collect_use_tree(child, src, prefix, out);
1077            }
1078        }
1079    }
1080}
1081
1082fn enclosing_type_qname(qname: &str, module_prefix: &str, known: &HashSet<&str>) -> Option<String> {
1083    let parts: Vec<&str> = qname.split('.').collect();
1084    if parts.len() < 2 {
1085        return None;
1086    }
1087    for end in (1..parts.len()).rev() {
1088        let candidate = parts[..end].join(".");
1089        if !module_prefix.is_empty() && !candidate.starts_with(module_prefix) {
1090            continue;
1091        }
1092        if known.contains(candidate.as_str()) {
1093            return Some(candidate);
1094        }
1095    }
1096    None
1097}
1098
1099#[allow(clippy::too_many_arguments)]
1100fn collect_calls(
1101    node: Node<'_>,
1102    src: &[u8],
1103    sym: &ParsedSymbol,
1104    module_prefix: &str,
1105    by_simple: &HashMap<String, String>,
1106    known: &HashSet<&str>,
1107    imports: &HashMap<String, UseBinding>,
1108    workspace: &WorkspaceSymbols,
1109    enclosing_type: Option<&str>,
1110    out: &mut HashSet<CallEdge>,
1111) {
1112    if node.kind() == "call_expression" {
1113        if let Some(func) = node.child_by_field_name("function") {
1114            if let Some(callee) = resolve_callee(
1115                func,
1116                src,
1117                module_prefix,
1118                by_simple,
1119                known,
1120                imports,
1121                workspace,
1122                enclosing_type,
1123            ) {
1124                out.insert(CallEdge {
1125                    caller_qname: sym.qname.clone(),
1126                    callee_qname: callee,
1127                });
1128            }
1129        }
1130    }
1131    let mut cursor = node.walk();
1132    for child in node.children(&mut cursor) {
1133        collect_calls(
1134            child,
1135            src,
1136            sym,
1137            module_prefix,
1138            by_simple,
1139            known,
1140            imports,
1141            workspace,
1142            enclosing_type,
1143            out,
1144        );
1145    }
1146}
1147
1148#[allow(clippy::too_many_arguments)]
1149fn resolve_callee(
1150    func: Node<'_>,
1151    src: &[u8],
1152    module_prefix: &str,
1153    by_simple: &HashMap<String, String>,
1154    known: &HashSet<&str>,
1155    imports: &HashMap<String, UseBinding>,
1156    workspace: &WorkspaceSymbols,
1157    enclosing_type: Option<&str>,
1158) -> Option<String> {
1159    match func.kind() {
1160        "identifier" => {
1161            let name = node_text(func, src)?;
1162            // Intra-module first.
1163            if let Some(q) = by_simple.get(&name) {
1164                return Some(q.clone());
1165            }
1166            // Import map.
1167            if let Some(binding) = imports.get(&name) {
1168                if workspace.contains(&binding.qname_prefix) {
1169                    return Some(binding.qname_prefix.clone());
1170                }
1171            }
1172            None
1173        }
1174        "field_expression" => {
1175            // `self.method()` or `obj.method()`
1176            let value = func.child_by_field_name("value")?;
1177            let field = func.child_by_field_name("field")?;
1178            let field_name = node_text(field, src)?;
1179
1180            if value.kind() == "self" {
1181                let class = enclosing_type?;
1182                let candidate = format!("{class}.{field_name}");
1183                if known.contains(candidate.as_str()) {
1184                    return Some(candidate);
1185                }
1186                return None;
1187            }
1188            if value.kind() == "identifier" {
1189                let obj_name = node_text(value, src)?;
1190                // Intra-module type reference: `Foo::bar` -> `mod.Foo.bar`
1191                let type_qname = if module_prefix.is_empty() {
1192                    obj_name.clone()
1193                } else {
1194                    format!("{module_prefix}.{obj_name}")
1195                };
1196                if known.contains(type_qname.as_str()) {
1197                    let candidate = format!("{type_qname}.{field_name}");
1198                    if known.contains(candidate.as_str()) {
1199                        return Some(candidate);
1200                    }
1201                }
1202                // Imported binding.
1203                if let Some(binding) = imports.get(&obj_name) {
1204                    let candidate = format!("{}.{field_name}", binding.qname_prefix);
1205                    if workspace.contains(&candidate) {
1206                        return Some(candidate);
1207                    }
1208                }
1209            }
1210            None
1211        }
1212        "scoped_identifier" => {
1213            // `Type::associated_fn()` or `module::function()`
1214            let text = node_text(func, src)?.replace("::", ".");
1215            // Direct workspace hit.
1216            if workspace.contains(&text) {
1217                return Some(text.clone());
1218            }
1219            // Try prefixing with module.
1220            if !module_prefix.is_empty() {
1221                let candidate = format!("{module_prefix}.{text}");
1222                if workspace.contains(&candidate) {
1223                    return Some(candidate);
1224                }
1225            }
1226            // Local simple name (last segment) in by_simple.
1227            let simple = text.rsplit('.').next()?;
1228            if let Some(q) = by_simple.get(simple) {
1229                return Some(q.clone());
1230            }
1231            None
1232        }
1233        _ => None,
1234    }
1235}
1236
1237// -----------------------------------------------------------------------------
1238// Tests
1239// -----------------------------------------------------------------------------
1240
1241#[cfg(test)]
1242mod tests {
1243    use super::*;
1244
1245    #[test]
1246    fn net_call_near_end_of_body_does_not_panic() {
1247        // Regression: the URL-host window `&body[call_site..call_site+200]`
1248        // was unclamped, so a reqwest call within 200 bytes of the body end
1249        // panicked with an out-of-bounds slice. Found by the cross-language
1250        // conformance matrix. The call sits right at the end here.
1251        let body = "async fn f() {\n    let _ = reqwest::get(\"https://api.svc/x\").await;\n}";
1252        let effs = infer_effects_from_body(body);
1253        let cats: Vec<_> = effs.iter().map(|e| &e.effect).collect();
1254        assert!(
1255            cats.contains(&&EffectCategory::IoNetOut),
1256            "expected IoNetOut, got {cats:?}"
1257        );
1258    }
1259
1260    #[test]
1261    fn module_prefix_strips_rs_and_leading_dot_slash() {
1262        // src/ anchor — stable regardless of index root. Path segments before
1263        // a nested `src/` (e.g. the crate dir) are retained as qname prefix.
1264        assert_eq!(module_qname_prefix("src/engine.rs"), "engine");
1265        assert_eq!(module_qname_prefix("crates/mylib/src/lib.rs"), "mylib.lib");
1266        assert_eq!(
1267            module_qname_prefix("src/payments/charge.rs"),
1268            "payments.charge"
1269        );
1270        // no src segment — full relative path (fallback)
1271        assert_eq!(module_qname_prefix("./foo/bar.rs"), "foo.bar");
1272        assert_eq!(module_qname_prefix("lib.rs"), "lib");
1273        assert_eq!(module_qname_prefix("main.rs"), "main");
1274    }
1275
1276    #[test]
1277    fn strip_src_prefix_variants() {
1278        assert_eq!(strip_src_prefix("src/engine.rs"), "engine.rs");
1279        assert_eq!(strip_src_prefix("crates/foo/src/lib.rs"), "lib.rs");
1280        assert_eq!(strip_src_prefix("main.rs"), "main.rs");
1281        assert_eq!(strip_src_prefix("nosrc/foo.rs"), "nosrc/foo.rs");
1282    }
1283
1284    #[test]
1285    fn parses_function_method_struct_enum() {
1286        let src = r#"
1287pub struct Engine { }
1288
1289impl Engine {
1290    pub fn open() -> Self { Engine {} }
1291    pub fn run(&self) { }
1292}
1293
1294pub fn top_level() { }
1295
1296pub enum Status { Ok, Err }
1297"#;
1298        let a = RustAdapter::new();
1299        let syms = a.parse_symbols("src/engine.rs", src).unwrap();
1300        let qnames: Vec<_> = syms.iter().map(|s| s.qname.clone()).collect();
1301        // src/ is stripped — qnames are anchored to the crate interior
1302        assert!(
1303            qnames.contains(&"engine.Engine".to_string()),
1304            "got {qnames:?}"
1305        );
1306        assert!(
1307            qnames.contains(&"engine.Engine.open".to_string()),
1308            "got {qnames:?}"
1309        );
1310        assert!(
1311            qnames.contains(&"engine.Engine.run".to_string()),
1312            "got {qnames:?}"
1313        );
1314        assert!(
1315            qnames.contains(&"engine.top_level".to_string()),
1316            "got {qnames:?}"
1317        );
1318        assert!(
1319            qnames.contains(&"engine.Status".to_string()),
1320            "got {qnames:?}"
1321        );
1322        let open = syms
1323            .iter()
1324            .find(|s| s.qname == "engine.Engine.open")
1325            .unwrap();
1326        assert_eq!(open.kind, SymbolKind::Method);
1327        let top = syms.iter().find(|s| s.qname == "engine.top_level").unwrap();
1328        assert_eq!(top.kind, SymbolKind::Function);
1329    }
1330
1331    #[test]
1332    fn infers_fs_read_and_write() {
1333        let body = r#"
1334fn f() {
1335    let _ = File::open("/tmp/a").unwrap();
1336    fs::write("/tmp/b", b"hi").unwrap();
1337}
1338"#;
1339        let effects = infer_effects_from_body(body);
1340        let cats: Vec<_> = effects.iter().map(|e| e.effect.clone()).collect();
1341        assert!(cats.contains(&EffectCategory::IoFsRead), "cats: {cats:?}");
1342        assert!(cats.contains(&EffectCategory::IoFsWrite), "cats: {cats:?}");
1343    }
1344
1345    #[test]
1346    fn infers_log_from_tracing() {
1347        let body = r#"fn f() { tracing::info!("hello"); }"#;
1348        let effects = infer_effects_from_body(body);
1349        let cats: Vec<_> = effects.iter().map(|e| e.effect.clone()).collect();
1350        assert!(cats.contains(&EffectCategory::Log), "cats: {cats:?}");
1351    }
1352
1353    #[test]
1354    fn empty_when_no_patterns() {
1355        let body = "fn f(x: i32) -> i32 { x + 1 }";
1356        assert!(infer_effects_from_body(body).is_empty());
1357    }
1358
1359    #[test]
1360    fn parses_trait_with_default_method() {
1361        let src = r#"
1362pub trait Store {
1363    fn save(&self);
1364    fn load(&self) -> String { String::new() }
1365}
1366"#;
1367        let a = RustAdapter::new();
1368        let syms = a.parse_symbols("src/store.rs", src).unwrap();
1369        let qnames: Vec<_> = syms.iter().map(|s| s.qname.clone()).collect();
1370        // src/ is stripped
1371        assert!(
1372            qnames.contains(&"store.Store".to_string()),
1373            "got {qnames:?}"
1374        );
1375        assert!(
1376            qnames.contains(&"store.Store.load".to_string()),
1377            "got {qnames:?}"
1378        );
1379    }
1380}
1381
1382#[cfg(test)]
1383mod service_endpoint_tests {
1384    use super::*;
1385    use agentstatedeveloper_core::adapter::LanguageAdapter;
1386
1387    fn detect(src: &str) -> Vec<DetectedEndpoint> {
1388        let a = RustAdapter;
1389        let s = a.parse_symbols("svc.rs", src).unwrap();
1390        a.infer_service_endpoints("svc.rs", src, &s)
1391    }
1392    fn inb(e: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
1393        e.iter()
1394            .filter(|e| e.direction == Direction::Inbound)
1395            .collect()
1396    }
1397    fn outb(e: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
1398        e.iter()
1399            .filter(|e| e.direction == Direction::Outbound)
1400            .collect()
1401    }
1402
1403    #[test]
1404    fn actix_attribute_macro_route() {
1405        let src = "#[get(\"/users/{id}\")]\nasync fn get_user(id: web::Path<u64>) -> impl Responder {\n    HttpResponse::Ok()\n}\n";
1406        let eps = detect(src);
1407        assert_eq!(inb(&eps)[0].contract, "http:GET /users/{}", "{eps:?}");
1408    }
1409
1410    #[test]
1411    fn axum_route_with_multiple_methods() {
1412        let src = "fn app() -> Router {\n    Router::new().route(\"/users/:id\", get(get_user).post(create_user))\n}\n";
1413        let eps = detect(src);
1414        let mut got: Vec<String> = inb(&eps).iter().map(|e| e.contract.clone()).collect();
1415        got.sort();
1416        assert_eq!(
1417            got,
1418            vec![
1419                "http:GET /users/{}".to_string(),
1420                "http:POST /users/{}".to_string()
1421            ],
1422            "{eps:?}"
1423        );
1424    }
1425
1426    #[test]
1427    fn reqwest_clients_outbound() {
1428        let src = "async fn call() {\n    reqwest::get(\"https://users.svc/users/{id}\").await.unwrap();\n    client.post(\"https://pay.svc/charge\").send().await.unwrap();\n}\n";
1429        let eps = detect(src);
1430        let mut got: Vec<String> = outb(&eps).iter().map(|e| e.contract.clone()).collect();
1431        got.sort();
1432        assert_eq!(
1433            got,
1434            vec![
1435                "http:GET /users/{}".to_string(),
1436                "http:POST /charge".to_string()
1437            ]
1438        );
1439    }
1440
1441    #[test]
1442    fn map_get_with_path_key_is_not_a_client() {
1443        // `.get("/k")` on a map (no `://`) must not be an HTTP client call.
1444        let src = "fn f(m: HashMap<String, u8>) {\n    let _ = m.get(\"/k\");\n}\n";
1445        assert!(outb(&detect(src)).is_empty(), "{:?}", detect(src));
1446    }
1447}