1use crate::diff::{compute_text_diff, opt_str_eq, tags_eq_unordered, DiffOp, TextDiffSummary};
12use crate::resource::EmailTemplate;
13use std::collections::BTreeMap;
14
15pub type EmailTemplateIdIndex = BTreeMap<String, String>;
19
20#[derive(Debug, Clone)]
21pub struct EmailTemplateDiff {
22 pub name: String,
23 pub op: DiffOp<EmailTemplate>,
24 pub subject_changed: bool,
25 pub body_html_diff: Option<TextDiffSummary>,
26 pub body_plaintext_diff: Option<TextDiffSummary>,
27 pub metadata_changed: bool,
28 pub orphan: bool,
30}
31
32impl EmailTemplateDiff {
33 pub fn has_changes(&self) -> bool {
34 self.op.is_change()
35 || self.subject_changed
36 || self.body_html_diff.is_some()
37 || self.body_plaintext_diff.is_some()
38 || self.metadata_changed
39 || self.orphan
40 }
41
42 pub fn is_orphan(&self) -> bool {
43 self.orphan
44 }
45
46 pub fn orphan(name: impl Into<String>) -> Self {
50 Self {
51 name: name.into(),
52 op: DiffOp::Unchanged,
53 subject_changed: false,
54 body_html_diff: None,
55 body_plaintext_diff: None,
56 metadata_changed: false,
57 orphan: true,
58 }
59 }
60}
61
62pub fn diff(
65 local: Option<&EmailTemplate>,
66 remote: Option<&EmailTemplate>,
67) -> Option<EmailTemplateDiff> {
68 match (local, remote) {
69 (None, None) => None,
70 (Some(l), None) => Some(EmailTemplateDiff {
71 name: l.name.clone(),
72 op: DiffOp::Added(l.clone()),
73 subject_changed: false,
74 body_html_diff: None,
75 body_plaintext_diff: None,
76 metadata_changed: false,
77 orphan: false,
78 }),
79 (None, Some(r)) => Some(EmailTemplateDiff::orphan(&r.name)),
80 (Some(l), Some(r)) => {
81 if syncable_eq(l, r) {
82 Some(EmailTemplateDiff {
83 name: l.name.clone(),
84 op: DiffOp::Unchanged,
85 subject_changed: false,
86 body_html_diff: None,
87 body_plaintext_diff: None,
88 metadata_changed: false,
89 orphan: false,
90 })
91 } else {
92 let subject_changed = l.subject != r.subject;
93 let body_html_diff = if l.body_html != r.body_html {
94 Some(compute_text_diff(&r.body_html, &l.body_html))
95 } else {
96 None
97 };
98 let body_plaintext_diff = if l.body_plaintext != r.body_plaintext {
99 Some(compute_text_diff(&r.body_plaintext, &l.body_plaintext))
100 } else {
101 None
102 };
103 let metadata_changed = !metadata_eq(l, r);
104 Some(EmailTemplateDiff {
105 name: l.name.clone(),
106 op: DiffOp::Modified {
107 from: r.clone(),
108 to: l.clone(),
109 },
110 subject_changed,
111 body_html_diff,
112 body_plaintext_diff,
113 metadata_changed,
114 orphan: false,
115 })
116 }
117 }
118 }
119}
120
121fn syncable_eq(a: &EmailTemplate, b: &EmailTemplate) -> bool {
125 a.name == b.name
126 && a.subject == b.subject
127 && a.body_html == b.body_html
128 && a.body_plaintext == b.body_plaintext
129 && opt_str_eq(&a.preheader, &b.preheader)
130 && a.should_inline_css == b.should_inline_css
131 && tags_eq_unordered(&a.tags, &b.tags)
132 }
134
135fn metadata_eq(a: &EmailTemplate, b: &EmailTemplate) -> bool {
138 opt_str_eq(&a.preheader, &b.preheader)
139 && a.should_inline_css == b.should_inline_css
140 && tags_eq_unordered(&a.tags, &b.tags)
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 fn et(name: &str, html: &str) -> EmailTemplate {
148 EmailTemplate {
149 name: name.into(),
150 subject: format!("Subject {name}"),
151 body_html: html.into(),
152 body_plaintext: format!("plain {name}"),
153 description: Some(format!("{name} desc")),
154 preheader: Some("preview".into()),
155 should_inline_css: Some(true),
156 tags: vec!["tag".into()],
157 }
158 }
159
160 #[test]
161 fn both_absent_returns_none() {
162 assert!(diff(None, None).is_none());
163 }
164
165 #[test]
166 fn local_only_is_added() {
167 let l = et("welcome", "<p>Hi</p>");
168 let d = diff(Some(&l), None).unwrap();
169 assert!(matches!(d.op, DiffOp::Added(_)));
170 assert!(!d.orphan);
171 assert!(d.has_changes());
172 }
173
174 #[test]
175 fn remote_only_is_orphan_not_removed() {
176 let r = et("legacy", "<p>old</p>");
177 let d = diff(None, Some(&r)).unwrap();
178 assert!(matches!(d.op, DiffOp::Unchanged));
179 assert!(d.orphan);
180 assert!(d.is_orphan());
181 assert!(d.has_changes());
182 }
183
184 #[test]
185 fn equal_templates_are_unchanged() {
186 let l = et("same", "<p>body</p>\n");
187 let r = et("same", "<p>body</p>\n");
188 let d = diff(Some(&l), Some(&r)).unwrap();
189 assert!(matches!(d.op, DiffOp::Unchanged));
190 assert!(!d.orphan);
191 assert!(!d.has_changes());
192 }
193
194 #[test]
195 fn subject_change_is_modified() {
196 let mut l = et("sub", "<p>body</p>\n");
197 let r = et("sub", "<p>body</p>\n");
198 l.subject = "New subject".into();
199 let d = diff(Some(&l), Some(&r)).unwrap();
200 assert!(matches!(d.op, DiffOp::Modified { .. }));
201 assert!(d.subject_changed);
202 assert!(d.body_html_diff.is_none());
203 assert!(d.body_plaintext_diff.is_none());
204 }
205
206 #[test]
207 fn body_html_change_produces_text_diff() {
208 let l = et("html", "line a\nline b\nline c\n");
209 let mut r = et("html", "line a\nold b\nline c\n");
210 r.body_html = "line a\nold b\nline c\n".into();
211 r.body_plaintext = l.body_plaintext.clone();
213 let d = diff(Some(&l), Some(&r)).unwrap();
214 assert!(matches!(d.op, DiffOp::Modified { .. }));
215 let td = d.body_html_diff.expect("html diff present");
216 assert_eq!(td.additions, 1);
217 assert_eq!(td.deletions, 1);
218 assert!(d.body_plaintext_diff.is_none());
219 }
220
221 #[test]
222 fn body_plaintext_change_produces_text_diff() {
223 let l = et("txt", "<p>same</p>");
224 let mut r = et("txt", "<p>same</p>");
225 r.body_plaintext = "different plain".into();
226 let d = diff(Some(&l), Some(&r)).unwrap();
227 assert!(matches!(d.op, DiffOp::Modified { .. }));
228 assert!(d.body_html_diff.is_none());
229 assert!(d.body_plaintext_diff.is_some());
230 }
231
232 #[test]
233 fn description_difference_alone_is_not_drift() {
234 let mut l = et("desc", "<p>body</p>");
235 let r = et("desc", "<p>body</p>");
236 l.description = Some("new desc".into());
237 let d = diff(Some(&l), Some(&r)).unwrap();
239 assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
240 assert!(!d.has_changes());
241 }
242
243 #[test]
244 fn tag_reorder_alone_is_not_drift() {
245 let mut l = et("tags", "<p>body</p>");
246 let mut r = et("tags", "<p>body</p>");
247 l.tags = vec!["alpha".into(), "beta".into(), "gamma".into()];
248 r.tags = vec!["gamma".into(), "alpha".into(), "beta".into()];
249 let d = diff(Some(&l), Some(&r)).unwrap();
250 assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
251 assert!(!d.has_changes());
252 }
253
254 #[test]
255 fn tag_change_is_modified_with_metadata_flag() {
256 let mut l = et("tags2", "<p>body</p>");
257 let r = et("tags2", "<p>body</p>");
258 l.tags = vec!["a".into(), "b".into()];
259 let d = diff(Some(&l), Some(&r)).unwrap();
260 assert!(matches!(d.op, DiffOp::Modified { .. }));
261 assert!(d.metadata_changed);
262 assert!(!d.subject_changed);
263 }
264
265 #[test]
266 fn preheader_none_equals_empty() {
267 let mut l = et("pre", "<p>body</p>");
268 let mut r = et("pre", "<p>body</p>");
269 l.preheader = Some(String::new());
270 r.preheader = None;
271 let d = diff(Some(&l), Some(&r)).unwrap();
272 assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
273 assert!(!d.has_changes());
274 }
275
276 #[test]
277 fn should_inline_css_change_is_metadata_modified() {
278 let mut l = et("css", "<p>body</p>");
279 let r = et("css", "<p>body</p>");
280 l.should_inline_css = Some(false);
281 let d = diff(Some(&l), Some(&r)).unwrap();
282 assert!(matches!(d.op, DiffOp::Modified { .. }));
283 assert!(d.metadata_changed);
284 }
285
286 #[test]
287 fn destructive_count_is_never_set_on_email_templates() {
288 let r = et("ghost", "<p>x</p>");
289 let orphan = diff(None, Some(&r)).unwrap();
290 assert!(!orphan.op.is_destructive());
291
292 let l2 = et("changed", "<p>new</p>");
293 let r2 = et("changed", "<p>old</p>");
294 let modified = diff(Some(&l2), Some(&r2)).unwrap();
295 assert!(!modified.op.is_destructive());
296 }
297}