spec_driven_docs/services/
hooks_render.rs1use std::fmt::Write as _;
15
16use crate::domain::instance_config::InstanceConfig;
17use crate::domain::marker;
18use crate::gates::GATES;
19
20const LANGUAGE: &str = "system";
25
26const MARKDOWNLINT_REPO: &str = "https://github.com/DavidAnson/markdownlint-cli2";
33
34const MARKDOWNLINT_REV: &str = "v0.23.2";
36
37const MARKDOWNLINT_DIR: &str = ".spec-driven-docs/markdownlint";
39
40#[derive(Debug, Clone)]
42pub struct RenderOptions {
43 pub docs_root: String,
46 pub entry: String,
48 pub indent: String,
50 pub declaration: InstanceConfig,
55}
56
57impl Default for RenderOptions {
58 fn default() -> Self {
59 Self {
60 docs_root: "_docs".to_string(),
61 entry: "sdd".to_string(),
62 indent: " ".to_string(),
63 declaration: InstanceConfig::default(),
64 }
65 }
66}
67
68fn quoted(value: &str) -> String {
70 format!("'{}'", value.replace('\'', "''"))
71}
72
73#[allow(
74 clippy::literal_string_with_formatting_args,
75 reason = "the braces are the wiring template's placeholder, not a formatting argument"
76)]
77fn substitute_root(pattern: &str, docs_root: &str) -> String {
78 pattern.replace("{docs_root}", docs_root)
79}
80
81fn escape(out: &mut String, ch: char) {
83 if ".^$+()|[]{}\\*?".contains(ch) {
84 out.push('\\');
85 }
86 out.push(ch);
87}
88
89fn glob_to_regex(glob: &str) -> String {
106 let mut out = String::from("^");
107 let bytes: Vec<char> = glob.chars().collect();
108 let mut i = 0;
109 while i < bytes.len() {
110 match bytes[i] {
111 '*' if bytes.get(i + 1) == Some(&'*') => {
112 if bytes.get(i + 2) == Some(&'/') {
113 out.push_str("(?:.*/)?");
115 i += 3;
116 } else {
117 out.push_str(".*");
118 i += 2;
119 }
120 }
121 '*' => {
122 out.push_str("[^/]*");
123 i += 1;
124 }
125 '?' => {
126 out.push_str("[^/]");
127 i += 1;
128 }
129 '[' => {
130 let close = bytes[i..].iter().position(|c| *c == ']').map(|p| i + p);
131 if let Some(close) = close {
132 out.push('[');
133 let mut j = i + 1;
134 if bytes.get(j) == Some(&'!') {
135 out.push('^');
136 j += 1;
137 }
138 for ch in &bytes[j..close] {
139 out.push(*ch);
140 }
141 out.push(']');
142 i = close + 1;
143 } else {
144 escape(&mut out, '[');
145 i += 1;
146 }
147 }
148 '{' => {
149 let close = bytes[i..].iter().position(|c| *c == '}').map(|p| i + p);
150 if let Some(close) = close {
151 out.push_str("(?:");
152 let branches: String = bytes[i + 1..close].iter().collect();
153 let rendered: Vec<String> = branches
154 .split(',')
155 .map(|branch| {
156 let whole = glob_to_regex(branch);
157 whole
158 .trim_start_matches('^')
159 .trim_end_matches('$')
160 .to_string()
161 })
162 .collect();
163 out.push_str(&rendered.join("|"));
164 out.push(')');
165 i = close + 1;
166 } else {
167 escape(&mut out, '{');
168 i += 1;
169 }
170 }
171 ch => {
172 escape(&mut out, ch);
173 i += 1;
174 }
175 }
176 }
177 out.push('$');
178 out
179}
180
181fn render_patterns(globs: &[&str], docs_root: &str) -> Option<String> {
183 let rendered: Vec<String> = globs
184 .iter()
185 .map(|glob| glob_to_regex(&substitute_root(glob, docs_root)))
186 .collect();
187 match rendered.len() {
188 0 => None,
189 1 => Some(rendered[0].clone()),
190 _ => {
191 let inner: Vec<String> = rendered
192 .iter()
193 .map(|r| r.trim_start_matches('^').trim_end_matches('$').to_string())
194 .collect();
195 Some(format!("^(?:{})$", inner.join("|")))
196 }
197 }
198}
199
200fn render_gates(options: &RenderOptions) -> String {
202 let item = format!("{0} - ", options.indent);
203 let field = format!("{0} ", options.indent);
204 let mut out = String::new();
205 for gate in GATES {
206 let _ = writeln!(out, "{item}id: {}", gate.id);
207 let _ = writeln!(out, "{field}name: {}", quoted(gate.name));
208 let _ = writeln!(out, "{field}entry: {} gate {}", options.entry, gate.id);
209 let _ = writeln!(out, "{field}language: {LANGUAGE}");
210 let declared = options.declaration.for_gate(gate.id);
216 let include: Vec<&str> = declared.filter(|d| !d.include.is_empty()).map_or_else(
220 || gate.include.to_vec(),
221 |declared| declared.include.iter().map(String::as_str).collect(),
222 );
223 let exclude: Vec<&str> = gate
224 .exclude
225 .iter()
226 .copied()
227 .chain(
228 declared
229 .into_iter()
230 .flat_map(|d| &d.exclude)
231 .map(String::as_str),
232 )
233 .chain(options.declaration.reserved.iter().map(String::as_str))
234 .collect();
235
236 if !gate.always_run
237 && let Some(files) = render_patterns(&include, &options.docs_root)
238 {
239 let _ = writeln!(out, "{field}files: {}", quoted(&files));
240 }
241 if let Some(types) = gate.types {
242 let _ = writeln!(out, "{field}types: [{types}]");
243 }
244 if !gate.always_run
245 && let Some(exclude) = render_patterns(&exclude, &options.docs_root)
246 {
247 let _ = writeln!(out, "{field}exclude: {}", quoted(&exclude));
248 }
249 if gate.always_run {
250 let _ = writeln!(out, "{field}always_run: true");
251 let _ = writeln!(out, "{field}pass_filenames: false");
252 }
253 }
254 out
255}
256
257#[must_use]
264pub fn selectors(
265 block: &str,
266) -> std::collections::BTreeMap<String, (Option<String>, Option<String>)> {
267 let mut found = std::collections::BTreeMap::new();
268 let mut current: Option<String> = None;
269 for line in block.lines() {
270 let trimmed = line.trim_start();
271 if let Some(id) = trimmed.strip_prefix("- id: ") {
272 current = Some(id.trim().to_string());
273 found.insert(id.trim().to_string(), (None, None));
274 continue;
275 }
276 let Some(id) = current.as_ref() else { continue };
277 if let Some(value) = trimmed.strip_prefix("files: ") {
278 if let Some(entry) = found.get_mut(id) {
279 entry.0 = Some(value.trim().to_string());
280 }
281 } else if let Some(value) = trimmed.strip_prefix("exclude: ")
282 && let Some(entry) = found.get_mut(id)
283 {
284 entry.1 = Some(value.trim().to_string());
285 }
286 }
287 found
288}
289
290#[must_use]
293pub fn render_block(options: &RenderOptions) -> String {
294 let indent = &options.indent;
295 let mut out = String::new();
296 out.push_str(marker::BEGIN);
297 out.push('\n');
298 let _ = writeln!(out, "{indent}- repo: local");
299 let _ = writeln!(out, "{indent} hooks:");
300 let _ = writeln!(out, "{indent} - id: spec-driven-docs-verify");
301 let _ = writeln!(out, "{indent} name: verify spec-driven docs instance");
302 let _ = writeln!(out, "{indent} entry: {} verify", options.entry);
303 let _ = writeln!(out, "{indent} language: {LANGUAGE}");
304 let _ = writeln!(out, "{indent} always_run: true");
305 let _ = writeln!(out, "{indent} pass_filenames: false");
306 out.push_str(&render_gates(options));
307 out.push_str(&render_markdownlint(options));
308 out.push_str(marker::END);
309 out.push('\n');
310 out
311}
312
313fn render_markdownlint(options: &RenderOptions) -> String {
315 let indent = &options.indent;
316 let docs_root = &options.docs_root;
317 let mut out = String::new();
318 let _ = writeln!(out, "{indent}- repo: {MARKDOWNLINT_REPO}");
319 let _ = writeln!(out, "{indent} rev: {MARKDOWNLINT_REV}");
320 let _ = writeln!(out, "{indent} hooks:");
321
322 let _ = writeln!(out, "{indent} - id: markdownlint-cli2");
323 let _ = writeln!(out, "{indent} alias: md-relative-links");
324 let _ = writeln!(out, "{indent} name: markdownlint relative links");
325 let _ = writeln!(
326 out,
327 "{indent} additional_dependencies: ['markdownlint-rule-relative-links']"
328 );
329 let _ = writeln!(
330 out,
331 "{indent} args: ['--config', '{MARKDOWNLINT_DIR}/relative-links.markdownlint-cli2.jsonc']"
332 );
333 let _ = writeln!(
334 out,
335 "{indent} exclude: {}",
336 quoted(&format!("^{docs_root}/decisions/"))
337 );
338
339 let _ = writeln!(out, "{indent} - id: markdownlint-cli2");
340 let _ = writeln!(out, "{indent} alias: md-spec");
341 let _ = writeln!(out, "{indent} name: markdownlint spec heading shape");
342 let _ = writeln!(
343 out,
344 "{indent} files: {}",
345 quoted(&format!("^{docs_root}/specs/SPEC-[a-z0-9-]+\\.md$"))
346 );
347 let _ = writeln!(
348 out,
349 "{indent} args: ['--config', '{MARKDOWNLINT_DIR}/spec.markdownlint-cli2.jsonc']"
350 );
351
352 let _ = writeln!(out, "{indent} - id: markdownlint-cli2");
353 let _ = writeln!(out, "{indent} alias: md-adr");
354 let _ = writeln!(
355 out,
356 "{indent} name: markdownlint decision heading shape"
357 );
358 let _ = writeln!(
359 out,
360 "{indent} files: {}",
361 quoted(&format!("^{docs_root}/decisions/ADR-[a-z-]+\\.md$"))
362 );
363 let _ = writeln!(
364 out,
365 "{indent} args: ['--config', '{MARKDOWNLINT_DIR}/adr.markdownlint-cli2.jsonc']"
366 );
367 out
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn the_linter_reads_the_configurations_the_landing_writes() {
376 let out = render_markdownlint(&RenderOptions::default());
377 assert!(out.contains(MARKDOWNLINT_REPO), "{out}");
378 for name in ["relative-links", "spec", "adr"] {
379 assert!(
380 out.contains(&format!(
381 "{MARKDOWNLINT_DIR}/{name}.markdownlint-cli2.jsonc"
382 )),
383 "{name} is not wired: {out}"
384 );
385 }
386 assert!(
387 out.contains("'^_docs/specs/SPEC-[a-z0-9-]+\\.md$'"),
388 "{out}"
389 );
390 }
391
392 #[test]
393 fn the_documentation_root_reaches_the_linter_wiring() {
394 let out = render_markdownlint(&RenderOptions {
395 docs_root: "docs".to_string(),
396 ..RenderOptions::default()
397 });
398 assert!(out.contains("'^docs/decisions/ADR-[a-z-]+\\.md$'"), "{out}");
399 assert!(!out.contains("_docs/"), "{out}");
400 }
401
402 #[test]
403 fn every_gate_renders_its_wiring_fields() {
404 let out = render_gates(&RenderOptions::default());
405 assert!(out.starts_with(" - id: adr-cites-a-live-rule\n"));
406 assert!(out.contains(" entry: sdd gate adr-filename-shape\n"));
407 assert!(out.contains(" language: system\n"));
408 assert!(out.contains(" types: [markdown]\n"));
409 assert_eq!(out.matches("- id: ").count(), crate::gates::GATES.len());
410 }
411
412 #[test]
414 fn the_docs_root_reaches_every_templated_pattern() {
415 let out = render_gates(&RenderOptions {
416 docs_root: "docs".to_string(),
417 ..RenderOptions::default()
418 });
419 assert!(out.contains(" files: '^docs/decisions/[^/]*\\.md$'\n"));
420 assert!(out.contains(" exclude: '^docs/decisions/.*$'\n"));
421 assert!(!out.contains("{docs_root}"));
422 }
423
424 #[test]
425 fn block_style_carries_the_markers_and_the_verifier() {
426 let out = render_block(&RenderOptions::default());
427 assert!(out.starts_with("# BEGIN spec-driven-docs managed\n"));
428 assert!(out.ends_with("# END spec-driven-docs managed\n"));
429 assert!(out.contains(" - id: spec-driven-docs-verify\n"));
430 assert!(out.contains(" entry: sdd verify\n"));
431 assert!(out.contains(" - id: adr-filename-shape\n"));
432 assert!(out.contains(" files: '^_docs/decisions/[^/]*\\.md$'\n"));
433 }
434
435 #[test]
436 fn a_glob_projects_onto_an_anchored_regex() {
437 assert_eq!(glob_to_regex("README.md"), r"^README\.md$");
438 assert_eq!(glob_to_regex("_docs/*.md"), r"^_docs/[^/]*\.md$");
439 assert_eq!(glob_to_regex("_docs/**/*.md"), r"^_docs/(?:.*/)?[^/]*\.md$");
440 assert_eq!(glob_to_regex("**/AGENTS.md"), r"^(?:.*/)?AGENTS\.md$");
441 assert_eq!(glob_to_regex("vendor/**"), r"^vendor/.*$");
442 assert_eq!(glob_to_regex("a?.md"), r"^a[^/]\.md$");
443 assert_eq!(glob_to_regex("[abc].md"), r"^[abc]\.md$");
444 assert_eq!(glob_to_regex("{SPEC,ADR}-a.md"), r"^(?:SPEC|ADR)-a\.md$");
445 }
446
447 #[test]
448 fn several_includes_render_as_one_alternation() {
449 let rendered = render_patterns(&["a.md", "b/*.md"], "_docs").expect("two patterns render");
450 assert_eq!(rendered, r"^(?:a\.md|b/[^/]*\.md)$");
451 assert_eq!(render_patterns(&[], "_docs"), None);
452 }
453
454 #[test]
459 fn the_rendered_pattern_is_a_superset_of_the_matcher() {
460 use crate::domain::path_filter::{Layer, PathFilter, Pattern};
461
462 let corpus = [
463 "README.md",
464 "AGENTS.md",
465 "method/AGENTS.md",
466 "method/gates.md",
467 "CHANGELOG.md",
468 "_docs/specs/SPEC-release.md",
469 "_docs/decisions/ADR-a-choice.md",
470 "_docs/reference/known-issues/KI-a-case.md",
471 "_docs/reference/tracking.yaml",
472 "_docs/guides/release.md",
473 "comparison-docs/COMPARISON-tools.md",
474 ".spec-driven-docs/manifest.json",
475 "src/gates.rs",
476 "vendor/third/lib.rs",
477 ];
478
479 for gate in GATES {
480 let filter = PathFilter::build(
481 gate.include
482 .iter()
483 .map(|g| Pattern::new(substitute_root(g, "_docs"), Layer::Registry))
484 .collect(),
485 gate.exclude
486 .iter()
487 .map(|g| Pattern::new(substitute_root(g, "_docs"), Layer::Registry))
488 .collect(),
489 )
490 .expect("every registry pattern compiles");
491
492 let files = render_patterns(gate.include, "_docs");
493 let excludes = render_patterns(gate.exclude, "_docs");
494
495 for path in corpus {
496 if !filter.judges(camino::Utf8Path::new(path)) {
497 continue;
498 }
499 if let Some(files) = &files {
500 let selector = regex::Regex::new(files).expect("the projection compiles");
501 assert!(
502 selector.is_match(path),
503 "{}: the matcher judges {path} and the rendered files: {files} does not select it",
504 gate.id
505 );
506 }
507 if let Some(excludes) = &excludes {
508 let selector = regex::Regex::new(excludes).expect("the projection compiles");
509 assert!(
510 !selector.is_match(path),
511 "{}: the matcher judges {path} and the rendered exclude: {excludes} drops it",
512 gate.id
513 );
514 }
515 }
516 }
517 }
518
519 #[test]
520 fn always_run_gates_do_not_take_filenames() {
521 let out = render_gates(&RenderOptions::default());
522 assert_eq!(
523 out.matches("always_run: true").count(),
524 out.matches("pass_filenames: false").count()
525 );
526 }
527
528 #[test]
529 fn an_apostrophe_in_a_name_would_be_doubled() {
530 assert_eq!(quoted("it's"), "'it''s'");
531 }
532
533 #[test]
534 fn the_block_splices_into_a_plain_config() {
535 let block = render_block(&RenderOptions::default());
536 let spliced = crate::domain::marker::splice("repos:\n", &block).unwrap();
537 let (base, found) = crate::domain::marker::split_block(&spliced).unwrap();
538 assert_eq!(base, "repos:\n");
539 assert_eq!(found.as_deref(), Some(block.as_str()));
540 }
541}