Skip to main content

cargo_mate/cmd/
deps.rs

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    // Filter non-external and local modules
110    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        // Cargo.toml so crate root is this dir
293        write(
294            &dir.path().join("Cargo.toml"),
295            "[package]\nname='tmp'\nversion='0.1.0'\nedition='2021'\n",
296        );
297        // Local module 'internal' should be filtered out
298        write(&dir.path().join("src/internal.rs"), "pub fn f(){}\n");
299        // Use statements include std/self/super/alloc which must be filtered, and external crates
300        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        // Should include external roots
314        assert!(used.contains("serde"));
315        assert!(used.contains("tokio"));
316        assert!(used.contains("foo"));
317        // Should not include reserved or local module
318        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        // Declare only serde; tokio should appear as missing if used
358        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        // src/foo.rs
393        write(&dir.path().join("src/foo.rs"), "");
394        // src/bar/mod.rs
395        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