1use crate::ProjectMetrics;
4use quote::ToTokens;
5use serde::Serialize;
6use std::{
7 collections::{BTreeMap, BTreeSet},
8 fs,
9 path::Path,
10};
11use syn::{spanned::Spanned, visit::Visit};
12
13#[derive(Debug, Clone, Serialize)]
14pub struct SourceItem {
15 pub module: String,
16 pub name: String,
17 pub kind: String,
18 pub file_path: String,
19 pub revision: Option<String>,
21 pub line: usize,
22 pub end_line: usize,
23 pub public: bool,
24 pub is_test: bool,
25 pub signature_types: Vec<String>,
26 pub calls: Vec<String>,
27 pub fields: Vec<(String, String)>,
28 pub body_fingerprint: Option<String>,
29 pub body_tokens: usize,
30 pub forwards_to: Option<String>,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub struct SourceFinding {
35 pub kind: String,
36 pub origin: String,
37 pub items: Vec<SourceItem>,
38 pub reason: String,
39 pub unknowns: Vec<String>,
40}
41
42#[derive(Debug, Clone, Default, Serialize)]
43pub struct SourceInventory {
44 pub items: Vec<SourceItem>,
45 pub findings: Vec<SourceFinding>,
46 pub notes: Vec<String>,
47 pub fingerprints: BTreeMap<String, String>,
48}
49
50pub fn fingerprint(bytes: &[u8]) -> String {
52 let mut hash = 0xcbf29ce484222325u64;
53 for byte in bytes {
54 hash ^= u64::from(*byte);
55 hash = hash.wrapping_mul(0x100000001b3);
56 }
57 format!("fnv1a64:{hash:016x}")
58}
59
60impl SourceInventory {
61 pub fn from_source(source: &str, path: &Path, module: &str, exclude_tests: bool) -> Self {
62 let mut inventory = Self::default();
63 inventory.scan(source, path, module, exclude_tests);
64 inventory.detect_findings();
65 inventory
66 }
67
68 pub fn read(metrics: &ProjectMetrics, exclude_tests: bool) -> Self {
69 let mut inventory = Self::default();
70 let mut files = BTreeMap::new();
71 let mut modules: Vec<_> = metrics.modules.iter().collect();
72 modules.sort_by_key(|(name, _)| *name);
73 for (name, module) in modules {
74 files.entry(module.path.clone()).or_insert(name.clone());
75 }
76 for (path, module) in files {
77 match fs::read_to_string(&path) {
78 Ok(source) => {
79 inventory
80 .fingerprints
81 .insert(path.display().to_string(), fingerprint(source.as_bytes()));
82 inventory.scan(&source, &path, &module, exclude_tests);
83 }
84 Err(error) => inventory.notes.push(format!("{}: {error}", path.display())),
85 }
86 }
87 inventory
88 .items
89 .sort_by(|a, b| (&a.file_path, a.line, &a.name).cmp(&(&b.file_path, b.line, &b.name)));
90 inventory.detect_findings();
91 inventory
92 }
93
94 fn scan(&mut self, source: &str, path: &Path, module: &str, exclude_tests: bool) {
95 match syn::parse_file(source) {
96 Ok(file) => scan_items(
97 &file.items,
98 module,
99 path,
100 false,
101 exclude_tests,
102 &mut self.items,
103 ),
104 Err(error) => self.notes.push(format!("{}: {error}", path.display())),
105 }
106 }
107
108 fn detect_findings(&mut self) {
109 let mut bodies: BTreeMap<&str, Vec<&SourceItem>> = BTreeMap::new();
110 let mut records: BTreeMap<&Vec<(String, String)>, Vec<&SourceItem>> = BTreeMap::new();
111 for item in &self.items {
112 if !item.is_test
113 && item.body_tokens >= 24
114 && let Some(hash) = &item.body_fingerprint
115 {
116 bodies.entry(hash).or_default().push(item);
117 }
118 if item.fields.len() >= 2 {
119 records.entry(&item.fields).or_default().push(item);
120 }
121 if !item.is_test
122 && let Some(target) = &item.forwards_to
123 {
124 self.findings.push(SourceFinding { kind: "forwarding-layer".into(), origin: "syntax".into(), items: vec![item.clone()], reason: format!("{} only forwards to {target}", item.name), unknowns: vec!["A stable name, visibility boundary, instrumentation point, or compatibility layer may justify forwarding.".into()] });
125 }
126 }
127 for items in bodies.values().filter(|items| {
128 items
129 .iter()
130 .map(|i| &i.module)
131 .collect::<BTreeSet<_>>()
132 .len()
133 > 1
134 }) {
135 self.findings.push(SourceFinding { kind: "identical-logic".into(), origin: "syntax".into(), items: items.iter().map(|i| (*i).clone()).collect(), reason: "Identical normalized Rust bodies occur in different modules.".into(), unknowns: vec!["Identical code does not establish a shared business rule; confirm whether requirements must change together.".into()] });
136 }
137 for items in records
138 .values()
139 .filter(|items| items.len() > 1 && items.iter().any(|i| i.public))
140 {
141 self.findings.push(SourceFinding { kind: "mirrored-record".into(), origin: "syntax".into(), items: items.iter().map(|i| (*i).clone()).collect(), reason: "Records expose the same named field types; the extra boundary may hide no model knowledge.".into(), unknowns: vec!["Separate ownership, validation, serialization or independent evolution can justify matching shapes.".into()] });
142 }
143 for item in self.items.iter().filter(|i| i.public && !i.is_test) {
144 let leaked: Vec<_> = item
145 .signature_types
146 .iter()
147 .filter(|ty| {
148 ty.contains("::internal::")
149 || self.items.iter().any(|definition| {
150 !definition.public
151 && definition.kind == "struct"
152 && type_mentions(ty, &definition.name)
153 })
154 })
155 .collect();
156 if !leaked.is_empty() {
157 self.findings.push(SourceFinding { kind: "implementation-type-exposure".into(), origin: "inferred".into(), items: vec![item.clone()], reason: format!("Public signature exposes implementation-shaped types: {}", leaked.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")), unknowns: vec!["Name matching is not Rust type checking; re-exports and opaque aliases need review.".into()] });
158 }
159 }
160 }
161}
162
163pub fn type_mentions(ty: &str, name: &str) -> bool {
164 ty.split(|c: char| !(c.is_alphanumeric() || c == '_'))
165 .any(|part| part == name)
166}
167
168fn test_attributes(attrs: &[syn::Attribute]) -> bool {
169 attrs.iter().any(|attr| {
170 attr.path()
171 .segments
172 .last()
173 .is_some_and(|segment| segment.ident == "test")
174 || attr.path().is_ident("cfg")
175 && attr
176 .parse_args::<syn::Path>()
177 .is_ok_and(|path| path.is_ident("test"))
178 })
179}
180
181fn scan_items(
182 items: &[syn::Item],
183 module: &str,
184 path: &Path,
185 inherited_test: bool,
186 exclude_tests: bool,
187 output: &mut Vec<SourceItem>,
188) {
189 let start = output.len();
190 for item in items {
191 match item {
192 syn::Item::Mod(value) => {
193 let is_test =
194 inherited_test || value.ident == "tests" || test_attributes(&value.attrs);
195 if !(exclude_tests && is_test)
196 && let Some((_, children)) = &value.content
197 {
198 scan_items(
199 children,
200 &format!("{module}::{}", value.ident),
201 path,
202 is_test,
203 exclude_tests,
204 output,
205 );
206 }
207 }
208 syn::Item::Fn(value) => {
209 let is_test = inherited_test || test_attributes(&value.attrs);
210 if !(exclude_tests && is_test) {
211 output.push(function(
212 &value.sig,
213 Some(&value.block),
214 &value.vis,
215 module,
216 path,
217 is_test,
218 None,
219 ));
220 }
221 }
222 syn::Item::Impl(value) => {
223 if exclude_tests && (inherited_test || test_attributes(&value.attrs)) {
224 continue;
225 }
226 let parent = value.self_ty.to_token_stream().to_string();
227 for child in &value.items {
228 if let syn::ImplItem::Fn(method) = child {
229 output.push(function(
230 &method.sig,
231 Some(&method.block),
232 &method.vis,
233 module,
234 path,
235 inherited_test,
236 Some(&parent),
237 ));
238 }
239 }
240 }
241 syn::Item::Trait(value) => {
242 if exclude_tests && (inherited_test || test_attributes(&value.attrs)) {
243 continue;
244 }
245 for child in &value.items {
246 if let syn::TraitItem::Fn(method) = child {
247 output.push(function(
248 &method.sig,
249 method.default.as_ref(),
250 &value.vis,
251 module,
252 path,
253 inherited_test,
254 Some(&value.ident.to_string()),
255 ));
256 }
257 }
258 }
259 syn::Item::Struct(value) => {
260 if exclude_tests && (inherited_test || test_attributes(&value.attrs)) {
261 continue;
262 }
263 let fields: Vec<_> = value
264 .fields
265 .iter()
266 .enumerate()
267 .map(|(index, field)| {
268 (
269 field
270 .ident
271 .as_ref()
272 .map(ToString::to_string)
273 .unwrap_or_else(|| index.to_string()),
274 field.ty.to_token_stream().to_string(),
275 )
276 })
277 .collect();
278 output.push(SourceItem {
279 module: module.into(),
280 name: value.ident.to_string(),
281 kind: "struct".into(),
282 file_path: path.display().to_string(),
283 revision: None,
284 line: value.span().start().line,
285 end_line: value.span().end().line,
286 public: matches!(value.vis, syn::Visibility::Public(_)),
287 is_test: inherited_test,
288 signature_types: fields.iter().map(|(_, ty)| ty.clone()).collect(),
289 fields,
290 calls: vec![],
291 body_fingerprint: None,
292 body_tokens: 0,
293 forwards_to: None,
294 });
295 }
296 _ => {}
297 }
298 }
299 let mut imports = BTreeMap::new();
300 for item in items {
301 if let syn::Item::Use(import) = item {
302 collect_imports(&import.tree, "", &mut imports);
303 }
304 }
305 for item in output[start..]
306 .iter_mut()
307 .filter(|item| item.module == module)
308 {
309 for ty in &mut item.signature_types {
310 *ty = ty
311 .split_whitespace()
312 .map(|word| imports.get(word).map(String::as_str).unwrap_or(word))
313 .collect::<Vec<_>>()
314 .join(" ");
315 }
316 }
317}
318
319fn collect_imports(tree: &syn::UseTree, prefix: &str, imports: &mut BTreeMap<String, String>) {
320 match tree {
321 syn::UseTree::Path(path) => {
322 collect_imports(&path.tree, &format!("{prefix}{}::", path.ident), imports)
323 }
324 syn::UseTree::Name(name) => {
325 imports.insert(name.ident.to_string(), format!("{prefix}{}", name.ident));
326 }
327 syn::UseTree::Rename(name) => {
328 imports.insert(name.rename.to_string(), format!("{prefix}{}", name.ident));
329 }
330 syn::UseTree::Group(group) => {
331 for item in &group.items {
332 collect_imports(item, prefix, imports);
333 }
334 }
335 syn::UseTree::Glob(_) => {}
336 }
337}
338
339fn function(
340 sig: &syn::Signature,
341 body: Option<&syn::Block>,
342 visibility: &syn::Visibility,
343 module: &str,
344 path: &Path,
345 is_test: bool,
346 parent: Option<&str>,
347) -> SourceItem {
348 let name = parent
349 .map(|p| format!("{p}::{}", sig.ident))
350 .unwrap_or_else(|| sig.ident.to_string());
351 let signature_types = sig
352 .inputs
353 .iter()
354 .filter_map(|arg| {
355 if let syn::FnArg::Typed(value) = arg {
356 Some(value.ty.to_token_stream().to_string())
357 } else {
358 None
359 }
360 })
361 .chain(match &sig.output {
362 syn::ReturnType::Default => None,
363 syn::ReturnType::Type(_, ty) => Some(ty.to_token_stream().to_string()),
364 })
365 .collect();
366 let text = body.map(|b| b.to_token_stream().to_string());
367 let mut calls = Calls::default();
368 if let Some(body) = body {
369 calls.visit_block(body);
370 }
371 let parameters: BTreeSet<_> = sig
372 .inputs
373 .iter()
374 .filter_map(|arg| match arg {
375 syn::FnArg::Typed(arg) => {
376 if let syn::Pat::Ident(pattern) = &*arg.pat {
377 Some(pattern.ident.to_string())
378 } else {
379 None
380 }
381 }
382 syn::FnArg::Receiver(_) => Some("self".into()),
383 })
384 .collect();
385 let forwards_to = body.and_then(|block| match block.stmts.as_slice() {
386 [syn::Stmt::Expr(expression, _)] => forwarded_call(expression, ¶meters),
387 _ => None,
388 });
389 SourceItem {
390 module: module.into(),
391 name,
392 kind: if parent.is_some() {
393 "method"
394 } else {
395 "function"
396 }
397 .into(),
398 file_path: path.display().to_string(),
399 revision: None,
400 line: sig.span().start().line,
401 end_line: body
402 .map(|b| b.span().end().line)
403 .unwrap_or_else(|| sig.span().end().line),
404 public: matches!(visibility, syn::Visibility::Public(_)),
405 is_test,
406 signature_types,
407 calls: calls.names.into_iter().collect(),
408 fields: vec![],
409 body_fingerprint: text.as_ref().map(|text| fingerprint(text.as_bytes())),
410 body_tokens: text
411 .as_deref()
412 .map(|text| text.split_whitespace().count())
413 .unwrap_or(0),
414 forwards_to,
415 }
416}
417
418fn forwarded_call(expression: &syn::Expr, parameters: &BTreeSet<String>) -> Option<String> {
419 match expression {
420 syn::Expr::Call(call)
421 if call
422 .args
423 .iter()
424 .all(|arg| forwarded_argument(arg, parameters)) =>
425 {
426 Some(call.func.to_token_stream().to_string())
427 }
428 syn::Expr::MethodCall(call)
429 if forwarded_argument(&call.receiver, parameters)
430 && call
431 .args
432 .iter()
433 .all(|arg| forwarded_argument(arg, parameters)) =>
434 {
435 Some(format!(
436 "{}.{}",
437 call.receiver.to_token_stream(),
438 call.method
439 ))
440 }
441 syn::Expr::Return(value) => value
442 .expr
443 .as_deref()
444 .and_then(|expr| forwarded_call(expr, parameters)),
445 syn::Expr::Try(value) => forwarded_call(&value.expr, parameters),
446 syn::Expr::Await(value) => forwarded_call(&value.base, parameters),
447 _ => None,
448 }
449}
450
451fn forwarded_argument(expression: &syn::Expr, parameters: &BTreeSet<String>) -> bool {
452 match expression {
453 syn::Expr::Path(path) => path
454 .path
455 .get_ident()
456 .is_some_and(|name| parameters.contains(&name.to_string())),
457 syn::Expr::Reference(reference) => forwarded_argument(&reference.expr, parameters),
458 syn::Expr::Field(field) => forwarded_argument(&field.base, parameters),
459 _ => false,
460 }
461}
462
463#[derive(Default)]
464struct Calls {
465 names: BTreeSet<String>,
466}
467impl<'ast> Visit<'ast> for Calls {
468 fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
469 self.names
470 .insert(node.func.to_token_stream().to_string().replace(' ', ""));
471 syn::visit::visit_expr_call(self, node);
472 }
473 fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
474 self.names.insert(node.method.to_string());
475 syn::visit::visit_expr_method_call(self, node);
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 #[test]
483 fn resolves_signature_imports_and_keeps_computation_out_of_forwarding() {
484 let inventory = SourceInventory::from_source(
485 "use external::Client as Http;\npub fn client() -> Http { create() }\nfn work(n:u32) { send(n + 1) }\n#[cfg(feature=\"latest\")] pub fn normal() {}",
486 Path::new("src/lib.rs"),
487 "lib",
488 true,
489 );
490 assert!(
491 inventory.items[0]
492 .signature_types
493 .iter()
494 .any(|ty| ty.contains("external::Client"))
495 );
496 assert!(inventory.items[1].forwards_to.is_none());
497 assert_eq!(inventory.items.len(), 3);
498 }
499 #[test]
500 fn records_real_spans_and_distinguishes_forwarding_from_logic() {
501 let mut inventory = SourceInventory::default();
502 inventory.scan(
503 "\npub fn forward(n:u32)->u32 { other(n) }\nfn actual(n:u32)->u32 { n+1 }",
504 Path::new("src/lib.rs"),
505 "lib",
506 false,
507 );
508 assert_eq!(inventory.items[0].line, 2);
509 assert_eq!(inventory.items[0].forwards_to.as_deref(), Some("other"));
510 assert!(inventory.items[1].forwards_to.is_none());
511 }
512}