1use serde::Deserialize;
2use std::collections::{HashMap, HashSet};
3use std::path::Path;
4
5#[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#[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#[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#[derive(Debug, Clone)]
43pub struct HeavyDep {
44 pub name: String,
45 pub transitive_count: usize,
46}
47
48#[derive(Debug)]
50pub struct BudgetReport {
51 pub checks: Vec<BudgetCheck>,
52 pub heaviest: Vec<HeavyDep>,
53 pub over_budget: bool,
54}
55
56#[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#[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
82pub 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
88pub 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
101pub 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
115pub 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, }
136}
137
138pub 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#[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
177fn extract_name_from_id(id: &str) -> String {
180 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 id.split_whitespace().next().unwrap_or(id).to_string()
190}
191
192pub 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 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 let root_id = match &resolve.root {
214 Some(r) => r.as_str(),
215 None => return vec![],
216 };
217
218 let root_deps = match adjacency.get(root_id) {
220 Some(deps) => deps.clone(),
221 None => return vec![],
222 };
223
224 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 heavy_deps.sort_by(|a, b| b.transitive_count.cmp(&a.transitive_count));
238 heavy_deps.truncate(top_n);
239 heavy_deps
240}
241
242fn 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 visited.len().saturating_sub(1)
262}
263
264pub 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 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); assert_eq!(config.max_build, 15); }
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); assert!(!report.checks[1].over); }
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); }
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 assert_eq!(heaviest[0].name, "clap");
576 assert_eq!(heaviest[0].transitive_count, 3);
577 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}