Skip to main content

cargo_dep_budget/
lib.rs

1use serde::Deserialize;
2use std::collections::{HashMap, HashSet};
3use std::path::Path;
4
5/// Budget configuration with defaults
6#[derive(Debug, Clone)]
7pub struct BudgetConfig {
8    pub max_direct: usize,
9    pub max_total: usize,
10    pub max_build: usize,
11}
12
13impl Default for BudgetConfig {
14    fn default() -> Self {
15        Self {
16            max_direct: 30,
17            max_total: 200,
18            max_build: 15,
19        }
20    }
21}
22
23/// Dependency counts parsed from a project
24#[derive(Debug, Clone, Default)]
25pub struct DepCounts {
26    pub direct: usize,
27    pub build: usize,
28    pub dev: usize,
29    pub total_transitive: usize,
30}
31
32/// Result of checking a single budget category
33#[derive(Debug, Clone)]
34pub struct BudgetCheck {
35    pub category: String,
36    pub count: usize,
37    pub limit: usize,
38    pub over: bool,
39}
40
41/// A dependency and how many transitive deps it pulls in
42#[derive(Debug, Clone)]
43pub struct HeavyDep {
44    pub name: String,
45    pub transitive_count: usize,
46}
47
48/// Overall budget report
49#[derive(Debug)]
50pub struct BudgetReport {
51    pub checks: Vec<BudgetCheck>,
52    pub heaviest: Vec<HeavyDep>,
53    pub over_budget: bool,
54}
55
56/// Configuration from [package.metadata.dep-budget] in Cargo.toml
57#[derive(Debug, Deserialize, Default)]
58#[serde(rename_all = "kebab-case")]
59struct DepBudgetMetadata {
60    max_direct: Option<usize>,
61    max_total: Option<usize>,
62    max_build: Option<usize>,
63}
64
65/// Top-level Cargo.toml structure for metadata extraction
66#[derive(Debug, Deserialize)]
67struct CargoTomlForMetadata {
68    package: Option<PackageSection>,
69}
70
71#[derive(Debug, Deserialize)]
72struct PackageSection {
73    metadata: Option<MetadataSection>,
74}
75
76#[derive(Debug, Deserialize)]
77#[serde(rename_all = "kebab-case")]
78struct MetadataSection {
79    dep_budget: Option<DepBudgetMetadata>,
80}
81
82/// Read budget config from Cargo.toml [package.metadata.dep-budget]
83pub fn read_config_from_cargo_toml(cargo_toml_path: &Path) -> Option<BudgetConfig> {
84    let content = std::fs::read_to_string(cargo_toml_path).ok()?;
85    parse_config_from_cargo_toml(&content)
86}
87
88/// Parse budget config from Cargo.toml content string
89pub fn parse_config_from_cargo_toml(content: &str) -> Option<BudgetConfig> {
90    let parsed: CargoTomlForMetadata = toml::from_str(content).ok()?;
91    let metadata = parsed.package?.metadata?.dep_budget?;
92
93    let defaults = BudgetConfig::default();
94    Some(BudgetConfig {
95        max_direct: metadata.max_direct.unwrap_or(defaults.max_direct),
96        max_total: metadata.max_total.unwrap_or(defaults.max_total),
97        max_build: metadata.max_build.unwrap_or(defaults.max_build),
98    })
99}
100
101/// Merge CLI overrides into a config (CLI values take precedence)
102pub fn merge_config(
103    base: BudgetConfig,
104    cli_max_direct: Option<usize>,
105    cli_max_total: Option<usize>,
106    cli_max_build: Option<usize>,
107) -> BudgetConfig {
108    BudgetConfig {
109        max_direct: cli_max_direct.unwrap_or(base.max_direct),
110        max_total: cli_max_total.unwrap_or(base.max_total),
111        max_build: cli_max_build.unwrap_or(base.max_build),
112    }
113}
114
115/// Count dependencies from Cargo.toml content
116pub fn count_deps_from_cargo_toml(content: &str) -> DepCounts {
117    let parsed: toml::Value = match toml::from_str(content) {
118        Ok(v) => v,
119        Err(_) => return DepCounts::default(),
120    };
121
122    let count_table = |key: &str| -> usize {
123        parsed
124            .get(key)
125            .and_then(|v| v.as_table())
126            .map(|t| t.len())
127            .unwrap_or(0)
128    };
129
130    DepCounts {
131        direct: count_table("dependencies"),
132        build: count_table("build-dependencies"),
133        dev: count_table("dev-dependencies"),
134        total_transitive: 0, // filled from Cargo.lock
135    }
136}
137
138/// Count total packages from Cargo.lock content
139/// Cargo.lock v3/v4 uses [[package]] entries with name, version, source fields
140pub fn count_packages_from_lockfile(content: &str) -> usize {
141    let parsed: toml::Value = match toml::from_str(content) {
142        Ok(v) => v,
143        Err(_) => return 0,
144    };
145
146    parsed
147        .get("package")
148        .and_then(|v| v.as_array())
149        .map(|a| a.len())
150        .unwrap_or(0)
151}
152
153/// Structures for cargo metadata JSON parsing
154#[derive(Debug, Deserialize)]
155pub struct CargoMetadata {
156    pub resolve: Option<Resolve>,
157}
158
159#[derive(Debug, Deserialize)]
160pub struct Resolve {
161    pub nodes: Vec<ResolveNode>,
162    pub root: Option<String>,
163}
164
165#[derive(Debug, Deserialize)]
166pub struct ResolveNode {
167    pub id: String,
168    pub deps: Vec<ResolveDep>,
169}
170
171#[derive(Debug, Deserialize)]
172pub struct ResolveDep {
173    pub name: String,
174    pub pkg: String,
175}
176
177/// Extract package name from a cargo metadata package ID
178/// Format: "registry+https://...#name@version" or "path+file:///...#name@version"
179fn extract_name_from_id(id: &str) -> String {
180    // Try the new format: "registry+...#name@version" or "path+...#name@version"
181    if let Some(hash_pos) = id.rfind('#') {
182        let after_hash = &id[hash_pos + 1..];
183        if let Some(at_pos) = after_hash.rfind('@') {
184            return after_hash[..at_pos].to_string();
185        }
186        return after_hash.to_string();
187    }
188    // Fallback: old format "name version (source)"
189    id.split_whitespace().next().unwrap_or(id).to_string()
190}
191
192/// Count transitive dependencies for each direct dependency of the root package
193/// using cargo metadata resolve graph
194pub fn find_heaviest_deps(metadata_json: &str, top_n: usize) -> Vec<HeavyDep> {
195    let metadata: CargoMetadata = match serde_json::from_str(metadata_json) {
196        Ok(m) => m,
197        Err(_) => return vec![],
198    };
199
200    let resolve = match metadata.resolve {
201        Some(r) => r,
202        None => return vec![],
203    };
204
205    // Build adjacency map: pkg_id -> list of dependency pkg_ids
206    let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new();
207    for node in &resolve.nodes {
208        let deps: Vec<&str> = node.deps.iter().map(|d| d.pkg.as_str()).collect();
209        adjacency.insert(&node.id, deps);
210    }
211
212    // Find root node
213    let root_id = match &resolve.root {
214        Some(r) => r.as_str(),
215        None => return vec![],
216    };
217
218    // Get direct deps of root
219    let root_deps = match adjacency.get(root_id) {
220        Some(deps) => deps.clone(),
221        None => return vec![],
222    };
223
224    // For each direct dep, count its total transitive closure
225    let mut heavy_deps: Vec<HeavyDep> = root_deps
226        .iter()
227        .map(|dep_id| {
228            let count = count_transitive(&adjacency, dep_id);
229            HeavyDep {
230                name: extract_name_from_id(dep_id),
231                transitive_count: count,
232            }
233        })
234        .collect();
235
236    // Sort by transitive count descending
237    heavy_deps.sort_by(|a, b| b.transitive_count.cmp(&a.transitive_count));
238    heavy_deps.truncate(top_n);
239    heavy_deps
240}
241
242/// Count total transitive deps reachable from a given node (BFS)
243fn count_transitive(adjacency: &HashMap<&str, Vec<&str>>, start: &str) -> usize {
244    let mut visited = HashSet::new();
245    let mut stack = vec![start];
246
247    while let Some(node) = stack.pop() {
248        if !visited.insert(node) {
249            continue;
250        }
251        if let Some(deps) = adjacency.get(node) {
252            for dep in deps {
253                if !visited.contains(dep) {
254                    stack.push(dep);
255                }
256            }
257        }
258    }
259
260    // Subtract 1 because we don't count the start node itself
261    visited.len().saturating_sub(1)
262}
263
264/// Check all budget categories and produce a report
265pub fn check_budget(counts: &DepCounts, config: &BudgetConfig, heaviest: Vec<HeavyDep>) -> BudgetReport {
266    let checks = vec![
267        BudgetCheck {
268            category: "Direct dependencies".to_string(),
269            count: counts.direct,
270            limit: config.max_direct,
271            over: counts.direct > config.max_direct,
272        },
273        BudgetCheck {
274            category: "Build dependencies".to_string(),
275            count: counts.build,
276            limit: config.max_build,
277            over: counts.build > config.max_build,
278        },
279        BudgetCheck {
280            category: "Total transitive".to_string(),
281            count: counts.total_transitive,
282            limit: config.max_total,
283            over: counts.total_transitive > config.max_total,
284        },
285    ];
286
287    let over_budget = checks.iter().any(|c| c.over);
288
289    BudgetReport {
290        checks,
291        heaviest,
292        over_budget,
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_default_budget_config() {
302        let config = BudgetConfig::default();
303        assert_eq!(config.max_direct, 30);
304        assert_eq!(config.max_total, 200);
305        assert_eq!(config.max_build, 15);
306    }
307
308    #[test]
309    fn test_count_deps_from_cargo_toml() {
310        let content = r#"
311[package]
312name = "example"
313version = "0.1.0"
314
315[dependencies]
316serde = "1"
317clap = "4"
318tokio = "1"
319
320[build-dependencies]
321cc = "1"
322
323[dev-dependencies]
324tempfile = "3"
325assert_cmd = "2"
326"#;
327        let counts = count_deps_from_cargo_toml(content);
328        assert_eq!(counts.direct, 3);
329        assert_eq!(counts.build, 1);
330        assert_eq!(counts.dev, 2);
331    }
332
333    #[test]
334    fn test_count_deps_with_inline_tables() {
335        let content = r#"
336[package]
337name = "example"
338version = "0.1.0"
339
340[dependencies]
341serde = { version = "1", features = ["derive"] }
342clap = { version = "4", features = ["derive"] }
343"#;
344        let counts = count_deps_from_cargo_toml(content);
345        assert_eq!(counts.direct, 2);
346    }
347
348    #[test]
349    fn test_count_deps_empty() {
350        let content = r#"
351[package]
352name = "example"
353version = "0.1.0"
354"#;
355        let counts = count_deps_from_cargo_toml(content);
356        assert_eq!(counts.direct, 0);
357        assert_eq!(counts.build, 0);
358        assert_eq!(counts.dev, 0);
359    }
360
361    #[test]
362    fn test_count_packages_from_lockfile_v4() {
363        // Real Cargo.lock v4 format
364        let content = r#"# This file is automatically @generated by Cargo.
365# It is not intended for manual editing.
366version = 4
367
368[[package]]
369name = "adler2"
370version = "2.0.1"
371source = "registry+https://github.com/rust-lang/crates.io-index"
372checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
373
374[[package]]
375name = "aho-corasick"
376version = "1.1.4"
377source = "registry+https://github.com/rust-lang/crates.io-index"
378checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
379dependencies = [
380 "memchr",
381]
382
383[[package]]
384name = "memchr"
385version = "2.7.1"
386source = "registry+https://github.com/rust-lang/crates.io-index"
387checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149"
388"#;
389        assert_eq!(count_packages_from_lockfile(content), 3);
390    }
391
392    #[test]
393    fn test_count_packages_from_lockfile_v3() {
394        let content = r#"# This file is automatically @generated by Cargo.
395# It is not intended for manual editing.
396version = 3
397
398[[package]]
399name = "serde"
400version = "1.0.200"
401source = "registry+https://github.com/rust-lang/crates.io-index"
402
403[[package]]
404name = "my-crate"
405version = "0.1.0"
406"#;
407        assert_eq!(count_packages_from_lockfile(content), 2);
408    }
409
410    #[test]
411    fn test_parse_config_from_cargo_toml() {
412        let content = r#"
413[package]
414name = "example"
415version = "0.1.0"
416
417[package.metadata.dep-budget]
418max-direct = 15
419max-total = 80
420max-build = 10
421"#;
422        let config = parse_config_from_cargo_toml(content).unwrap();
423        assert_eq!(config.max_direct, 15);
424        assert_eq!(config.max_total, 80);
425        assert_eq!(config.max_build, 10);
426    }
427
428    #[test]
429    fn test_parse_config_partial() {
430        let content = r#"
431[package]
432name = "example"
433version = "0.1.0"
434
435[package.metadata.dep-budget]
436max-direct = 10
437"#;
438        let config = parse_config_from_cargo_toml(content).unwrap();
439        assert_eq!(config.max_direct, 10);
440        assert_eq!(config.max_total, 200); // default
441        assert_eq!(config.max_build, 15); // default
442    }
443
444    #[test]
445    fn test_parse_config_missing() {
446        let content = r#"
447[package]
448name = "example"
449version = "0.1.0"
450"#;
451        assert!(parse_config_from_cargo_toml(content).is_none());
452    }
453
454    #[test]
455    fn test_merge_config_cli_overrides() {
456        let base = BudgetConfig {
457            max_direct: 15,
458            max_total: 80,
459            max_build: 10,
460        };
461        let merged = merge_config(base, Some(20), None, Some(5));
462        assert_eq!(merged.max_direct, 20);
463        assert_eq!(merged.max_total, 80);
464        assert_eq!(merged.max_build, 5);
465    }
466
467    #[test]
468    fn test_check_budget_within() {
469        let counts = DepCounts {
470            direct: 5,
471            build: 2,
472            dev: 3,
473            total_transitive: 50,
474        };
475        let config = BudgetConfig::default();
476        let report = check_budget(&counts, &config, vec![]);
477        assert!(!report.over_budget);
478        for check in &report.checks {
479            assert!(!check.over);
480        }
481    }
482
483    #[test]
484    fn test_check_budget_over_direct() {
485        let counts = DepCounts {
486            direct: 35,
487            build: 2,
488            dev: 3,
489            total_transitive: 50,
490        };
491        let config = BudgetConfig::default();
492        let report = check_budget(&counts, &config, vec![]);
493        assert!(report.over_budget);
494        assert!(report.checks[0].over); // direct
495        assert!(!report.checks[1].over); // build
496    }
497
498    #[test]
499    fn test_check_budget_over_total() {
500        let counts = DepCounts {
501            direct: 5,
502            build: 2,
503            dev: 3,
504            total_transitive: 250,
505        };
506        let config = BudgetConfig::default();
507        let report = check_budget(&counts, &config, vec![]);
508        assert!(report.over_budget);
509        assert!(report.checks[2].over); // total
510    }
511
512    #[test]
513    fn test_extract_name_from_id_new_format() {
514        let id = "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.200";
515        assert_eq!(extract_name_from_id(id), "serde");
516    }
517
518    #[test]
519    fn test_extract_name_from_id_path_format() {
520        let id = "path+file:///home/user/project#my-crate@0.1.0";
521        assert_eq!(extract_name_from_id(id), "my-crate");
522    }
523
524    #[test]
525    fn test_find_heaviest_deps() {
526        let metadata_json = r#"{
527            "resolve": {
528                "root": "path+file:///home/user/project#my-crate@0.1.0",
529                "nodes": [
530                    {
531                        "id": "path+file:///home/user/project#my-crate@0.1.0",
532                        "deps": [
533                            {"name": "serde", "pkg": "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.200"},
534                            {"name": "clap", "pkg": "registry+https://github.com/rust-lang/crates.io-index#clap@4.5.0"}
535                        ]
536                    },
537                    {
538                        "id": "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.200",
539                        "deps": [
540                            {"name": "serde_derive", "pkg": "registry+https://github.com/rust-lang/crates.io-index#serde_derive@1.0.200"}
541                        ]
542                    },
543                    {
544                        "id": "registry+https://github.com/rust-lang/crates.io-index#serde_derive@1.0.200",
545                        "deps": []
546                    },
547                    {
548                        "id": "registry+https://github.com/rust-lang/crates.io-index#clap@4.5.0",
549                        "deps": [
550                            {"name": "clap_builder", "pkg": "registry+https://github.com/rust-lang/crates.io-index#clap_builder@4.5.0"},
551                            {"name": "clap_derive", "pkg": "registry+https://github.com/rust-lang/crates.io-index#clap_derive@4.5.0"}
552                        ]
553                    },
554                    {
555                        "id": "registry+https://github.com/rust-lang/crates.io-index#clap_builder@4.5.0",
556                        "deps": [
557                            {"name": "anstyle", "pkg": "registry+https://github.com/rust-lang/crates.io-index#anstyle@1.0.0"}
558                        ]
559                    },
560                    {
561                        "id": "registry+https://github.com/rust-lang/crates.io-index#clap_derive@4.5.0",
562                        "deps": []
563                    },
564                    {
565                        "id": "registry+https://github.com/rust-lang/crates.io-index#anstyle@1.0.0",
566                        "deps": []
567                    }
568                ]
569            }
570        }"#;
571
572        let heaviest = find_heaviest_deps(metadata_json, 5);
573        assert_eq!(heaviest.len(), 2);
574        // clap should be heaviest: clap_builder, clap_derive, anstyle = 3 transitive
575        assert_eq!(heaviest[0].name, "clap");
576        assert_eq!(heaviest[0].transitive_count, 3);
577        // serde: serde_derive = 1 transitive
578        assert_eq!(heaviest[1].name, "serde");
579        assert_eq!(heaviest[1].transitive_count, 1);
580    }
581
582    #[test]
583    fn test_find_heaviest_deps_empty() {
584        let heaviest = find_heaviest_deps("{}", 5);
585        assert!(heaviest.is_empty());
586    }
587}