1mod 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 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 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
99fn 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 Some(format!("{source}/blob/HEAD/{{file}}#L{{line}}"))
109 } else {
110 None
111 }
112}
113
114fn 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 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 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}