mdbook_section_validator/
lib.rs

1mod link_formatter;
2pub mod issue_validator;
3
4use regex::{Regex, Captures};
5
6use mdbook::book::{Book, BookItem};
7use mdbook::errors::Error;
8use mdbook::preprocess::{Preprocessor, PreprocessorContext};
9use url::Url;
10use crate::link_formatter::LinkFormatter;
11use crate::issue_validator::{IssueValidator, issue_from_url, ValidationResult};
12
13pub struct ValidatorProcessorOptions {
14    hide_invalid: bool,
15    invalid_message: String
16}
17
18#[derive(Debug, Eq, PartialEq)]
19enum ValidationSection {
20    NonValidationSection(String),
21    ValidationSection(Vec<Url>, String),
22}
23
24pub struct ValidatorProcessor {
25    pub validator: Box<dyn IssueValidator>
26}
27
28impl Preprocessor for ValidatorProcessor {
29    fn name(&self) -> &str { "section-validator" }
30
31    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
32        let options = self.build_options(ctx);
33
34        book.for_each_mut(|item| {
35            if let BookItem::Chapter(chapter) = item {
36                chapter.content =
37                    self.process_chapter(&chapter.content, &options)
38            }
39        });
40        Ok(book)
41    }
42
43    fn supports_renderer(&self, renderer: &str) -> bool { renderer == "html" }
44}
45
46impl ValidatorProcessor {
47    fn build_options(&self, ctx: &PreprocessorContext) -> ValidatorProcessorOptions {
48        let mut options = ValidatorProcessorOptions {
49            hide_invalid: true,
50            invalid_message: "🚨 Warning, this content is out of date and is included for historical reasons. 🚨".to_string()
51        };
52
53        if let Some(config) = ctx.config.get_preprocessor("section-validator") {
54            if let Some(toml::value::Value::Boolean(hide_closed)) = config.get("hide_invalid") {
55                options.hide_invalid = *hide_closed;
56            }
57            if let Some(toml::value::Value::String(message)) = config.get("invalid_message") {
58                options.invalid_message = message.to_string();
59            }
60        }
61
62        options
63    }
64
65    fn process_chapter(
66        &self,
67        raw_content: &str,
68        options: &ValidatorProcessorOptions
69    ) -> String {
70        let mut content = String::new();
71        for section in ValidatorProcessor::validation_sections(raw_content) {
72            match section {
73                ValidationSection::NonValidationSection(text) => {
74                    content.push_str(&text);
75                },
76                ValidationSection::ValidationSection(links, text) => {
77                    let validation_result = self.is_section_valid(&links);
78                    if options.hide_invalid && validation_result == ValidationResult::NoLongerValid {
79                        continue;
80                    }
81                    content.push_str(&*format!("<div class=\"validated-content\" links=\"{}\">\n\n", ValidatorProcessor::links_joined(&links)));
82                    if validation_result == ValidationResult::NoLongerValid {
83                        content.push_str(&*options.invalid_message);
84                    } else {
85                        let mut is_or_are = "is";
86                        if links.len() != 1 {
87                            is_or_are = "are";
88                        }
89                        content.push_str(&*format!("⚠️ This is only valid while {} {} open", LinkFormatter::markdown_many(&links), is_or_are));
90                    }
91                    content.push_str(&text);
92                    content.push_str("\n</div>");
93                }
94            }
95        }
96        content
97    }
98
99    fn validation_sections(raw_content: &str) -> Vec<ValidationSection> {
100        let section_regex = Regex::new(r"(?m)^!!!(.+)$(?s)(.+?)(?-s)^!!!$").unwrap();
101
102        let captures: Vec<Captures> = section_regex.captures_iter(&raw_content).collect();
103        let mut sections: Vec<ValidationSection> = Vec::new();
104
105        if captures.is_empty() {
106            return vec!(ValidationSection::NonValidationSection(raw_content.to_string()));
107        }
108
109        let mut last_endpoint: usize = 0;
110        for capture in captures {
111            let mat = capture.get(0).unwrap();
112            let start = mat.start();
113
114            if start - last_endpoint != 0 {
115                sections.push(ValidationSection::NonValidationSection(raw_content[last_endpoint..start].to_string()));
116            }
117
118            last_endpoint = mat.end();
119
120            sections.push(ValidationSection::ValidationSection(
121                ValidatorProcessor::links_to_check(capture.get(1).unwrap().as_str()),
122                capture.get(2).unwrap().as_str().to_string()
123            ))
124        }
125
126
127        if raw_content.len() > last_endpoint {
128            sections.push(ValidationSection::NonValidationSection(raw_content[last_endpoint..raw_content.len()].to_string()));
129        }
130
131        return sections;
132    }
133
134    fn links_to_check(links: &str) -> Vec<Url> {
135        links.split(",").map(|text| Url::parse(text).unwrap()).collect()
136    }
137
138    fn links_joined(links: &Vec<Url>) -> String {
139        let links_strs: Vec<String> = links.into_iter().map(|url| url.as_str().to_string()).collect();
140        links_strs.join(",")
141    }
142
143    fn is_section_valid(&self, links: &Vec<Url>) ->ValidationResult {
144        links.into_iter()
145            .map(|u| issue_from_url(u))
146            .map(|issue| self.validator.validate(&issue))
147            .reduce(|a, b|
148            if a == ValidationResult::StillValid && b == ValidationResult::StillValid { a }
149            else { ValidationResult::NoLongerValid }
150        ).unwrap()
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::IssueValidator;
157    use super::ValidatorProcessor;
158    use super::ValidationSection;
159    use url::Url;
160    use crate::issue_validator::{Issue, ValidationResult};
161    use crate::ValidatorProcessorOptions;
162
163    #[test]
164    fn test_validation_sections_single_link() {
165        let content = "whatever
166!!!https://github.com/example/example/issues/1
167
168some content to be conditionally included.
169
170!!!
171
172other content";
173
174        let sections: Vec<ValidationSection> = ValidatorProcessor::validation_sections(&content);
175
176        assert_eq!(sections.len(), 3);
177        assert_eq!(sections.get(0).unwrap(), &ValidationSection::NonValidationSection("whatever\n".to_string()));
178        assert_eq!(
179            sections.get(1).unwrap(),
180            &ValidationSection::ValidationSection(
181                vec![Url::parse("https://github.com/example/example/issues/1").unwrap()],
182                "\n\nsome content to be conditionally included.\n\n".to_string()
183            )
184        );
185        assert_eq!(sections.get(2).unwrap(), &ValidationSection::NonValidationSection("\n\nother content".to_string()));
186    }
187
188    #[test]
189    fn test_validation_sections_multiple() {
190        let content = "!!!https://github.com/example/example/issues/1
191
192some content to be conditionally included.
193
194!!!
195
196other content
197
198!!!https://github.com/example/example/issues/1,https://github.com/example/example/issues/2
199
200other content to be conditionally included.
201
202!!!";
203
204        let sections: Vec<ValidationSection> = ValidatorProcessor::validation_sections(&content);
205
206        assert_eq!(sections.len(), 3);
207        assert_eq!(
208            sections.get(0).unwrap(),
209            &ValidationSection::ValidationSection(
210                vec![Url::parse("https://github.com/example/example/issues/1").unwrap()],
211                "\n\nsome content to be conditionally included.\n\n".to_string()
212            )
213        );
214        assert_eq!(sections.get(1).unwrap(), &ValidationSection::NonValidationSection("\n\nother content\n\n".to_string()));
215        assert_eq!(
216            sections.get(2).unwrap(),
217            &ValidationSection::ValidationSection(
218                vec![
219                    Url::parse("https://github.com/example/example/issues/1").unwrap(),
220                    Url::parse("https://github.com/example/example/issues/2").unwrap()
221                ],
222                "\n\nother content to be conditionally included.\n\n".to_string()
223            )
224        );
225    }
226
227    #[test]
228    fn test_content_all_valid_still_included_with_warning() {
229        let content = "whatever
230!!!https://github.com/example/example/issues/1
231
232some content to be conditionally included.
233
234!!!
235
236other content
237        ";
238
239        let validator = FakeIssueValidator { validate_behavior: ValidateBehavior::AllValid };
240
241        let processor = ValidatorProcessor { validator: Box::new(validator) };
242
243        let options = ValidatorProcessorOptions { hide_invalid: true, invalid_message: "".to_string() };
244
245        let received_chapter = processor.process_chapter(
246            content,
247            &options
248        );
249
250        let expected_chapter = "whatever
251<div class=\"validated-content\" links=\"https://github.com/example/example/issues/1\">
252
253⚠️ This is only valid while [example/example#1](https://github.com/example/example/issues/1) is open ⚠️
254
255some content to be conditionally included.
256
257
258</div>
259
260other content
261        ";
262        assert_eq!(received_chapter, expected_chapter.to_string());
263    }
264
265    #[test]
266    fn tset_content_none_valid_content_not_included() {
267        let content = "whatever
268!!!https://github.com/example/example/issues/1
269
270some content to be conditionally included.
271
272!!!
273
274other content
275        ";
276
277        let validator = FakeIssueValidator { validate_behavior: ValidateBehavior::NoneValid };
278
279        let processor = ValidatorProcessor { validator: Box::new(validator) };
280
281        let received_chapter = processor.process_chapter(
282            content,
283            &ValidatorProcessorOptions {
284                hide_invalid: true,
285                invalid_message: "🚨 Warning, this content is out of date and is included for historical reasons. 🚨".to_string()
286            }
287        );
288
289        let expected_chapter = "whatever
290
291
292other content
293        ";
294        assert_eq!(received_chapter, expected_chapter.to_string());
295    }
296
297    #[test]
298    fn test_content_none_valid_content_still_included_with_warning() {
299        let content = "whatever
300!!!https://github.com/example/example/issues/1
301
302some content to be conditionally included.
303
304!!!
305
306other content
307        ";
308
309        let validator = FakeIssueValidator { validate_behavior: ValidateBehavior::NoneValid };
310
311        let processor = ValidatorProcessor { validator: Box::new(validator) };
312
313        let received_chapter = processor.process_chapter(
314            content,
315            &ValidatorProcessorOptions {
316                hide_invalid: false,
317                invalid_message: "🚨 Warning, this content is out of date and is included for historical reasons. 🚨".to_string()
318            }
319        );
320
321        let expected_chapter = "whatever
322<div class=\"validated-content\" links=\"https://github.com/example/example/issues/1\">
323
324🚨 Warning, this content is out of date and is included for historical reasons. 🚨
325
326some content to be conditionally included.
327
328
329</div>
330
331other content
332        ";
333        assert_eq!(received_chapter, expected_chapter.to_string());
334    }
335
336    enum ValidateBehavior {
337        AllValid,
338        NoneValid
339    }
340
341    struct FakeIssueValidator {
342        validate_behavior: ValidateBehavior
343    }
344
345    impl IssueValidator for FakeIssueValidator {
346        fn validate(&self, _link: &Issue) -> ValidationResult {
347            match &self.validate_behavior {
348                ValidateBehavior::NoneValid => ValidationResult::NoLongerValid,
349                ValidateBehavior::AllValid => ValidationResult::StillValid
350            }
351        }
352    }
353}