1use crate::rule::{AstRule, RuleCategory, RuleMetadata};
6use crate::{
7 Document,
8 violation::{Severity, Violation},
9};
10use comrak::nodes::{AstNode, NodeValue};
11use std::collections::HashMap;
12
13type DocumentTitleList = [(String, Vec<(String, usize, usize)>)];
15
16pub struct MDBOOK004;
22
23impl AstRule for MDBOOK004 {
24 fn id(&self) -> &'static str {
25 "MDBOOK004"
26 }
27
28 fn name(&self) -> &'static str {
29 "no-duplicate-chapter-titles"
30 }
31
32 fn description(&self) -> &'static str {
33 "Chapter titles should be unique across the book"
34 }
35
36 fn metadata(&self) -> RuleMetadata {
37 RuleMetadata::stable(RuleCategory::MdBook).introduced_in("mdbook-lint v0.1.0")
38 }
39
40 fn check_ast<'a>(
41 &self,
42 document: &Document,
43 ast: &'a AstNode<'a>,
44 ) -> crate::error::Result<Vec<Violation>> {
45 let mut violations = Vec::new();
46 let mut title_positions = HashMap::new();
47
48 for node in ast.descendants() {
50 if let NodeValue::Heading(_heading) = &node.data.borrow().value
51 && let Some((line, column)) = document.node_position(node)
52 {
53 let title = document.node_text(node).trim().to_string();
54
55 if !title.is_empty() {
56 if let Some((prev_line, _)) = title_positions.get(&title) {
58 violations.push(self.create_violation(
59 format!(
60 "Duplicate chapter title '{title}' found (also at line {prev_line})"
61 ),
62 line,
63 column,
64 Severity::Error,
65 ));
66 } else {
67 title_positions.insert(title, (line, column));
68 }
69 }
70 }
71 }
72
73 Ok(violations)
74 }
75}
76
77impl MDBOOK004 {
78 pub fn extract_chapter_titles(
80 document: &Document,
81 ) -> crate::error::Result<Vec<(String, usize, usize)>> {
82 use comrak::Arena;
83
84 let arena = Arena::new();
85 let ast = document.parse_ast(&arena);
86 let mut titles = Vec::new();
87
88 for node in ast.descendants() {
89 if let NodeValue::Heading(_) = &node.data.borrow().value
90 && let Some((line, column)) = document.node_position(node)
91 {
92 let title = document.node_text(node).trim().to_string();
93 if !title.is_empty() {
94 titles.push((title, line, column));
95 }
96 }
97 }
98
99 Ok(titles)
100 }
101
102 pub fn check_cross_document_duplicates(
104 documents_with_titles: &DocumentTitleList,
105 ) -> Vec<(String, String, usize, usize, String)> {
106 let mut title_to_files = HashMap::new();
107 let mut duplicates = Vec::new();
108
109 for (file_path, titles) in documents_with_titles {
111 for (title, line, column) in titles {
112 title_to_files
113 .entry(title.clone())
114 .or_insert_with(Vec::new)
115 .push((file_path.clone(), *line, *column));
116 }
117 }
118
119 for (title, occurrences) in &title_to_files {
121 if occurrences.len() > 1 {
122 for (file_path, line, column) in occurrences {
123 let other_files: Vec<String> = title_to_files[title]
124 .iter()
125 .filter(|(f, _, _)| f != file_path)
126 .map(|(f, l, _)| format!("{f}:{l}"))
127 .collect();
128
129 if !other_files.is_empty() {
130 duplicates.push((
131 file_path.clone(),
132 title.clone(),
133 *line,
134 *column,
135 other_files.join(", "),
136 ));
137 }
138 }
139 }
140 }
141
142 duplicates
143 }
144
145 pub fn create_cross_document_violations(
147 &self,
148 duplicates: &[(String, String, usize, usize, String)],
149 ) -> Vec<(String, Violation)> {
150 duplicates
151 .iter()
152 .map(|(file_path, title, line, column, other_locations)| {
153 let violation = Violation {
154 rule_id: self.id().to_string(),
155 rule_name: self.name().to_string(),
156 message: format!(
157 "Duplicate chapter title '{title}' found in other files: {other_locations}"
158 ),
159 line: *line,
160 column: *column,
161 severity: Severity::Error,
162 };
163 (file_path.clone(), violation)
164 })
165 .collect()
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::test_helpers::*;
173
174 #[test]
175 fn test_mdbook004_no_duplicates() {
176 let content = MarkdownBuilder::new()
177 .heading(1, "Introduction")
178 .blank_line()
179 .paragraph("This is the introduction.")
180 .blank_line()
181 .heading(2, "Getting Started")
182 .blank_line()
183 .paragraph("How to get started.")
184 .blank_line()
185 .heading(2, "Advanced Topics")
186 .blank_line()
187 .paragraph("Advanced material.")
188 .build();
189
190 assert_no_violations(MDBOOK004, &content);
191 }
192
193 #[test]
194 fn test_mdbook004_within_document_duplicates() {
195 let content = MarkdownBuilder::new()
196 .heading(1, "Introduction")
197 .blank_line()
198 .paragraph("First introduction.")
199 .blank_line()
200 .heading(2, "Getting Started")
201 .blank_line()
202 .paragraph("How to get started.")
203 .blank_line()
204 .heading(1, "Introduction")
205 .blank_line()
206 .paragraph("Second introduction - duplicate!")
207 .build();
208
209 let violations = assert_violation_count(MDBOOK004, &content, 1);
210 assert_violation_contains_message(&violations, "Duplicate chapter title 'Introduction'");
211 assert_violation_contains_message(&violations, "also at line 1");
212 assert_violation_at_line(&violations, 9);
213 }
214
215 #[test]
216 fn test_mdbook004_case_sensitive() {
217 let content = MarkdownBuilder::new()
218 .heading(1, "Introduction")
219 .blank_line()
220 .heading(1, "introduction")
221 .blank_line()
222 .heading(1, "INTRODUCTION")
223 .build();
224
225 assert_no_violations(MDBOOK004, &content);
227 }
228
229 #[test]
230 fn test_mdbook004_different_heading_levels() {
231 let content = MarkdownBuilder::new()
232 .heading(1, "Setup")
233 .blank_line()
234 .heading(2, "Setup")
235 .blank_line()
236 .heading(3, "Setup")
237 .build();
238
239 let violations = assert_violation_count(MDBOOK004, &content, 2);
241 assert_violation_contains_message(&violations, "Duplicate chapter title 'Setup'");
242 }
243
244 #[test]
245 fn test_mdbook004_extract_titles() {
246 let content = MarkdownBuilder::new()
247 .heading(1, "Chapter One")
248 .blank_line()
249 .paragraph("Content.")
250 .blank_line()
251 .heading(2, "Section A")
252 .blank_line()
253 .heading(2, "Section B")
254 .build();
255
256 let document = create_document(&content);
257 let titles = MDBOOK004::extract_chapter_titles(&document).unwrap();
258
259 assert_eq!(titles.len(), 3);
260 assert_eq!(titles[0].0, "Chapter One");
261 assert_eq!(titles[1].0, "Section A");
262 assert_eq!(titles[2].0, "Section B");
263
264 assert_eq!(titles[0].1, 1); assert_eq!(titles[1].1, 5); assert_eq!(titles[2].1, 7); }
269
270 #[test]
271 fn test_mdbook004_cross_document_analysis() {
272 let documents = vec![
273 (
274 "chapter1.md".to_string(),
275 vec![
276 ("Introduction".to_string(), 1, 1),
277 ("Getting Started".to_string(), 5, 1),
278 ],
279 ),
280 (
281 "chapter2.md".to_string(),
282 vec![
283 ("Advanced Topics".to_string(), 1, 1),
284 ("Introduction".to_string(), 8, 1), ],
286 ),
287 (
288 "chapter3.md".to_string(),
289 vec![
290 ("Conclusion".to_string(), 1, 1),
291 ("Getting Started".to_string(), 3, 1), ],
293 ),
294 ];
295
296 let duplicates = MDBOOK004::check_cross_document_duplicates(&documents);
297
298 assert_eq!(duplicates.len(), 4);
300
301 let duplicate_titles: Vec<&String> =
303 duplicates.iter().map(|(_, title, _, _, _)| title).collect();
304 assert!(duplicate_titles.contains(&&"Introduction".to_string()));
305 assert!(duplicate_titles.contains(&&"Getting Started".to_string()));
306 }
307
308 #[test]
309 fn test_mdbook004_create_cross_document_violations() {
310 let rule = MDBOOK004;
311 let duplicates = vec![
312 (
313 "chapter1.md".to_string(),
314 "Introduction".to_string(),
315 1,
316 1,
317 "chapter2.md:5".to_string(),
318 ),
319 (
320 "chapter2.md".to_string(),
321 "Introduction".to_string(),
322 5,
323 1,
324 "chapter1.md:1".to_string(),
325 ),
326 ];
327
328 let violations = rule.create_cross_document_violations(&duplicates);
329
330 assert_eq!(violations.len(), 2);
331 assert_eq!(violations[0].0, "chapter1.md");
332 assert_eq!(violations[1].0, "chapter2.md");
333
334 assert!(
335 violations[0]
336 .1
337 .message
338 .contains("Duplicate chapter title 'Introduction'")
339 );
340 assert!(violations[0].1.message.contains("chapter2.md:5"));
341 assert!(violations[1].1.message.contains("chapter1.md:1"));
342 }
343
344 #[test]
345 fn test_mdbook004_empty_headings_ignored() {
346 let content = MarkdownBuilder::new()
347 .line("# ")
348 .blank_line()
349 .line("## ")
350 .blank_line()
351 .heading(1, "Real Title")
352 .build();
353
354 assert_no_violations(MDBOOK004, &content);
356 }
357
358 #[test]
359 fn test_mdbook004_whitespace_handling() {
360 let content = MarkdownBuilder::new()
361 .line("# Introduction ")
362 .blank_line()
363 .line("# Introduction")
364 .blank_line()
365 .line("# Introduction ")
366 .build();
367
368 let violations = assert_violation_count(MDBOOK004, &content, 2);
370 assert_violation_contains_message(&violations, "Duplicate chapter title 'Introduction'");
371 }
372}