1use crate::bundle::{Bundle, Concept};
17use crate::concept_id::ConceptId;
18use crate::trust::{Status, TrustTier};
19use crate::yaml::Value;
20use std::collections::hash_map::DefaultHasher;
21use std::collections::{BTreeSet, HashMap};
22use std::hash::{Hash, Hasher};
23
24#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct Rename {
28 pub from: ConceptId,
30 pub to: ConceptId,
32}
33
34#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct FrontmatterChange {
37 pub id: ConceptId,
39 pub added: Vec<String>,
41 pub removed: Vec<String>,
43 pub changed: Vec<(String, String, String)>,
46}
47
48#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct TrustChange {
51 pub id: ConceptId,
53 pub tier: Option<(TrustTier, TrustTier)>,
55 pub status: Option<(Status, Status)>,
57}
58
59#[derive(Clone, Debug, Default, PartialEq, Eq)]
65pub struct BundleDiff {
66 pub added: Vec<ConceptId>,
69 pub removed: Vec<ConceptId>,
72 pub renamed: Vec<Rename>,
74 pub content: Vec<ConceptId>,
76 pub frontmatter: Vec<FrontmatterChange>,
78 pub trust: Vec<TrustChange>,
80 pub added_links: Vec<(ConceptId, ConceptId)>,
84 pub removed_links: Vec<(ConceptId, ConceptId)>,
88 pub mended_links: Vec<(ConceptId, String)>,
91 pub broken_links: Vec<(ConceptId, String)>,
94}
95
96impl BundleDiff {
97 #[must_use]
99 pub const fn is_empty(&self) -> bool {
100 self.added.is_empty()
101 && self.removed.is_empty()
102 && self.renamed.is_empty()
103 && self.content.is_empty()
104 && self.frontmatter.is_empty()
105 && self.trust.is_empty()
106 && self.added_links.is_empty()
107 && self.removed_links.is_empty()
108 && self.mended_links.is_empty()
109 && self.broken_links.is_empty()
110 }
111}
112
113#[must_use]
118pub fn bundle_diff(a: &Bundle, b: &Bundle) -> BundleDiff {
119 let a_ids: BTreeSet<ConceptId> = a.concepts().iter().map(|c| c.id.clone()).collect();
120 let b_ids: BTreeSet<ConceptId> = b.concepts().iter().map(|c| c.id.clone()).collect();
121
122 let removed: Vec<ConceptId> = a_ids.difference(&b_ids).cloned().collect();
123 let added: Vec<ConceptId> = b_ids.difference(&a_ids).cloned().collect();
124
125 let mut removed_by_hash: HashMap<u64, Vec<ConceptId>> = HashMap::new();
129 for id in &removed {
130 if let Some(c) = a.get(id) {
131 removed_by_hash
132 .entry(content_hash(c))
133 .or_default()
134 .push(id.clone());
135 }
136 }
137 let mut consumed_removed: BTreeSet<ConceptId> = BTreeSet::new();
138 let mut renamed: Vec<Rename> = Vec::new();
139 for id in &added {
140 let Some(c) = b.get(id) else { continue };
141 let h = content_hash(c);
142 let Some(candidates) = removed_by_hash.get(&h) else {
143 continue;
144 };
145 if let Some(from) = candidates
146 .iter()
147 .find(|cand| !consumed_removed.contains(*cand))
148 {
149 renamed.push(Rename {
150 from: from.clone(),
151 to: id.clone(),
152 });
153 consumed_removed.insert(from.clone());
154 }
155 }
156
157 let to_ids: BTreeSet<&ConceptId> = renamed.iter().map(|r| &r.to).collect();
158 let added: Vec<ConceptId> = added
159 .iter()
160 .filter(|id| !to_ids.contains(id))
161 .cloned()
162 .collect();
163 let removed: Vec<ConceptId> = removed
164 .iter()
165 .filter(|id| !consumed_removed.contains(id))
166 .cloned()
167 .collect();
168
169 let mut content = Vec::new();
172 let mut frontmatter = Vec::new();
173 let mut trust = Vec::new();
174 for id in a_ids.intersection(&b_ids) {
175 let (Some(ca), Some(cb)) = (a.get(id), b.get(id)) else {
176 continue;
177 };
178 if ca.document.body != cb.document.body {
179 content.push(id.clone());
180 }
181 if let Some(fc) = frontmatter_diff(ca, cb) {
182 frontmatter.push(fc);
183 }
184 if let Some(tc) = trust_diff(ca, cb) {
185 trust.push(tc);
186 }
187 }
188
189 let a_links = valid_link_edges(a);
194 let b_links = valid_link_edges(b);
195 let removed_links: Vec<(ConceptId, ConceptId)> =
196 a_links.difference(&b_links).cloned().collect();
197 let added_links: Vec<(ConceptId, ConceptId)> = b_links.difference(&a_links).cloned().collect();
198
199 let a_broken: BTreeSet<(ConceptId, String)> = a.broken_links().into_iter().collect();
202 let b_broken: BTreeSet<(ConceptId, String)> = b.broken_links().into_iter().collect();
203 let mended_links: Vec<(ConceptId, String)> = a_broken.difference(&b_broken).cloned().collect();
204 let broken_links: Vec<(ConceptId, String)> = b_broken.difference(&a_broken).cloned().collect();
205
206 BundleDiff {
207 added,
208 removed,
209 renamed,
210 content,
211 frontmatter,
212 trust,
213 added_links,
214 removed_links,
215 mended_links,
216 broken_links,
217 }
218}
219
220fn valid_link_edges(bundle: &Bundle) -> BTreeSet<(ConceptId, ConceptId)> {
222 bundle
223 .concepts()
224 .iter()
225 .flat_map(|concept| {
226 bundle
227 .links_from(&concept.id)
228 .iter()
229 .filter(|link| link.exists)
230 .map(|link| (concept.id.clone(), link.target.clone()))
231 })
232 .collect()
233}
234
235fn content_hash(concept: &Concept) -> u64 {
243 let mut hasher = DefaultHasher::new();
244 concept.document.body.hash(&mut hasher);
245 hash_option(&mut hasher, concept.type_().as_deref());
246 hash_option(&mut hasher, concept.document.frontmatter.title().as_deref());
247 hash_option(
248 &mut hasher,
249 concept.document.frontmatter.description().as_deref(),
250 );
251 hasher.finish()
252}
253
254fn hash_option<T: Hash + ?Sized>(hasher: &mut DefaultHasher, opt: Option<&T>) {
257 match opt {
258 Some(value) => {
259 1u8.hash(hasher);
260 value.hash(hasher);
261 }
262 None => 0u8.hash(hasher),
263 }
264}
265
266fn frontmatter_diff(a: &Concept, b: &Concept) -> Option<FrontmatterChange> {
269 let ma = a.document.frontmatter.as_mapping();
270 let mb = b.document.frontmatter.as_mapping();
271 let keys_a: BTreeSet<String> = ma.keys().map(String::from).collect();
272 let keys_b: BTreeSet<String> = mb.keys().map(String::from).collect();
273
274 let added: Vec<String> = keys_b.difference(&keys_a).cloned().collect();
275 let removed: Vec<String> = keys_a.difference(&keys_b).cloned().collect();
276
277 let mut changed: Vec<(String, String, String)> = Vec::new();
278 for key in keys_a.intersection(&keys_b) {
279 let va = ma.get(key).expect("key present in a");
280 let vb = mb.get(key).expect("key present in b");
281 if va != vb {
282 changed.push((key.clone(), scalar(va), scalar(vb)));
283 }
284 }
285
286 if added.is_empty() && removed.is_empty() && changed.is_empty() {
287 None
288 } else {
289 Some(FrontmatterChange {
290 id: a.id.clone(),
291 added,
292 removed,
293 changed,
294 })
295 }
296}
297
298fn trust_diff(a: &Concept, b: &Concept) -> Option<TrustChange> {
301 let tier = (a.trust_tier(), b.trust_tier());
302 let status = (a.status(), b.status());
303 let tier = (tier.0 != tier.1).then_some(tier);
304 let status = (status.0 != status.1).then_some(status);
305 if tier.is_none() && status.is_none() {
306 None
307 } else {
308 Some(TrustChange {
309 id: a.id.clone(),
310 tier,
311 status,
312 })
313 }
314}
315
316fn scalar(value: &Value) -> String {
322 value
323 .to_yaml_string()
324 .split_whitespace()
325 .collect::<Vec<_>>()
326 .join(" ")
327}
328
329impl std::fmt::Display for BundleDiff {
330 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331 if self.is_empty() {
332 return writeln!(f, "no changes");
333 }
334
335 if !self.added.is_empty() {
336 writeln!(f, "added ({}):", self.added.len())?;
337 for id in &self.added {
338 writeln!(f, " + {id}")?;
339 }
340 }
341 if !self.removed.is_empty() {
342 writeln!(f, "removed ({}):", self.removed.len())?;
343 for id in &self.removed {
344 writeln!(f, " - {id}")?;
345 }
346 }
347 if !self.renamed.is_empty() {
348 writeln!(f, "renamed ({}):", self.renamed.len())?;
349 for r in &self.renamed {
350 writeln!(f, " ~ {} -> {}", r.from, r.to)?;
351 }
352 }
353 if !self.content.is_empty() {
354 writeln!(f, "content ({}):", self.content.len())?;
355 for id in &self.content {
356 writeln!(f, " ~ {id} (body)")?;
357 }
358 }
359 if !self.frontmatter.is_empty() {
360 writeln!(f, "frontmatter ({}):", self.frontmatter.len())?;
361 for fc in &self.frontmatter {
362 writeln!(f, " {}:", fc.id)?;
363 for k in &fc.added {
364 writeln!(f, " + {k}")?;
365 }
366 for k in &fc.removed {
367 writeln!(f, " - {k}")?;
368 }
369 for (k, old, new) in &fc.changed {
370 writeln!(f, " ~ {k}: {old} -> {new}")?;
371 }
372 }
373 }
374 if !self.trust.is_empty() {
375 writeln!(f, "trust ({}):", self.trust.len())?;
376 for tc in &self.trust {
377 write!(f, " {}:", tc.id)?;
378 if let Some((from, to)) = &tc.tier {
379 write!(f, " tier {from} -> {to}")?;
380 }
381 if let Some((from, to)) = &tc.status {
382 write!(f, " status {from} -> {to}")?;
383 }
384 writeln!(f)?;
385 }
386 }
387 if !self.added_links.is_empty() {
388 writeln!(f, "added links ({}):", self.added_links.len())?;
389 for (source, target) in &self.added_links {
390 writeln!(f, " + {source} -> {target}")?;
391 }
392 }
393 if !self.removed_links.is_empty() {
394 writeln!(f, "removed links ({}):", self.removed_links.len())?;
395 for (source, target) in &self.removed_links {
396 writeln!(f, " - {source} -> {target}")?;
397 }
398 }
399 if !self.mended_links.is_empty() {
400 writeln!(f, "mended links ({}):", self.mended_links.len())?;
401 for (id, target) in &self.mended_links {
402 writeln!(f, " + {id} -> {target}")?;
403 }
404 }
405 if !self.broken_links.is_empty() {
406 writeln!(f, "broken links ({}):", self.broken_links.len())?;
407 for (id, target) in &self.broken_links {
408 writeln!(f, " - {id} -> {target}")?;
409 }
410 }
411 Ok(())
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use crate::yaml::Value;
419
420 #[test]
421 fn hash_option_distinguishes_none_and_empty() {
422 let mut with_none = DefaultHasher::new();
423 hash_option::<str>(&mut with_none, None);
424 let mut with_empty = DefaultHasher::new();
425 hash_option(&mut with_empty, Some(""));
426 assert_ne!(with_none.finish(), with_empty.finish());
427
428 let mut with_value = DefaultHasher::new();
429 hash_option(&mut with_value, Some("revenue"));
430 assert_ne!(with_empty.finish(), with_value.finish());
431 }
432
433 #[test]
434 fn scalar_trims_trailing_newline() {
435 assert_eq!(scalar(&Value::String("x".into())), "x");
436 assert_eq!(scalar(&Value::Int(7)), "7");
437 }
438}