1use anyhow::{anyhow, Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6use walkdir::WalkDir;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct DeclaredDeps {
10 pub normal: BTreeSet<String>,
11 pub dev: BTreeSet<String>,
12 pub build: BTreeSet<String>,
13 pub renamed: BTreeMap<String, String>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct MissingDep {
18 pub name: String,
19 pub crates_io: String,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct DepsReport {
24 pub crate_root: String,
25 pub used_crates: BTreeSet<String>,
26 pub declared_deps: DeclaredDeps,
27 pub missing_deps: Vec<MissingDep>,
28}
29
30pub async fn handle_deps_async(path: Option<PathBuf>, json: bool) -> Result<()> {
31 let start_dir = path.unwrap_or(std::env::current_dir()?);
32 let crate_root = find_crate_root(&start_dir)
33 .with_context(|| format!("No Cargo.toml found starting from {}", start_dir.display()))?;
34 let used_roots = scan_used_crates(&crate_root)?;
35 let declared = parse_declared_dependencies(&crate_root)?;
36 let mut declared_union: BTreeSet<String> = declared
37 .normal
38 .union(&declared.dev)
39 .cloned()
40 .collect();
41 declared_union.extend(declared.build.iter().cloned());
42
43 let client = reqwest::Client::builder()
44 .timeout(std::time::Duration::from_secs(5))
45 .build()
46 .unwrap();
47 let mut missing: Vec<MissingDep> = Vec::new();
48 for name in used_roots.iter() {
49 if !declared_union.contains(name) {
50 let status = check_crates_io_async(name, &client).await;
51 missing.push(MissingDep { name: name.clone(), crates_io: status });
52 }
53 }
54
55 let report = DepsReport {
56 crate_root: crate_root.display().to_string(),
57 used_crates: used_roots,
58 declared_deps: declared,
59 missing_deps: missing,
60 };
61
62 if json {
63 println!("{}", serde_json::to_string_pretty(&report)?);
64 } else {
65 print_human_report(&report);
66 }
67 Ok(())
68}
69
70fn find_crate_root(start: &Path) -> Result<PathBuf> {
71 let mut current = if start.is_file() {
72 start.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."))
73 } else {
74 start.to_path_buf()
75 };
76 loop {
77 let candidate = current.join("Cargo.toml");
78 if candidate.exists() {
79 return Ok(current);
80 }
81 if !current.pop() {
82 break;
83 }
84 }
85 Err(anyhow!("Cargo.toml not found"))
86}
87
88fn scan_used_crates(crate_root: &Path) -> Result<BTreeSet<String>> {
89 let src_dir = crate_root.join("src");
90 let mut roots: BTreeSet<String> = BTreeSet::new();
91 if src_dir.exists() {
92 for entry in WalkDir::new(&src_dir).into_iter().filter_map(|e| e.ok()) {
93 let path = entry.path();
94 if path.is_file() && path.extension().map(|e| e == "rs").unwrap_or(false) {
95 if let Ok(content) = fs::read_to_string(path) {
96 collect_use_roots(&content, &mut roots);
97 collect_extern_crates(&content, &mut roots);
98 }
99 }
100 }
101 }
102 let build_rs = crate_root.join("build.rs");
103 if build_rs.exists() {
104 if let Ok(content) = fs::read_to_string(&build_rs) {
105 collect_use_roots(&content, &mut roots);
106 collect_extern_crates(&content, &mut roots);
107 }
108 }
109 let reserved = ["crate", "self", "super", "std", "core", "alloc"];
111 roots.retain(|name| !reserved.contains(&name.as_str()));
112 roots.retain(|name| !is_local_module(crate_root, name));
113 Ok(roots)
114}
115
116fn collect_use_roots(source: &str, out: &mut BTreeSet<String>) {
117 let file = match syn::parse_file(source) {
118 Ok(f) => f,
119 Err(_) => return,
120 };
121 for item in file.items {
122 if let syn::Item::Use(u) = item {
123 walk_use_tree(&u.tree, None, out);
124 }
125 }
126}
127
128fn collect_extern_crates(source: &str, out: &mut BTreeSet<String>) {
129 let file = match syn::parse_file(source) {
130 Ok(f) => f,
131 Err(_) => return,
132 };
133 for item in file.items {
134 if let syn::Item::ExternCrate(ext) = item {
135 let used = if let Some((_, rename)) = ext.rename {
136 rename.to_string()
137 } else {
138 ext.ident.to_string()
139 };
140 out.insert(used);
141 }
142 }
143}
144
145fn walk_use_tree(tree: &syn::UseTree, current_root: Option<String>, out: &mut BTreeSet<String>) {
146 match tree {
147 syn::UseTree::Path(p) => {
148 let ident = p.ident.to_string();
149 let root = current_root.unwrap_or(ident);
150 walk_use_tree(&*p.tree, Some(root), out);
151 }
152 syn::UseTree::Name(n) => {
153 let root = current_root.unwrap_or(n.ident.to_string());
154 out.insert(root);
155 }
156 syn::UseTree::Rename(r) => {
157 let root = current_root.unwrap_or(r.ident.to_string());
158 out.insert(root);
159 }
160 syn::UseTree::Glob(_) => {
161 if let Some(root) = current_root {
162 out.insert(root);
163 }
164 }
165 syn::UseTree::Group(g) => {
166 for item in &g.items {
167 walk_use_tree(item, current_root.clone(), out);
168 }
169 }
170 }
171}
172
173fn is_local_module(crate_root: &Path, name: &str) -> bool {
174 let src = crate_root.join("src");
175 let file_rs = src.join(format!("{}.rs", name));
176 let mod_rs = src.join(name).join("mod.rs");
177 file_rs.exists() || mod_rs.exists()
178}
179
180fn parse_declared_dependencies(crate_root: &Path) -> Result<DeclaredDeps> {
181 let cargo_toml_path = crate_root.join("Cargo.toml");
182 let content = fs::read_to_string(&cargo_toml_path)
183 .with_context(|| format!("Failed to read {}", cargo_toml_path.display()))?;
184 let value: toml::Value = toml::from_str(&content)
185 .with_context(|| format!("Failed to parse {}", cargo_toml_path.display()))?;
186 let mut declared = DeclaredDeps {
187 normal: BTreeSet::new(),
188 dev: BTreeSet::new(),
189 build: BTreeSet::new(),
190 renamed: BTreeMap::new(),
191 };
192 for (kind, set) in [
193 ("dependencies", &mut declared.normal),
194 ("dev-dependencies", &mut declared.dev),
195 ("build-dependencies", &mut declared.build),
196 ] {
197 if let Some(table) = value.get(kind).and_then(|v| v.as_table()) {
198 for (dep_key, spec) in table {
199 set.insert(dep_key.clone());
200 if let Some(t) = spec.as_table() {
201 if let Some(pkg) = t.get("package").and_then(|v| v.as_str()) {
202 declared.renamed.insert(dep_key.clone(), pkg.to_string());
203 }
204 }
205 }
206 }
207 }
208 Ok(declared)
209}
210
211async fn check_crates_io_async(name: &str, client: &reqwest::Client) -> String {
212 let url = format!("https://crates.io/api/v1/crates/{}", name);
213 match client.get(&url).send().await {
214 Ok(resp) => match resp.status().as_u16() {
215 200 => "exists".to_string(),
216 404 => "missing".to_string(),
217 _ => "error".to_string(),
218 },
219 Err(_) => "error".to_string(),
220 }
221}
222
223fn print_human_report(report: &DepsReport) {
224 println!("๐ฆ Crate root: {}", report.crate_root);
225 println!("");
226 println!("๐ Detected external crates (from use statements):");
227 if report.used_crates.is_empty() {
228 println!(" (none)");
229 } else {
230 for name in &report.used_crates {
231 println!(" - {}", name);
232 }
233 }
234 println!("");
235 println!("๐งพ Declared dependencies:");
236 if !report.declared_deps.normal.is_empty() {
237 println!(" [dependencies]:");
238 for k in &report.declared_deps.normal {
239 if let Some(pkg) = report.declared_deps.renamed.get(k) {
240 println!(" - {} (package = {})", k, pkg);
241 } else {
242 println!(" - {}", k);
243 }
244 }
245 }
246 if !report.declared_deps.dev.is_empty() {
247 println!(" [dev-dependencies]:");
248 for k in &report.declared_deps.dev {
249 if let Some(pkg) = report.declared_deps.renamed.get(k) {
250 println!(" - {} (package = {})", k, pkg);
251 } else {
252 println!(" - {}", k);
253 }
254 }
255 }
256 if !report.declared_deps.build.is_empty() {
257 println!(" [build-dependencies]:");
258 for k in &report.declared_deps.build {
259 if let Some(pkg) = report.declared_deps.renamed.get(k) {
260 println!(" - {} (package = {})", k, pkg);
261 } else {
262 println!(" - {}", k);
263 }
264 }
265 }
266 println!("");
267 println!("โ Missing dependencies (not declared in Cargo.toml):");
268 if report.missing_deps.is_empty() {
269 println!(" (none)");
270 } else {
271 for m in &report.missing_deps {
272 println!(" - {} [{}]", m.name, m.crates_io);
273 }
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use std::io::Write;
281 use tempfile::tempdir;
282
283 fn write(path: &Path, content: &str) {
284 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
285 let mut f = std::fs::File::create(path).unwrap();
286 f.write_all(content.as_bytes()).unwrap();
287 }
288
289 #[test]
290 fn scan_used_crates_detects_external_and_filters_reserved_and_local() {
291 let dir = tempdir().unwrap();
292 write(
294 &dir.path().join("Cargo.toml"),
295 "[package]\nname='tmp'\nversion='0.1.0'\nedition='2021'\n",
296 );
297 write(&dir.path().join("src/internal.rs"), "pub fn f(){}\n");
299 let lib_rs = r#"
301 use std::fs;
302 use self::something;
303 use super::other;
304 use alloc::vec::Vec;
305 use serde::Serialize;
306 use tokio::io;
307 use foo::{bar, baz};
308 use crate::internal;
309 "#;
310 write(&dir.path().join("src/lib.rs"), lib_rs);
311
312 let used = scan_used_crates(dir.path()).unwrap();
313 assert!(used.contains("serde"));
315 assert!(used.contains("tokio"));
316 assert!(used.contains("foo"));
317 assert!(!used.contains("std"));
319 assert!(!used.contains("alloc"));
320 assert!(!used.contains("internal"));
321 }
322
323 #[test]
324 fn parse_declared_dependencies_collects_sets_and_renames() {
325 let dir = tempdir().unwrap();
326 let cargo = r#"
327 [package]
328 name = "tmp"
329 version = "0.1.0"
330 edition = "2021"
331
332 [dependencies]
333 serde = "1"
334 tokio = { version = "1" }
335 foo = { package = "bar", version = "1" }
336
337 [dev-dependencies]
338 serde_json = "1"
339
340 [build-dependencies]
341 cc = "*"
342 "#;
343 write(&dir.path().join("Cargo.toml"), cargo);
344 let declared = parse_declared_dependencies(dir.path()).unwrap();
345
346 assert!(declared.normal.contains("serde"));
347 assert!(declared.normal.contains("tokio"));
348 assert!(declared.normal.contains("foo"));
349 assert_eq!(declared.renamed.get("foo").map(|s| s.as_str()), Some("bar"));
350 assert!(declared.dev.contains("serde_json"));
351 assert!(declared.build.contains("cc"));
352 }
353
354 #[test]
355 fn missing_deps_computation_works_without_network() {
356 let dir = tempdir().unwrap();
357 let cargo = r#"
359 [package]
360 name = "tmp"
361 version = "0.1.0"
362 edition = "2021"
363
364 [dependencies]
365 serde = "1"
366 "#;
367 write(&dir.path().join("Cargo.toml"), cargo);
368 let src = r#"
369 use serde::Serialize;
370 use tokio::io;
371 "#;
372 write(&dir.path().join("src/lib.rs"), src);
373
374 let used = scan_used_crates(dir.path()).unwrap();
375 let declared = parse_declared_dependencies(dir.path()).unwrap();
376 let mut declared_union: BTreeSet<String> = declared
377 .normal
378 .union(&declared.dev)
379 .cloned()
380 .collect();
381 declared_union.extend(declared.build.iter().cloned());
382 let missing: BTreeSet<String> = used.difference(&declared_union).cloned().collect();
383
384 assert!(missing.contains("tokio"));
385 assert!(!missing.contains("serde"));
386 }
387
388 #[test]
389 fn is_local_module_detects_src_layouts() {
390 let dir = tempdir().unwrap();
391 write(&dir.path().join("Cargo.toml"), "[package]\nname='x'\nversion='0.1.0'\n");
392 write(&dir.path().join("src/foo.rs"), "");
394 write(&dir.path().join("src/bar/mod.rs"), "");
396 assert!(is_local_module(dir.path(), "foo"));
397 assert!(is_local_module(dir.path(), "bar"));
398 assert!(!is_local_module(dir.path(), "baz"));
399 }
400}
401
402