blz_core/
mapping.rs

1use crate::{AnchorMapping, AnchorsMap, TocEntry};
2use chrono::{DateTime, Utc};
3use std::collections::HashMap;
4
5/// Compute anchor remapping between two TOC trees.
6///
7/// Returns mappings for anchors whose line ranges changed between versions.
8#[must_use]
9pub fn compute_anchor_mappings(old: &[TocEntry], new: &[TocEntry]) -> Vec<AnchorMapping> {
10    let mut old_map = HashMap::<String, (String, Vec<String>)>::new();
11    collect_anchor_map(&mut old_map, old);
12
13    let mut mappings = Vec::new();
14    walk_new_list(&mut mappings, &old_map, new);
15    mappings
16}
17
18fn collect_anchor_map(map: &mut HashMap<String, (String, Vec<String>)>, list: &[TocEntry]) {
19    for e in list {
20        if let Some(a) = &e.anchor {
21            map.insert(a.clone(), (e.lines.clone(), e.heading_path.clone()));
22        }
23        if !e.children.is_empty() {
24            collect_anchor_map(map, &e.children);
25        }
26    }
27}
28
29fn walk_new_list(
30    mappings: &mut Vec<AnchorMapping>,
31    old_map: &HashMap<String, (String, Vec<String>)>,
32    list: &[TocEntry],
33) {
34    for e in list {
35        if let (Some(anchor), new_lines) = (e.anchor.as_ref(), &e.lines) {
36            if let Some((old_lines, path)) = old_map.get(anchor) {
37                if old_lines != new_lines {
38                    mappings.push(AnchorMapping {
39                        anchor: anchor.clone(),
40                        old_lines: old_lines.clone(),
41                        new_lines: new_lines.clone(),
42                        heading_path: path.clone(),
43                    });
44                }
45            }
46        }
47        if !e.children.is_empty() {
48            walk_new_list(mappings, old_map, &e.children);
49        }
50    }
51}
52
53/// Convenience to build an `AnchorsMap` with a timestamp.
54#[must_use]
55pub const fn build_anchors_map(mappings: Vec<AnchorMapping>, ts: DateTime<Utc>) -> AnchorsMap {
56    AnchorsMap {
57        updated_at: ts,
58        mappings,
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use crate::{MarkdownParser, ParseResult};
66
67    fn parse_toc(s: &str) -> ParseResult {
68        let mut p = MarkdownParser::new().expect("parser");
69        p.parse(s).expect("parse")
70    }
71
72    fn find_anchor<'a>(list: &'a [TocEntry], name: &str) -> Option<&'a str> {
73        for e in list {
74            if e.heading_path.last().map(std::string::String::as_str) == Some(name) {
75                if let Some(a) = e.anchor.as_deref() {
76                    return Some(a);
77                }
78            }
79            if let Some(a) = find_anchor(&e.children, name) {
80                return Some(a);
81            }
82        }
83        None
84    }
85
86    #[test]
87    fn compute_mappings_detects_moved_section() {
88        let v1 = r"
89# Title
90
91## A
92alpha
93
94## B
95bravo
96
97## C
98charlie
99";
100        let v2 = r"
101# Title
102
103## C
104charlie
105
106## A
107alpha
108
109## B
110bravo
111";
112        let r1 = parse_toc(v1);
113        let r2 = parse_toc(v2);
114
115        // Anchors should be stable across moves (same heading text)
116        let a1 = find_anchor(&r1.toc, "A").expect("anchor A v1");
117        let a2 = find_anchor(&r2.toc, "A").expect("anchor A v2");
118        assert_eq!(a1, a2, "anchor should be stable for A");
119
120        let mappings = compute_anchor_mappings(&r1.toc, &r2.toc);
121        // Expect at least one mapping (A or C moved)
122        assert!(!mappings.is_empty(), "should detect moved sections");
123        // Ensure mapping for A exists and old/new lines differ
124        let m_a = mappings
125            .iter()
126            .find(|m| m.anchor == a1)
127            .expect("mapping for A");
128        assert_ne!(m_a.old_lines, m_a.new_lines);
129    }
130}