1use serde::{Deserialize, Serialize};
2
3use crate::schema::{ApiItem, ApiItemKind, ApiSnapshot, Visibility};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7pub enum ChangeDetail {
8 SignatureChanged { old: String, new: String },
9 VisibilityChanged { old: Visibility, new: Visibility },
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ApiChange {
15 pub name: String,
16 pub module_path: Vec<String>,
17 pub kind: ApiItemKind,
18 pub changes: Vec<ChangeDetail>,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23pub enum BreakingLevel {
24 Breaking,
25 Dangerous,
26 Additive,
27 Unchanged,
28}
29
30impl std::fmt::Display for BreakingLevel {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 match self {
33 Self::Breaking => write!(f, "BREAKING"),
34 Self::Dangerous => write!(f, "DANGEROUS"),
35 Self::Additive => write!(f, "ADDITIVE"),
36 Self::Unchanged => write!(f, "UNCHANGED"),
37 }
38 }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ApiDiff {
44 pub added: Vec<ApiItem>,
45 pub removed: Vec<ApiItem>,
46 pub changed: Vec<ApiChange>,
47}
48
49type ItemKey = (ApiItemKind, String, Vec<String>);
51
52fn item_key(item: &ApiItem) -> ItemKey {
53 (item.kind.clone(), item.name.clone(), item.module_path.clone())
54}
55
56pub fn diff_snapshots(baseline: &ApiSnapshot, current: &ApiSnapshot) -> ApiDiff {
58 let baseline_keys: std::collections::HashMap<ItemKey, &ApiItem> =
59 baseline.items.iter().map(|i| (item_key(i), i)).collect();
60 let current_keys: std::collections::HashMap<ItemKey, &ApiItem> =
61 current.items.iter().map(|i| (item_key(i), i)).collect();
62
63 let mut added = Vec::new();
64 let mut removed = Vec::new();
65 let mut changed = Vec::new();
66
67 for (key, item) in ¤t_keys {
69 if !baseline_keys.contains_key(key) {
70 added.push((*item).clone());
71 }
72 }
73
74 for (key, item) in &baseline_keys {
76 if !current_keys.contains_key(key) {
77 removed.push((*item).clone());
78 }
79 }
80
81 for (key, baseline_item) in &baseline_keys {
83 if let Some(current_item) = current_keys.get(key) {
84 let mut changes = Vec::new();
85
86 if baseline_item.signature != current_item.signature {
87 changes.push(ChangeDetail::SignatureChanged {
88 old: baseline_item.signature.clone(),
89 new: current_item.signature.clone(),
90 });
91 }
92
93 if baseline_item.visibility != current_item.visibility {
94 changes.push(ChangeDetail::VisibilityChanged {
95 old: baseline_item.visibility.clone(),
96 new: current_item.visibility.clone(),
97 });
98 }
99
100 if !changes.is_empty() {
101 changed.push(ApiChange {
102 name: baseline_item.name.clone(),
103 module_path: baseline_item.module_path.clone(),
104 kind: baseline_item.kind.clone(),
105 changes,
106 });
107 }
108 }
109 }
110
111 ApiDiff { added, removed, changed }
112}
113
114pub fn classify_change(change: &ApiChange) -> BreakingLevel {
116 let mut level = BreakingLevel::Unchanged;
117
118 for detail in &change.changes {
119 let detail_level = match detail {
120 ChangeDetail::VisibilityChanged { old, new } => {
121 if is_visibility_narrowed(old, new) {
122 BreakingLevel::Breaking
123 } else {
124 BreakingLevel::Additive
125 }
126 }
127 ChangeDetail::SignatureChanged { .. } => BreakingLevel::Dangerous,
128 };
129
130 level = max_breaking(level, detail_level);
131 }
132
133 level
134}
135
136fn visibility_rank(v: &Visibility) -> u8 {
137 match v {
138 Visibility::Public => 3,
139 Visibility::Crate => 2,
140 Visibility::Restricted => 1,
141 Visibility::Private => 0,
142 }
143}
144
145fn is_visibility_narrowed(old: &Visibility, new: &Visibility) -> bool {
146 visibility_rank(new) < visibility_rank(old)
147}
148
149fn max_breaking(a: BreakingLevel, b: BreakingLevel) -> BreakingLevel {
150 fn rank(l: BreakingLevel) -> u8 {
151 match l {
152 BreakingLevel::Unchanged => 0,
153 BreakingLevel::Additive => 1,
154 BreakingLevel::Dangerous => 2,
155 BreakingLevel::Breaking => 3,
156 }
157 }
158 if rank(a) >= rank(b) { a } else { b }
159}
160
161impl ApiDiff {
162 pub fn is_empty(&self) -> bool {
164 self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
165 }
166
167 pub fn has_breaking(&self) -> bool {
169 if !self.removed.is_empty() {
170 return true;
171 }
172 self.changed.iter().any(|c| classify_change(c) == BreakingLevel::Breaking)
173 }
174
175 pub fn breaking_count(&self) -> usize {
177 let removed_count = self.removed.len();
178 let changed_breaking = self.changed.iter()
179 .filter(|c| classify_change(c) == BreakingLevel::Breaking)
180 .count();
181 removed_count + changed_breaking
182 }
183
184 pub fn summary(&self) -> String {
186 let mut parts = Vec::new();
187 if !self.added.is_empty() {
188 parts.push(format!("{} added", self.added.len()));
189 }
190 if !self.removed.is_empty() {
191 parts.push(format!("{} removed", self.removed.len()));
192 }
193 if !self.changed.is_empty() {
194 parts.push(format!("{} changed", self.changed.len()));
195 }
196 if parts.is_empty() {
197 "No API changes".into()
198 } else {
199 parts.join(", ")
200 }
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn make_item(name: &str, sig: &str, vis: Visibility) -> ApiItem {
209 ApiItem {
210 kind: ApiItemKind::Function,
211 name: name.into(),
212 module_path: vec![],
213 signature: sig.into(),
214 visibility: vis,
215 trait_associations: vec![],
216 stability: None,
217 doc_summary: None,
218 span_file: None,
219 span_line: None,
220 }
221 }
222
223 fn make_snapshot(items: Vec<ApiItem>) -> ApiSnapshot {
224 ApiSnapshot {
225 crate_name: "test".into(),
226 version: None,
227 items,
228 extracted_at: "now".into(),
229 }
230 }
231
232 #[test]
233 fn diff_added_items() {
234 let baseline = make_snapshot(vec![
235 make_item("foo", "fn foo()", Visibility::Public),
236 ]);
237 let current = make_snapshot(vec![
238 make_item("foo", "fn foo()", Visibility::Public),
239 make_item("bar", "fn bar()", Visibility::Public),
240 ]);
241 let diff = diff_snapshots(&baseline, ¤t);
242 assert_eq!(diff.added.len(), 1);
243 assert_eq!(diff.added[0].name, "bar");
244 assert!(diff.removed.is_empty());
245 assert!(diff.changed.is_empty());
246 }
247
248 #[test]
249 fn diff_removed_items() {
250 let baseline = make_snapshot(vec![
251 make_item("foo", "fn foo()", Visibility::Public),
252 make_item("bar", "fn bar()", Visibility::Public),
253 ]);
254 let current = make_snapshot(vec![
255 make_item("foo", "fn foo()", Visibility::Public),
256 ]);
257 let diff = diff_snapshots(&baseline, ¤t);
258 assert!(diff.added.is_empty());
259 assert_eq!(diff.removed.len(), 1);
260 assert_eq!(diff.removed[0].name, "bar");
261 assert!(diff.has_breaking());
262 }
263
264 #[test]
265 fn diff_changed_signature() {
266 let baseline = make_snapshot(vec![
267 make_item("foo", "fn foo()", Visibility::Public),
268 ]);
269 let current = make_snapshot(vec![
270 make_item("foo", "fn foo(x: i32)", Visibility::Public),
271 ]);
272 let diff = diff_snapshots(&baseline, ¤t);
273 assert!(diff.added.is_empty());
274 assert!(diff.removed.is_empty());
275 assert_eq!(diff.changed.len(), 1);
276 assert_eq!(diff.changed[0].name, "foo");
277 assert_eq!(classify_change(&diff.changed[0]), BreakingLevel::Dangerous);
278 }
279
280 #[test]
281 fn diff_changed_visibility() {
282 let baseline = make_snapshot(vec![
283 make_item("foo", "fn foo()", Visibility::Public),
284 ]);
285 let current = make_snapshot(vec![
286 make_item("foo", "fn foo()", Visibility::Crate),
287 ]);
288 let diff = diff_snapshots(&baseline, ¤t);
289 assert_eq!(diff.changed.len(), 1);
290 assert_eq!(classify_change(&diff.changed[0]), BreakingLevel::Breaking);
291 assert!(diff.has_breaking());
292 }
293
294 #[test]
295 fn diff_no_changes() {
296 let baseline = make_snapshot(vec![
297 make_item("foo", "fn foo()", Visibility::Public),
298 ]);
299 let current = make_snapshot(vec![
300 make_item("foo", "fn foo()", Visibility::Public),
301 ]);
302 let diff = diff_snapshots(&baseline, ¤t);
303 assert!(diff.is_empty());
304 assert!(!diff.has_breaking());
305 assert_eq!(diff.breaking_count(), 0);
306 assert_eq!(diff.summary(), "No API changes");
307 }
308
309 #[test]
310 fn summary_format() {
311 let baseline = make_snapshot(vec![
312 make_item("foo", "fn foo()", Visibility::Public),
313 ]);
314 let current = make_snapshot(vec![
315 make_item("bar", "fn bar()", Visibility::Public),
316 ]);
317 let diff = diff_snapshots(&baseline, ¤t);
318 let summary = diff.summary();
319 assert!(summary.contains("added"));
320 assert!(summary.contains("removed"));
321 }
322
323 #[test]
324 fn breaking_count_includes_removed_and_breaking_changes() {
325 let baseline = make_snapshot(vec![
326 make_item("foo", "fn foo()", Visibility::Public),
327 make_item("bar", "fn bar()", Visibility::Public),
328 ]);
329 let current = make_snapshot(vec![
330 make_item("bar", "fn bar()", Visibility::Crate),
331 ]);
332 let diff = diff_snapshots(&baseline, ¤t);
333 assert_eq!(diff.breaking_count(), 2);
335 }
336}