1use 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#[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
82fn 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
103fn 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 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
131fn 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
150fn 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 }
181 "impl_item" => {
182 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 out.push(make_parsed_symbol(
187 node,
188 src,
189 qname,
190 SymbolKind::Class,
191 Some(type_name.clone()),
192 ));
193 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 }
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 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 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
266fn 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
276fn 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 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
347const 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 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 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 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 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
465fn 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 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 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
514fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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
801fn 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
880fn 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 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#[derive(Debug, Clone)]
956struct UseBinding {
957 qname_prefix: String,
958}
959
960fn 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
986fn 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" => { }
995 "scoped_use_list" => {
996 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 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 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 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 if let Some(q) = by_simple.get(&name) {
1164 return Some(q.clone());
1165 }
1166 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 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 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 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 let text = node_text(func, src)?.replace("::", ".");
1215 if workspace.contains(&text) {
1217 return Some(text.clone());
1218 }
1219 if !module_prefix.is_empty() {
1221 let candidate = format!("{module_prefix}.{text}");
1222 if workspace.contains(&candidate) {
1223 return Some(candidate);
1224 }
1225 }
1226 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#[cfg(test)]
1242mod tests {
1243 use super::*;
1244
1245 #[test]
1246 fn net_call_near_end_of_body_does_not_panic() {
1247 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 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 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 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 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 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}