markdown_includes/
lib.rs

1//! A simple way of including other files, rust doc and table of content in a markdown file.
2//!
3//! For a repo's README file, you'll create a _README.tpl.md_ which you can edit like a normal
4//! markdown file, but with the added support for fenced includes which are TOML fences with
5//! an extra name containing the configuration of the include.
6//!
7//! ## rustdoc
8//!
9//! The rustdoc part of this crate is based on modified code from [cargo-rdme](https://crates.io/crates/cargo-rdme).
10//! The same limitations apply, especially for the syntax of [intralinks](https://github.com/orium/cargo-rdme#intralinks)
11//!
12//! ## Example
13//!
14//! _src/README.tpl.md_:
15//! > My title<br>
16//! > <br>
17//! > Include a table of content:<br>
18//! > &#96;&#96;&#96;toml toc<br>
19//! > header = "# Table of contents"<br>
20//! > &#96;&#96;&#96;<br>
21//! > <br>
22//! > Extracted from lib.rs' rust doc:<br>
23//! > <br>
24//! > &#96;&#96;&#96;toml rustdoc<br>
25//! > source = "lib.rs"<br>
26//! > &#96;&#96;&#96;<br>
27//!
28//!
29//! To generate a _README.md_ file you add a test:
30//!
31//! ```rust
32//! #[test]
33//! fn update_readme() {
34//!     markdown_includes::update("src/README.tpl.md", "README.md").unwrap();
35//! }
36//! ```
37//!
38//! This test will update the README file if necessary, but if running
39//! in a CI pipeline (the CI environment variable is set),
40//! it will fail if the _README.md_ needs updating.
41//!
42#[cfg(test)]
43mod tests;
44
45mod fence;
46mod rustdoc_parse;
47
48use fs_err as fs;
49use std::{
50    env,
51    iter::zip,
52    path::{Path, PathBuf},
53};
54
55use anyhow::{bail, Context, Result};
56use fence::find_fences;
57
58pub fn process_includes_document(document: &mut String, template_dir: &Path) -> Result<()> {
59    let mut fences = find_fences(&document, template_dir)?;
60    fences.sort_by_key(|f| f.priority());
61
62    for fence in fences {
63        fence.run(document)?;
64    }
65    Ok(())
66}
67
68pub fn update<P1: AsRef<Path>, P2: AsRef<Path>>(
69    template_file: P1,
70    destination_file: P2,
71) -> Result<()> {
72    let is_ci = env::var("CI").map(|_| true).unwrap_or(false);
73
74    let template_file = template_file.as_ref();
75
76    let template_dir = template_file
77        .parent()
78        .map(|p| p.to_path_buf())
79        .unwrap_or_else(|| PathBuf::from(""));
80    let mut generated_doc = fs::read_to_string(&template_file)
81        .context(format!(
82            "current working directory: {:?}",
83            env::current_dir()
84        ))
85        .context(format!("failed to read template"))?;
86    process_includes_document(&mut generated_doc, &template_dir)?;
87
88    let file = template_file
89        .components()
90        .map(|c| c.as_os_str().to_string_lossy())
91        .collect::<Vec<_>>()
92        .join("/");
93    let generated_doc = format!(
94        r#"<!-- 
95Please don't edit. This document has been generated from {file:?}
96--> 
97{generated_doc}"#
98    );
99
100    let dest_path = destination_file.as_ref();
101
102    let current_doc = if dest_path.exists() {
103        fs::read_to_string(&dest_path)?
104    } else {
105        "".to_string()
106    };
107
108    if let Some(diff_str) = diff(&generated_doc, &current_doc) {
109        if is_ci {
110            bail!(
111                "The markdown document {dest_path:?} is out of sync with {template_file:?}. 
112            Please re-run the tests and commit the updated file. 
113            This message is generated because the test is run on CI (the CI environment variable is set).\n{diff_str}"
114            );
115        } else {
116            fs::write(&dest_path, generated_doc.as_bytes())?;
117        }
118    }
119
120    Ok(())
121}
122
123fn diff(doc1: &str, doc2: &str) -> Option<String> {
124    if zip(doc1.lines(), doc2.lines()).any(|(l1, l2)| l1.trim() != l2.trim()) {
125        Some(
126            zip(doc1.lines(), doc2.lines())
127                .filter(|(l1, l2)| l1.trim() != l2.trim())
128                .map(|(l1, l2)| format!("> {}\n< {}\n", l1.trim(), l2.trim()))
129                .take(5)
130                .collect::<Vec<_>>()
131                .join(", "),
132        )
133    } else {
134        None
135    }
136}
137
138#[test]
139fn update_readme() {
140    update(
141        &Path::new("src").join("README.tpl.md"),
142        Path::new("README.md"),
143    )
144    .unwrap();
145}