Skip to main content

okf_core/
diff.rs

1//! Bundle-level diff: an OKF-semantics diff between two [`Bundle`]s.
2//!
3//! [`bundle_diff`] reports concepts added, removed, renamed (detected by
4//! content hash), same-id body changes, frontmatter key changes, trust-tier/status
5//! changes, and cross-link changes between two snapshots. It is a semantic diff,
6//! not a raw file diff: frontmatter values are compared as parsed values, while
7//! body content is compared as text.
8//!
9//! The rename heuristic hashes a concept's body together with its `type`,
10//! `title`, and `description` (the identifying frontmatter fields that do not
11//! depend on the concept's id). A removed and an added concept sharing that
12//! hash are reported as a rename rather than as separate add and remove. The
13//! heuristic is best-effort: a move that also edits the body, or one whose body
14//! contains self-referential links, will not be detected.
15
16use 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/// A rename detected by matching content hash between a removed and an added
25/// concept.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct Rename {
28    /// The concept's id in the first bundle.
29    pub from: ConceptId,
30    /// The concept's id in the second bundle.
31    pub to: ConceptId,
32}
33
34/// Frontmatter key changes for a concept present in both bundles.
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct FrontmatterChange {
37    /// The concept the change applies to.
38    pub id: ConceptId,
39    /// Keys present in the second bundle but not the first.
40    pub added: Vec<String>,
41    /// Keys present in the first bundle but not the second.
42    pub removed: Vec<String>,
43    /// Keys present in both but with a different value, as
44    /// `(key, old display form, new display form)`.
45    pub changed: Vec<(String, String, String)>,
46}
47
48/// A trust tier or status change for a concept present in both bundles.
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct TrustChange {
51    /// The concept the change applies to.
52    pub id: ConceptId,
53    /// The trust tier transition, when it changed.
54    pub tier: Option<(TrustTier, TrustTier)>,
55    /// The status transition, when it changed.
56    pub status: Option<(Status, Status)>,
57}
58
59/// A bundle-level diff.
60///
61/// Build one with [`bundle_diff`] and either read its fields directly or print
62/// it with its [`Display`](std::fmt::Display) implementation, which is what
63/// `okf diff` uses.
64#[derive(Clone, Debug, Default, PartialEq, Eq)]
65pub struct BundleDiff {
66    /// Concepts present in the second bundle and absent from the first, after
67    /// subtracting renames.
68    pub added: Vec<ConceptId>,
69    /// Concepts present in the first bundle and absent from the second, after
70    /// subtracting renames.
71    pub removed: Vec<ConceptId>,
72    /// Concepts whose id changed but whose content hash did not.
73    pub renamed: Vec<Rename>,
74    /// Concepts present in both bundles whose body content changed.
75    pub content: Vec<ConceptId>,
76    /// Per-concept frontmatter key changes, for ids present in both bundles.
77    pub frontmatter: Vec<FrontmatterChange>,
78    /// Per-concept trust-tier/status changes, for ids present in both bundles.
79    pub trust: Vec<TrustChange>,
80    /// Resolved internal links present in the second bundle but not the first,
81    /// as `(source id, target id)` pairs. Only links to existing concepts are
82    /// included; broken-link state transitions are reported separately below.
83    pub added_links: Vec<(ConceptId, ConceptId)>,
84    /// Resolved internal links present in the first bundle but not the second,
85    /// as `(source id, target id)` pairs. Only links to existing concepts are
86    /// included; broken-link state transitions are reported separately below.
87    pub removed_links: Vec<(ConceptId, ConceptId)>,
88    /// Links broken in the first bundle that are resolved in the second, as
89    /// `(source id, raw target as written)`.
90    pub mended_links: Vec<(ConceptId, String)>,
91    /// Links resolved in the first bundle that are broken in the second, as
92    /// `(source id, raw target as written)`.
93    pub broken_links: Vec<(ConceptId, String)>,
94}
95
96impl BundleDiff {
97    /// `true` when the two bundles are semantically identical.
98    #[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/// Computes the OKF-semantics diff between two bundles.
114///
115/// Both bundles are loaded fully before this call; the diff itself does no
116/// filesystem access.
117#[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    // Match renames by content hash among the removed and added concepts. A
126    // removed concept whose hash matches an added concept's is reported as a
127    // rename and dropped from the add/remove lists.
128    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    // Per-concept body, frontmatter, and trust changes for ids present in both
170    // bundles.
171    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    // Compare valid graph edges by resolved target. This catches additions,
190    // removals, and retargeting separately from broken-link state transitions.
191    // A rename of the source concept is not tracked specially here: the source
192    // id differs, so a link from a renamed concept is a separate edge.
193    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    // Broken vs mended state remains keyed by (source id, raw target as
200    // written), preserving the existing diagnostic detail and behavior.
201    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
220/// Returns the valid internal cross-link edges in a bundle.
221fn 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
235/// A best-effort content hash for a concept: the body plus the `type`,
236/// `title`, and `description` frontmatter fields.
237///
238/// These are the identifying fields that do not change when a concept is moved
239/// to a new id within the bundle, so equal hashes are a strong signal of a
240/// rename. Fields such as `generated` or `verified` are deliberately excluded:
241/// a producer may refresh those without touching the content.
242fn 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
254/// Hashes an optional value with a presence marker, so `None` and `Some("")`
255/// do not collide.
256fn 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
266/// Computes the frontmatter key changes between two concepts sharing an id.
267/// Returns `None` when nothing changed.
268fn 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
298/// Computes the trust tier and status changes between two concepts sharing an
299/// id. Returns `None` when neither changed.
300fn 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
316/// A scalar's text in display form: the YAML value with its trailing newline
317/// trimmed and any internal line breaks collapsed to single spaces. Keeping
318/// each changed value on one line preserves the `key: old -> new` layout when
319/// a value is a nested mapping or sequence, which `to_yaml_string` would
320/// otherwise emit across several lines.
321fn 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}