1use crate::{AnchorMapping, AnchorsMap, TocEntry};
2use chrono::{DateTime, Utc};
3use std::collections::HashMap;
4
5#[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#[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 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 assert!(!mappings.is_empty(), "should detect moved sections");
123 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}