Skip to main content

cargo_coupling/design/
source.rs

1//! Source-level evidence. Syntax is observed; business meaning remains inferred.
2
3use 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    /// None denotes the analyzed working tree; deleted items retain their Git revision.
20    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
50/// Deterministic content identity, not a cryptographic integrity signature.
51pub 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, &parameters),
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}