use crate::bundle::{Bundle, Concept};
use crate::concept_id::ConceptId;
use crate::trust::{Status, TrustTier};
use crate::yaml::Value;
use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeSet, HashMap};
use std::hash::{Hash, Hasher};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Rename {
pub from: ConceptId,
pub to: ConceptId,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FrontmatterChange {
pub id: ConceptId,
pub added: Vec<String>,
pub removed: Vec<String>,
pub changed: Vec<(String, String, String)>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TrustChange {
pub id: ConceptId,
pub tier: Option<(TrustTier, TrustTier)>,
pub status: Option<(Status, Status)>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct BundleDiff {
pub added: Vec<ConceptId>,
pub removed: Vec<ConceptId>,
pub renamed: Vec<Rename>,
pub frontmatter: Vec<FrontmatterChange>,
pub trust: Vec<TrustChange>,
pub mended_links: Vec<(ConceptId, String)>,
pub broken_links: Vec<(ConceptId, String)>,
}
impl BundleDiff {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.added.is_empty()
&& self.removed.is_empty()
&& self.renamed.is_empty()
&& self.frontmatter.is_empty()
&& self.trust.is_empty()
&& self.mended_links.is_empty()
&& self.broken_links.is_empty()
}
}
#[must_use]
pub fn bundle_diff(a: &Bundle, b: &Bundle) -> BundleDiff {
let a_ids: BTreeSet<ConceptId> = a.concepts().iter().map(|c| c.id.clone()).collect();
let b_ids: BTreeSet<ConceptId> = b.concepts().iter().map(|c| c.id.clone()).collect();
let removed: Vec<ConceptId> = a_ids.difference(&b_ids).cloned().collect();
let added: Vec<ConceptId> = b_ids.difference(&a_ids).cloned().collect();
let mut removed_by_hash: HashMap<u64, Vec<ConceptId>> = HashMap::new();
for id in &removed {
if let Some(c) = a.get(id) {
removed_by_hash
.entry(content_hash(c))
.or_default()
.push(id.clone());
}
}
let mut consumed_removed: BTreeSet<ConceptId> = BTreeSet::new();
let mut renamed: Vec<Rename> = Vec::new();
for id in &added {
let Some(c) = b.get(id) else { continue };
let h = content_hash(c);
let Some(candidates) = removed_by_hash.get(&h) else {
continue;
};
if let Some(from) = candidates
.iter()
.find(|cand| !consumed_removed.contains(*cand))
{
renamed.push(Rename {
from: from.clone(),
to: id.clone(),
});
consumed_removed.insert(from.clone());
}
}
let to_ids: BTreeSet<&ConceptId> = renamed.iter().map(|r| &r.to).collect();
let added: Vec<ConceptId> = added
.iter()
.filter(|id| !to_ids.contains(id))
.cloned()
.collect();
let removed: Vec<ConceptId> = removed
.iter()
.filter(|id| !consumed_removed.contains(id))
.cloned()
.collect();
let mut frontmatter = Vec::new();
let mut trust = Vec::new();
for id in a_ids.intersection(&b_ids) {
let (Some(ca), Some(cb)) = (a.get(id), b.get(id)) else {
continue;
};
if let Some(fc) = frontmatter_diff(ca, cb) {
frontmatter.push(fc);
}
if let Some(tc) = trust_diff(ca, cb) {
trust.push(tc);
}
}
let a_broken: BTreeSet<(ConceptId, String)> = a.broken_links().into_iter().collect();
let b_broken: BTreeSet<(ConceptId, String)> = b.broken_links().into_iter().collect();
let mended_links: Vec<(ConceptId, String)> = a_broken.difference(&b_broken).cloned().collect();
let broken_links: Vec<(ConceptId, String)> = b_broken.difference(&a_broken).cloned().collect();
BundleDiff {
added,
removed,
renamed,
frontmatter,
trust,
mended_links,
broken_links,
}
}
fn content_hash(concept: &Concept) -> u64 {
let mut hasher = DefaultHasher::new();
concept.document.body.hash(&mut hasher);
hash_option(&mut hasher, concept.type_().as_deref());
hash_option(&mut hasher, concept.document.frontmatter.title().as_deref());
hash_option(
&mut hasher,
concept.document.frontmatter.description().as_deref(),
);
hasher.finish()
}
fn hash_option<T: Hash + ?Sized>(hasher: &mut DefaultHasher, opt: Option<&T>) {
match opt {
Some(value) => {
1u8.hash(hasher);
value.hash(hasher);
}
None => 0u8.hash(hasher),
}
}
fn frontmatter_diff(a: &Concept, b: &Concept) -> Option<FrontmatterChange> {
let ma = a.document.frontmatter.as_mapping();
let mb = b.document.frontmatter.as_mapping();
let keys_a: BTreeSet<String> = ma.keys().map(String::from).collect();
let keys_b: BTreeSet<String> = mb.keys().map(String::from).collect();
let added: Vec<String> = keys_b.difference(&keys_a).cloned().collect();
let removed: Vec<String> = keys_a.difference(&keys_b).cloned().collect();
let mut changed: Vec<(String, String, String)> = Vec::new();
for key in keys_a.intersection(&keys_b) {
let va = ma.get(key).expect("key present in a");
let vb = mb.get(key).expect("key present in b");
if va != vb {
changed.push((key.clone(), scalar(va), scalar(vb)));
}
}
if added.is_empty() && removed.is_empty() && changed.is_empty() {
None
} else {
Some(FrontmatterChange {
id: a.id.clone(),
added,
removed,
changed,
})
}
}
fn trust_diff(a: &Concept, b: &Concept) -> Option<TrustChange> {
let tier = (a.trust_tier(), b.trust_tier());
let status = (a.status(), b.status());
let tier = (tier.0 != tier.1).then_some(tier);
let status = (status.0 != status.1).then_some(status);
if tier.is_none() && status.is_none() {
None
} else {
Some(TrustChange {
id: a.id.clone(),
tier,
status,
})
}
}
fn scalar(value: &Value) -> String {
value
.to_yaml_string()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
impl std::fmt::Display for BundleDiff {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.is_empty() {
return writeln!(f, "no changes");
}
if !self.added.is_empty() {
writeln!(f, "added ({}):", self.added.len())?;
for id in &self.added {
writeln!(f, " + {id}")?;
}
}
if !self.removed.is_empty() {
writeln!(f, "removed ({}):", self.removed.len())?;
for id in &self.removed {
writeln!(f, " - {id}")?;
}
}
if !self.renamed.is_empty() {
writeln!(f, "renamed ({}):", self.renamed.len())?;
for r in &self.renamed {
writeln!(f, " ~ {} -> {}", r.from, r.to)?;
}
}
if !self.frontmatter.is_empty() {
writeln!(f, "frontmatter ({}):", self.frontmatter.len())?;
for fc in &self.frontmatter {
writeln!(f, " {}:", fc.id)?;
for k in &fc.added {
writeln!(f, " + {k}")?;
}
for k in &fc.removed {
writeln!(f, " - {k}")?;
}
for (k, old, new) in &fc.changed {
writeln!(f, " ~ {k}: {old} -> {new}")?;
}
}
}
if !self.trust.is_empty() {
writeln!(f, "trust ({}):", self.trust.len())?;
for tc in &self.trust {
write!(f, " {}:", tc.id)?;
if let Some((from, to)) = &tc.tier {
write!(f, " tier {from} -> {to}")?;
}
if let Some((from, to)) = &tc.status {
write!(f, " status {from} -> {to}")?;
}
writeln!(f)?;
}
}
if !self.mended_links.is_empty() {
writeln!(f, "mended links ({}):", self.mended_links.len())?;
for (id, target) in &self.mended_links {
writeln!(f, " + {id} -> {target}")?;
}
}
if !self.broken_links.is_empty() {
writeln!(f, "broken links ({}):", self.broken_links.len())?;
for (id, target) in &self.broken_links {
writeln!(f, " - {id} -> {target}")?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::yaml::Value;
#[test]
fn hash_option_distinguishes_none_and_empty() {
let mut with_none = DefaultHasher::new();
hash_option::<str>(&mut with_none, None);
let mut with_empty = DefaultHasher::new();
hash_option(&mut with_empty, Some(""));
assert_ne!(with_none.finish(), with_empty.finish());
let mut with_value = DefaultHasher::new();
hash_option(&mut with_value, Some("revenue"));
assert_ne!(with_empty.finish(), with_value.finish());
}
#[test]
fn scalar_trims_trailing_newline() {
assert_eq!(scalar(&Value::String("x".into())), "x");
assert_eq!(scalar(&Value::Int(7)), "7");
}
}