1use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, HashSet};
9
10#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
12pub struct DependencyChange {
13 pub name: String,
14 pub old_version: Option<String>,
15 pub new_version: Option<String>,
16 pub change_type: DependencyChangeType,
17}
18
19#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
20#[serde(rename_all = "snake_case")]
21pub enum DependencyChangeType {
22 Added,
23 Removed,
24 Updated,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
29pub struct BinaryBlame {
30 pub changes: Vec<DependencyChange>,
31}
32
33pub fn parse_lockfile(content: &str) -> BTreeMap<String, String> {
35 let mut packages = BTreeMap::new();
36 let mut current_package = None;
37
38 for line in content.lines() {
39 let line = line.trim();
40 if line == "[[package]]" {
41 current_package = None;
42 } else if line.starts_with("name = ") {
43 current_package = line
44 .strip_prefix("name = ")
45 .map(|s| s.trim_matches('"').to_string());
46 } else if line.starts_with("version = ")
47 && let (Some(name), Some(version)) = (
48 current_package.as_ref(),
49 line.strip_prefix("version = ").map(|s| s.trim_matches('"')),
50 )
51 {
52 packages.insert(name.clone(), version.to_string());
53 }
54 }
55 packages
56}
57
58pub fn compare_lockfiles(old_lock: &str, new_lock: &str) -> BinaryBlame {
60 let old_pkgs = parse_lockfile(old_lock);
61 let new_pkgs = parse_lockfile(new_lock);
62
63 let mut changes = Vec::new();
64 let all_names: HashSet<_> = old_pkgs.keys().chain(new_pkgs.keys()).collect();
65
66 for name in all_names {
67 match (old_pkgs.get(name), new_pkgs.get(name)) {
68 (Some(old_v), Some(new_v)) if old_v != new_v => {
69 changes.push(DependencyChange {
70 name: name.clone(),
71 old_version: Some(old_v.clone()),
72 new_version: Some(new_v.clone()),
73 change_type: DependencyChangeType::Updated,
74 });
75 }
76 (None, Some(new_v)) => {
77 changes.push(DependencyChange {
78 name: name.clone(),
79 old_version: None,
80 new_version: Some(new_v.clone()),
81 change_type: DependencyChangeType::Added,
82 });
83 }
84 (Some(old_v), None) => {
85 changes.push(DependencyChange {
86 name: name.clone(),
87 old_version: Some(old_v.clone()),
88 new_version: None,
89 change_type: DependencyChangeType::Removed,
90 });
91 }
92 _ => {}
93 }
94 }
95
96 changes.sort_by(|a, b| a.name.cmp(&b.name));
98
99 BinaryBlame { changes }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn test_parse_lockfile() {
108 let lock = r#"
109[[package]]
110name = "pkg1"
111version = "1.0.0"
112
113[[package]]
114name = "pkg2"
115version = "2.1.0"
116"#;
117 let pkgs = parse_lockfile(lock);
118 assert_eq!(pkgs.len(), 2);
119 assert_eq!(pkgs["pkg1"], "1.0.0");
120 assert_eq!(pkgs["pkg2"], "2.1.0");
121 }
122
123 #[test]
124 fn test_compare_lockfiles() {
125 let old = r#"
126[[package]]
127name = "stay"
128version = "1.0.0"
129[[package]]
130name = "update"
131version = "1.0.0"
132[[package]]
133name = "remove"
134version = "1.0.0"
135"#;
136 let new = r#"
137[[package]]
138name = "stay"
139version = "1.0.0"
140[[package]]
141name = "update"
142version = "1.1.0"
143[[package]]
144name = "add"
145version = "1.0.0"
146"#;
147 let blame = compare_lockfiles(old, new);
148 assert_eq!(blame.changes.len(), 3);
149
150 assert_eq!(blame.changes[0].name, "add");
151 assert_eq!(blame.changes[0].change_type, DependencyChangeType::Added);
152
153 assert_eq!(blame.changes[1].name, "remove");
154 assert_eq!(blame.changes[1].change_type, DependencyChangeType::Removed);
155
156 assert_eq!(blame.changes[2].name, "update");
157 assert_eq!(blame.changes[2].change_type, DependencyChangeType::Updated);
158 }
159}