1use crate::store::Annotation;
8use crate::types::{Binding, RelativePath, TagName};
9use serde::Serialize;
10
11#[non_exhaustive]
13#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
14#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
15#[cfg_attr(feature = "ts", ts(export))]
16#[cfg_attr(feature = "flow", flow(export))]
17#[derive(Debug, Clone, Serialize)]
18pub struct DiffResult {
19 pub added: Vec<DiffEntry>,
21 pub removed: Vec<DiffEntry>,
23 pub changed: Vec<DiffChange>,
25}
26
27#[non_exhaustive]
29#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
30#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
31#[cfg_attr(feature = "ts", ts(export))]
32#[cfg_attr(feature = "flow", flow(export))]
33#[derive(Debug, Clone, Serialize)]
34pub struct DiffEntry {
35 pub file: RelativePath,
37 pub tag: TagName,
39 pub binding: Binding,
41}
42
43#[non_exhaustive]
45#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
46#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
47#[cfg_attr(feature = "ts", ts(export))]
48#[cfg_attr(feature = "flow", flow(export))]
49#[derive(Debug, Clone, Serialize)]
50pub struct DiffChange {
51 pub file: RelativePath,
53 pub tag: TagName,
55 pub binding: Binding,
57 pub baseline_attrs: Vec<String>,
59 pub current_attrs: Vec<String>,
61}
62
63#[must_use = "diff result contains added, removed, and changed annotations"]
67pub fn diff_annotations(baseline: &[Annotation], current: &[Annotation]) -> DiffResult {
68 let mut added = Vec::new();
69 let mut removed = Vec::new();
70 let mut changed = Vec::new();
71
72 let baseline_set: rustc_hash::FxHashMap<(&str, &str, &str), &Annotation> = baseline
74 .iter()
75 .map(|a| (a.file.as_ref(), a.tag.as_ref(), a.binding.as_ref()))
76 .zip(baseline.iter())
77 .collect();
78
79 let current_set: rustc_hash::FxHashMap<(&str, &str, &str), &Annotation> = current
80 .iter()
81 .map(|a| (a.file.as_ref(), a.tag.as_ref(), a.binding.as_ref()))
82 .zip(current.iter())
83 .collect();
84
85 for (key, curr_ann) in ¤t_set {
87 match baseline_set.get(key) {
88 None => {
89 added.push(DiffEntry {
90 file: curr_ann.file.clone(),
91 tag: curr_ann.tag.clone(),
92 binding: curr_ann.binding.clone(),
93 });
94 }
95 Some(base_ann) => {
96 let diff = attr_diff(base_ann, curr_ann);
97 if !diff.0.is_empty() || !diff.1.is_empty() {
98 changed.push(DiffChange {
99 file: curr_ann.file.clone(),
100 tag: curr_ann.tag.clone(),
101 binding: curr_ann.binding.clone(),
102 baseline_attrs: diff.0,
103 current_attrs: diff.1,
104 });
105 }
106 }
107 }
108 }
109
110 for (key, base_ann) in &baseline_set {
112 if !current_set.contains_key(key) {
113 removed.push(DiffEntry {
114 file: base_ann.file.clone(),
115 tag: base_ann.tag.clone(),
116 binding: base_ann.binding.clone(),
117 });
118 }
119 }
120
121 DiffResult {
122 added,
123 removed,
124 changed,
125 }
126}
127
128fn attr_diff(baseline: &Annotation, current: &Annotation) -> (Vec<String>, Vec<String>) {
130 let mut baseline_diff = Vec::new();
131 let mut current_diff = Vec::new();
132
133 for (key, base_val) in &baseline.attrs {
134 match current.attrs.get(key.as_ref()) {
135 None => baseline_diff.push(format!("-{key}")),
136 Some(curr_val) if curr_val != base_val => {
137 baseline_diff.push(format!("{key}={base_val}"));
138 current_diff.push(format!("{key}={curr_val}"));
139 }
140 _ => {}
141 }
142 }
143
144 for key in current.attrs.keys() {
145 if !baseline.attrs.contains_key(key.as_ref()) {
146 current_diff.push(format!("+{key}"));
147 }
148 }
149
150 (baseline_diff, current_diff)
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use crate::types::{AttrName, Binding, TagName};
157 use rustc_hash::FxHashMap;
158 use serde_json::Value as JsonValue;
159
160 fn ann(tag: &str, binding: &str, file: &str, attrs: Vec<(&str, &str)>) -> Annotation {
161 let mut attr_map = FxHashMap::default();
162 for (k, v) in attrs {
163 attr_map.insert(AttrName::from(k), JsonValue::String(v.to_string()));
164 }
165 Annotation {
166 tag: TagName::from(tag),
167 attrs: attr_map,
168 binding: Binding::from(binding),
169 file: RelativePath::from(file),
170 children: vec![],
171 }
172 }
173
174 #[test]
175 fn detects_added_annotations() {
176 let baseline = vec![ann("route", "GET /users", "api.ts", vec![])];
178 let current = vec![
179 ann("route", "GET /users", "api.ts", vec![]),
180 ann("route", "POST /users", "api.ts", vec![]),
181 ];
182
183 let diff = diff_annotations(&baseline, ¤t);
185
186 assert_eq!(diff.added.len(), 1, "should detect 1 added annotation");
188 assert_eq!(diff.added[0].binding, "POST /users", "added binding");
189 assert!(diff.removed.is_empty(), "nothing removed");
190 }
191
192 #[test]
193 fn detects_removed_annotations() {
194 let baseline = vec![
196 ann("route", "GET /users", "api.ts", vec![]),
197 ann("route", "DELETE /users", "api.ts", vec![]),
198 ];
199 let current = vec![ann("route", "GET /users", "api.ts", vec![])];
200
201 let diff = diff_annotations(&baseline, ¤t);
203
204 assert_eq!(diff.removed.len(), 1, "should detect 1 removed annotation");
206 assert_eq!(diff.removed[0].binding, "DELETE /users", "removed binding");
207 }
208
209 #[test]
210 fn detects_changed_attributes() {
211 let baseline = vec![ann("route", "GET /users", "api.ts", vec![("auth", "none")])];
213 let current = vec![ann(
214 "route",
215 "GET /users",
216 "api.ts",
217 vec![("auth", "required")],
218 )];
219
220 let diff = diff_annotations(&baseline, ¤t);
222
223 assert!(diff.added.is_empty(), "nothing added");
225 assert!(diff.removed.is_empty(), "nothing removed");
226 assert_eq!(diff.changed.len(), 1, "should detect 1 changed annotation");
227 }
228
229 #[test]
230 fn no_diff_on_identical() {
231 let anns = vec![ann(
233 "route",
234 "GET /users",
235 "api.ts",
236 vec![("method", "GET")],
237 )];
238
239 let diff = diff_annotations(&anns, &anns);
241
242 assert!(diff.added.is_empty(), "nothing added");
244 assert!(diff.removed.is_empty(), "nothing removed");
245 assert!(diff.changed.is_empty(), "nothing changed");
246 }
247}