Skip to main content

mdbook_tracey/
lib.rs

1//! mdbook preprocessor for [tracey](https://github.com/bearcove/tracey)
2//! requirement annotations.
3//!
4//! Tracey defines requirements in spec markdown with `r[req.id]` markers.
5//! mdbook renders those as raw text. This preprocessor turns each marker into
6//! a styled anchor block (so you can link to `#r-req.id`), and — when pointed
7//! at a `.config/tracey/config.styx` — scans the source tree at preprocess
8//! time to decorate each anchor with impl/verify badges. Hover a badge to see
9//! where the refs live; click through to GitHub.
10
11mod config;
12pub mod coverage;
13pub mod marker;
14pub mod render;
15
16use std::fs;
17
18use anyhow::{Context, Result, anyhow};
19use mdbook_preprocessor::book::{Book, BookItem};
20use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
21
22use config::Config;
23use coverage::CoverageMap;
24use marker::find_markers;
25use render::{STYLE, render_marker};
26
27pub struct Tracey;
28
29impl Preprocessor for Tracey {
30    fn name(&self) -> &str {
31        "tracey"
32    }
33
34    fn supports_renderer(&self, renderer: &str) -> Result<bool> {
35        Ok(renderer == "html")
36    }
37
38    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
39        let cfg = Config::from_context(ctx)?;
40
41        // Misconfigured coverage is a hard error — the user asked for
42        // badges, and silently falling back to anchor-only would hide the
43        // problem until someone noticed the badges were missing.
44        let coverage = match &cfg.tracey_config {
45            Some(styx_path) => {
46                let styx = fs::read_to_string(styx_path)
47                    .with_context(|| format!("reading tracey config {}", styx_path.display()))?;
48                let tracey_cfg: tracey_config::Config = facet_styx::from_str(&styx)
49                    .map_err(|e| anyhow!("{e}"))
50                    .with_context(|| format!("parsing {}", styx_path.display()))?;
51
52                // Tracey config lives at .config/tracey/config.styx relative
53                // to project root; three ../ gets us there.
54                let project_root = styx_path
55                    .parent()
56                    .and_then(|p| p.parent())
57                    .and_then(|p| p.parent())
58                    .context("tracey config must live at .config/tracey/config.styx")?;
59
60                let repo_url = cfg
61                    .repo_url
62                    .clone()
63                    .or_else(|| derive_repo_url(&tracey_cfg));
64                let map = coverage::scan(project_root, &tracey_cfg)?;
65                Some((map, repo_url))
66            }
67            None => None,
68        };
69
70        let (cov_map, repo_url) = match &coverage {
71            Some((m, u)) => (Some(m), u.as_deref()),
72            None => (None, None),
73        };
74
75        let mut misses: Vec<String> = Vec::new();
76        book.for_each_mut(|item| {
77            if let BookItem::Chapter(ch) = item
78                && let Some(new) =
79                    process_chapter(&ch.content, cov_map, repo_url, cfg.style, &mut misses)
80            {
81                ch.content = new;
82            }
83        });
84
85        if !misses.is_empty() {
86            misses.sort();
87            misses.dedup();
88            eprintln!(
89                "mdbook-tracey: warning: {} rule(s) not found in coverage scan: {}",
90                misses.len(),
91                misses.join(", ")
92            );
93        }
94
95        Ok(book)
96    }
97}
98
99/// Derive a `{file}`/`{line}` URL template from `SpecConfig.source_url` if
100/// it looks like a GitHub repo. Returns `None` otherwise — refs render as
101/// plain `<span>` in the popover.
102fn derive_repo_url(cfg: &tracey_config::Config) -> Option<String> {
103    let source = cfg.specs.iter().find_map(|s| s.source_url.as_deref())?;
104    let source = source.trim_end_matches('/');
105    if source.starts_with("https://github.com/") {
106        // /blob/HEAD/ resolves to the default branch regardless of whether
107        // it's called main, master, trunk, etc.
108        Some(format!("{source}/blob/HEAD/{{file}}#L{{line}}"))
109    } else {
110        None
111    }
112}
113
114/// Rewrite one chapter's markdown. Returns `None` if no markers were found
115/// (leaves chapters without tracey annotations byte-identical). When
116/// `coverage` is `Some` but a marker's ID is absent from the map, the ID is
117/// pushed onto `misses` so the caller can warn.
118fn process_chapter(
119    content: &str,
120    coverage: Option<&CoverageMap>,
121    repo_url: Option<&str>,
122    inject_style: bool,
123    misses: &mut Vec<String>,
124) -> Option<String> {
125    let markers = find_markers(content);
126    if markers.is_empty() {
127        return None;
128    }
129
130    // Walk the source once, copying unmodified spans between markers and
131    // splicing rendered HTML in place of each marker line. Marker spans are
132    // non-overlapping and in document order.
133    let mut out = String::with_capacity(content.len() + markers.len() * 256);
134    if inject_style {
135        out.push_str(STYLE);
136    }
137
138    let mut cursor = 0;
139    for m in &markers {
140        out.push_str(&content[cursor..m.line_span.start]);
141        let cov = match coverage {
142            Some(map) => match map.get(&m.id.base) {
143                Some(c) => Some(c),
144                None => {
145                    misses.push(m.id.base.clone());
146                    None
147                }
148            },
149            None => None,
150        };
151        out.push_str(&render_marker(m, cov, repo_url));
152        cursor = m.line_span.end;
153    }
154    out.push_str(&content[cursor..]);
155
156    Some(out)
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use coverage::{Coverage, Ref};
163    use pretty_assertions::assert_eq;
164
165    #[test]
166    fn chapter_without_markers_is_untouched() {
167        let md = "# Title\n\nJust prose.\n";
168        assert_eq!(process_chapter(md, None, None, true, &mut Vec::new()), None);
169    }
170
171    #[test]
172    fn marker_replaced_prose_preserved() {
173        let md = "# Heading\n\nr[foo.bar]\nThe requirement text.\n\nAnother paragraph.\n";
174        let out = process_chapter(md, None, None, false, &mut Vec::new()).unwrap();
175        assert!(out.contains(r#"id="r-foo.bar""#));
176        assert!(out.contains("The requirement text."));
177        assert!(out.contains("Another paragraph."));
178        assert!(!out.contains("r[foo.bar]"));
179    }
180
181    #[test]
182    fn style_injected_when_enabled() {
183        let out = process_chapter("r[x.y]\n", None, None, true, &mut Vec::new()).unwrap();
184        assert!(out.starts_with("<style>"));
185        let out = process_chapter("r[x.y]\n", None, None, false, &mut Vec::new()).unwrap();
186        assert!(!out.starts_with("<style>"));
187    }
188
189    #[test]
190    fn coverage_lookup_by_base() {
191        let mut map = CoverageMap::new();
192        fn rf(file: &str, line: usize) -> Ref {
193            Ref {
194                file: file.into(),
195                line,
196            }
197        }
198        map.insert(
199            "foo.bar".into(),
200            Coverage {
201                impl_refs: vec![rf("a.rs", 1), rf("b.rs", 2), rf("c.rs", 3)],
202                verify_refs: vec![rf("t.rs", 5)],
203            },
204        );
205        // Coverage is keyed by base ID; version suffix in the marker
206        // shouldn't defeat the lookup.
207        let out =
208            process_chapter("r[foo.bar+2]\n", Some(&map), None, false, &mut Vec::new()).unwrap();
209        assert!(out.contains("impl 3"));
210        assert!(out.contains("verify 1"));
211    }
212
213    #[test]
214    fn coverage_miss_recorded() {
215        let map = CoverageMap::new();
216        let mut misses = Vec::new();
217        let out = process_chapter("r[not.in.map]\n", Some(&map), None, false, &mut misses).unwrap();
218        assert_eq!(misses, ["not.in.map"]);
219        assert!(!out.contains("tracey-badge"));
220    }
221
222    #[test]
223    fn no_miss_without_coverage() {
224        let mut misses = Vec::new();
225        process_chapter("r[anything]\n", None, None, false, &mut misses).unwrap();
226        assert!(misses.is_empty());
227    }
228
229    #[test]
230    fn derive_repo_url_github() {
231        let mut cfg = tracey_config::Config::default();
232        cfg.specs.push(tracey_config::SpecConfig {
233            name: "rix".into(),
234            prefix: None,
235            source_url: Some("https://github.com/lovesegfault/rix".into()),
236            include: vec![],
237            impls: vec![],
238        });
239        assert_eq!(
240            derive_repo_url(&cfg),
241            Some("https://github.com/lovesegfault/rix/blob/HEAD/{file}#L{line}".into())
242        );
243    }
244
245    #[test]
246    fn derive_repo_url_trailing_slash() {
247        let mut cfg = tracey_config::Config::default();
248        cfg.specs.push(tracey_config::SpecConfig {
249            name: "x".into(),
250            prefix: None,
251            source_url: Some("https://github.com/foo/bar/".into()),
252            include: vec![],
253            impls: vec![],
254        });
255        assert_eq!(
256            derive_repo_url(&cfg),
257            Some("https://github.com/foo/bar/blob/HEAD/{file}#L{line}".into())
258        );
259    }
260
261    #[test]
262    fn derive_repo_url_non_github() {
263        let mut cfg = tracey_config::Config::default();
264        cfg.specs.push(tracey_config::SpecConfig {
265            name: "x".into(),
266            prefix: None,
267            source_url: Some("https://gitlab.com/foo/bar".into()),
268            include: vec![],
269            impls: vec![],
270        });
271        assert_eq!(derive_repo_url(&cfg), None);
272    }
273
274    #[test]
275    fn derive_repo_url_none_when_unset() {
276        assert_eq!(derive_repo_url(&tracey_config::Config::default()), None);
277    }
278}