cargo_perspective/
analysis.rs1use crate::types::{CallSite, ProjectAnalysis};
4use anyhow::{Context, Result};
5use cargo_metadata::{MetadataCommand, PackageId};
6use std::collections::HashSet;
7use std::path::Path;
8use syn::visit::{self, Visit};
9use walkdir::WalkDir;
10
11pub struct DependencyAnalyzer {
13 metadata: cargo_metadata::Metadata,
15 direct_deps: HashSet<String>,
17 all_deps: HashSet<String>,
19}
20
21impl DependencyAnalyzer {
22 pub fn new() -> Result<Self> {
24 let metadata = MetadataCommand::new()
25 .exec()
26 .context("Failed to execute cargo metadata")?;
27
28 let resolve = metadata
29 .resolve
30 .as_ref()
31 .context("No resolve information in metadata")?;
32
33 let root_id = resolve
34 .root
35 .as_ref()
36 .context("No root package in resolve")?;
37
38 let direct_deps = Self::get_direct_dependency_names(&metadata, root_id)?;
40
41 let all_deps = Self::get_all_dependency_names(&metadata, root_id)?;
43
44 Ok(Self {
45 metadata,
46 direct_deps,
47 all_deps,
48 })
49 }
50
51 pub fn analyze(&self) -> Result<ProjectAnalysis> {
53 let resolve = self
54 .metadata
55 .resolve
56 .as_ref()
57 .context("No resolve information")?;
58
59 let root_id = resolve.root.as_ref().context("No root package")?;
60 let root_pkg = self
61 .metadata
62 .packages
63 .iter()
64 .find(|p| &p.id == root_id)
65 .context("Root package not found")?;
66
67 let mut analysis = ProjectAnalysis::new(root_pkg.name.clone());
68
69 if !Path::new("src").exists() {
71 return Ok(analysis);
72 }
73
74 for entry in WalkDir::new("src")
75 .into_iter()
76 .filter_map(|e| e.ok())
77 .filter(|e| e.path().extension().map_or(false, |ext| ext == "rs"))
78 {
79 let file_path = entry.path();
80 self.analyze_file(file_path, &mut analysis)?;
81 }
82
83 analysis.recalculate_totals();
84 Ok(analysis)
85 }
86
87 fn analyze_file(&self, file_path: &Path, analysis: &mut ProjectAnalysis) -> Result<()> {
89 let content = std::fs::read_to_string(file_path)
90 .with_context(|| format!("Failed to read file: {:?}", file_path))?;
91
92 let syntax_tree = syn::parse_file(&content).with_context(|| {
93 format!("Failed to parse Rust file: {:?}", file_path)
94 })?;
95
96 let mut visitor = CallVisitor {
97 file_path: file_path.to_string_lossy().to_string(),
98 direct_deps: &self.direct_deps,
99 all_deps: &self.all_deps,
100 analysis,
101 };
102
103 visitor.visit_file(&syntax_tree);
104 Ok(())
105 }
106
107 fn get_direct_dependency_names(
108 metadata: &cargo_metadata::Metadata,
109 root_id: &PackageId,
110 ) -> Result<HashSet<String>> {
111 let root_pkg = metadata
112 .packages
113 .iter()
114 .find(|p| &p.id == root_id)
115 .context("Root package not found")?;
116
117 Ok(root_pkg
118 .dependencies
119 .iter()
120 .map(|d| d.name.clone())
121 .collect())
122 }
123
124 fn get_all_dependency_names(
125 metadata: &cargo_metadata::Metadata,
126 root_id: &PackageId,
127 ) -> Result<HashSet<String>> {
128 let resolve = metadata
129 .resolve
130 .as_ref()
131 .context("No resolve information")?;
132
133 let all_dep_ids: HashSet<&PackageId> = resolve
134 .nodes
135 .iter()
136 .filter(|n| &n.id != root_id)
137 .map(|n| &n.id)
138 .collect();
139
140 Ok(metadata
141 .packages
142 .iter()
143 .filter(|p| all_dep_ids.contains(&p.id))
144 .map(|p| p.name.clone())
145 .collect())
146 }
147}
148
149struct CallVisitor<'a> {
151 file_path: String,
152 direct_deps: &'a HashSet<String>,
153 all_deps: &'a HashSet<String>,
154 analysis: &'a mut ProjectAnalysis,
155}
156
157impl<'a> CallVisitor<'a> {
158 fn extract_crate_and_method(&self, path: &syn::Path) -> Option<(String, String)> {
160 if path.segments.is_empty() {
161 return None;
162 }
163
164 let first_segment = path.segments.first()?.ident.to_string();
166
167 if !self.all_deps.contains(&first_segment) {
169 return None;
170 }
171
172 let full_path: Vec<String> = path
174 .segments
175 .iter()
176 .map(|seg| seg.ident.to_string())
177 .collect();
178
179 Some((first_segment, full_path.join("::")))
180 }
181
182 fn record_call(&mut self, crate_name: String, method_path: String, line: usize, column: usize) {
183 let is_direct = self.direct_deps.contains(&crate_name);
184 let crate_usage = self.analysis.get_or_create_crate(crate_name, is_direct);
185
186 let call_site = CallSite {
187 file_path: self.file_path.clone(),
188 line,
189 column,
190 };
191
192 crate_usage.add_call(method_path, call_site);
193 }
194}
195
196impl<'a> Visit<'a> for CallVisitor<'a> {
197 fn visit_expr_call(&mut self, node: &'a syn::ExprCall) {
198 if let syn::Expr::Path(expr_path) = &*node.func {
200 if let Some((crate_name, method_path)) = self.extract_crate_and_method(&expr_path.path)
201 {
202 self.record_call(crate_name, method_path, 0, 0);
205 }
206 }
207
208 visit::visit_expr_call(self, node);
210 }
211
212 fn visit_expr_method_call(&mut self, node: &'a syn::ExprMethodCall) {
213 let method_name = node.method.to_string();
216
217 if let syn::Expr::Path(expr_path) = &*node.receiver {
219 if let Some((crate_name, base_path)) = self.extract_crate_and_method(&expr_path.path)
220 {
221 let full_method_path = format!("{}::{}", base_path, method_name);
222 self.record_call(crate_name, full_method_path, 0, 0);
223 }
224 }
225
226 visit::visit_expr_method_call(self, node);
228 }
229
230 fn visit_macro(&mut self, node: &'a syn::Macro) {
231 if let Some((crate_name, method_path)) = self.extract_crate_and_method(&node.path) {
233 self.record_call(crate_name, method_path, 0, 0);
234 }
235
236 visit::visit_macro(self, node);
238 }
239
240 fn visit_use_tree(&mut self, node: &'a syn::UseTree) {
241 visit::visit_use_tree(self, node);
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn test_analyzer_creation() {
252 let result = DependencyAnalyzer::new();
255 assert!(result.is_ok() || result.is_err()); }
257}