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
26#[derive(Debug, Clone)]
28pub struct RenderOptions {
29 pub docs_root: String,
32 pub entry: String,
34 pub indent: String,
36 pub declaration: InstanceConfig,
41}
42
43impl Default for RenderOptions {
44 fn default() -> Self {
45 Self {
46 docs_root: "_docs".to_string(),
47 entry: "sdd".to_string(),
48 indent: " ".to_string(),
49 declaration: InstanceConfig::default(),
50 }
51 }
52}
53
54fn quoted(value: &str) -> String {
56 format!("'{}'", value.replace('\'', "''"))
57}
58
59#[allow(
60 clippy::literal_string_with_formatting_args,
61 reason = "the braces are the wiring template's placeholder, not a formatting argument"
62)]
63fn substitute_root(pattern: &str, docs_root: &str) -> String {
64 pattern.replace("{docs_root}", docs_root)
65}
66
67fn escape(out: &mut String, ch: char) {
69 if ".^$+()|[]{}\\*?".contains(ch) {
70 out.push('\\');
71 }
72 out.push(ch);
73}
74
75fn glob_to_regex(glob: &str) -> String {
92 let mut out = String::from("^");
93 let bytes: Vec<char> = glob.chars().collect();
94 let mut i = 0;
95 while i < bytes.len() {
96 match bytes[i] {
97 '*' if bytes.get(i + 1) == Some(&'*') => {
98 if bytes.get(i + 2) == Some(&'/') {
99 out.push_str("(?:.*/)?");
101 i += 3;
102 } else {
103 out.push_str(".*");
104 i += 2;
105 }
106 }
107 '*' => {
108 out.push_str("[^/]*");
109 i += 1;
110 }
111 '?' => {
112 out.push_str("[^/]");
113 i += 1;
114 }
115 '[' => {
116 let close = bytes[i..].iter().position(|c| *c == ']').map(|p| i + p);
117 if let Some(close) = close {
118 out.push('[');
119 let mut j = i + 1;
120 if bytes.get(j) == Some(&'!') {
121 out.push('^');
122 j += 1;
123 }
124 for ch in &bytes[j..close] {
125 out.push(*ch);
126 }
127 out.push(']');
128 i = close + 1;
129 } else {
130 escape(&mut out, '[');
131 i += 1;
132 }
133 }
134 '{' => {
135 let close = bytes[i..].iter().position(|c| *c == '}').map(|p| i + p);
136 if let Some(close) = close {
137 out.push_str("(?:");
138 let branches: String = bytes[i + 1..close].iter().collect();
139 let rendered: Vec<String> = branches
140 .split(',')
141 .map(|branch| {
142 let whole = glob_to_regex(branch);
143 whole
144 .trim_start_matches('^')
145 .trim_end_matches('$')
146 .to_string()
147 })
148 .collect();
149 out.push_str(&rendered.join("|"));
150 out.push(')');
151 i = close + 1;
152 } else {
153 escape(&mut out, '{');
154 i += 1;
155 }
156 }
157 ch => {
158 escape(&mut out, ch);
159 i += 1;
160 }
161 }
162 }
163 out.push('$');
164 out
165}
166
167fn render_patterns(globs: &[&str], docs_root: &str) -> Option<String> {
169 let rendered: Vec<String> = globs
170 .iter()
171 .map(|glob| glob_to_regex(&substitute_root(glob, docs_root)))
172 .collect();
173 match rendered.len() {
174 0 => None,
175 1 => Some(rendered[0].clone()),
176 _ => {
177 let inner: Vec<String> = rendered
178 .iter()
179 .map(|r| r.trim_start_matches('^').trim_end_matches('$').to_string())
180 .collect();
181 Some(format!("^(?:{})$", inner.join("|")))
182 }
183 }
184}
185
186fn render_gates(options: &RenderOptions) -> String {
188 let item = format!("{0} - ", options.indent);
189 let field = format!("{0} ", options.indent);
190 let mut out = String::new();
191 for gate in GATES {
192 let _ = writeln!(out, "{item}id: {}", gate.id);
193 let _ = writeln!(out, "{field}name: {}", quoted(gate.name));
194 let _ = writeln!(out, "{field}entry: {} gate {}", options.entry, gate.id);
195 let _ = writeln!(out, "{field}language: {LANGUAGE}");
196 let declared = options.declaration.for_gate(gate.id);
202 let include: Vec<&str> = declared.filter(|d| !d.include.is_empty()).map_or_else(
206 || gate.include.to_vec(),
207 |declared| declared.include.iter().map(String::as_str).collect(),
208 );
209 let exclude: Vec<&str> = gate
210 .exclude
211 .iter()
212 .copied()
213 .chain(
214 declared
215 .into_iter()
216 .flat_map(|d| &d.exclude)
217 .map(String::as_str),
218 )
219 .chain(options.declaration.reserved.iter().map(String::as_str))
220 .collect();
221
222 if !gate.always_run {
223 if let Some(files) = render_patterns(&include, &options.docs_root) {
224 let _ = writeln!(out, "{field}files: {}", quoted(&files));
225 }
226 }
227 if let Some(types) = gate.types {
228 let _ = writeln!(out, "{field}types: [{types}]");
229 }
230 if !gate.always_run {
231 if let Some(exclude) = render_patterns(&exclude, &options.docs_root) {
232 let _ = writeln!(out, "{field}exclude: {}", quoted(&exclude));
233 }
234 }
235 if gate.always_run {
236 let _ = writeln!(out, "{field}always_run: true");
237 let _ = writeln!(out, "{field}pass_filenames: false");
238 }
239 }
240 out
241}
242
243#[must_use]
250pub fn selectors(
251 block: &str,
252) -> std::collections::BTreeMap<String, (Option<String>, Option<String>)> {
253 let mut found = std::collections::BTreeMap::new();
254 let mut current: Option<String> = None;
255 for line in block.lines() {
256 let trimmed = line.trim_start();
257 if let Some(id) = trimmed.strip_prefix("- id: ") {
258 current = Some(id.trim().to_string());
259 found.insert(id.trim().to_string(), (None, None));
260 continue;
261 }
262 let Some(id) = current.as_ref() else { continue };
263 if let Some(value) = trimmed.strip_prefix("files: ") {
264 if let Some(entry) = found.get_mut(id) {
265 entry.0 = Some(value.trim().to_string());
266 }
267 } else if let Some(value) = trimmed.strip_prefix("exclude: ") {
268 if let Some(entry) = found.get_mut(id) {
269 entry.1 = Some(value.trim().to_string());
270 }
271 }
272 }
273 found
274}
275
276#[must_use]
279pub fn render_block(options: &RenderOptions) -> String {
280 let indent = &options.indent;
281 let mut out = String::new();
282 out.push_str(marker::BEGIN);
283 out.push('\n');
284 let _ = writeln!(out, "{indent}- repo: local");
285 let _ = writeln!(out, "{indent} hooks:");
286 let _ = writeln!(out, "{indent} - id: spec-driven-docs-verify");
287 let _ = writeln!(out, "{indent} name: verify spec-driven docs instance");
288 let _ = writeln!(out, "{indent} entry: {} verify", options.entry);
289 let _ = writeln!(out, "{indent} language: {LANGUAGE}");
290 let _ = writeln!(out, "{indent} always_run: true");
291 let _ = writeln!(out, "{indent} pass_filenames: false");
292 out.push_str(&render_gates(options));
293 out.push_str(marker::END);
294 out.push('\n');
295 out
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn every_gate_renders_its_wiring_fields() {
304 let out = render_gates(&RenderOptions::default());
305 assert!(out.starts_with(" - id: adr-cites-a-live-rule\n"));
306 assert!(out.contains(" entry: sdd gate adr-filename-shape\n"));
307 assert!(out.contains(" language: system\n"));
308 assert!(out.contains(" types: [markdown]\n"));
309 assert_eq!(out.matches("- id: ").count(), crate::gates::GATES.len());
310 }
311
312 #[test]
314 fn the_docs_root_reaches_every_templated_pattern() {
315 let out = render_gates(&RenderOptions {
316 docs_root: "docs".to_string(),
317 ..RenderOptions::default()
318 });
319 assert!(out.contains(" files: '^docs/decisions/[^/]*\\.md$'\n"));
320 assert!(out.contains(" exclude: '^docs/decisions/.*$'\n"));
321 assert!(!out.contains("{docs_root}"));
322 }
323
324 #[test]
325 fn block_style_carries_the_markers_and_the_verifier() {
326 let out = render_block(&RenderOptions::default());
327 assert!(out.starts_with("# BEGIN spec-driven-docs managed\n"));
328 assert!(out.ends_with("# END spec-driven-docs managed\n"));
329 assert!(out.contains(" - id: spec-driven-docs-verify\n"));
330 assert!(out.contains(" entry: sdd verify\n"));
331 assert!(out.contains(" - id: adr-filename-shape\n"));
332 assert!(out.contains(" files: '^_docs/decisions/[^/]*\\.md$'\n"));
333 }
334
335 #[test]
336 fn a_glob_projects_onto_an_anchored_regex() {
337 assert_eq!(glob_to_regex("README.md"), r"^README\.md$");
338 assert_eq!(glob_to_regex("_docs/*.md"), r"^_docs/[^/]*\.md$");
339 assert_eq!(glob_to_regex("_docs/**/*.md"), r"^_docs/(?:.*/)?[^/]*\.md$");
340 assert_eq!(glob_to_regex("**/AGENTS.md"), r"^(?:.*/)?AGENTS\.md$");
341 assert_eq!(glob_to_regex("vendor/**"), r"^vendor/.*$");
342 assert_eq!(glob_to_regex("a?.md"), r"^a[^/]\.md$");
343 assert_eq!(glob_to_regex("[abc].md"), r"^[abc]\.md$");
344 assert_eq!(glob_to_regex("{SPEC,ADR}-a.md"), r"^(?:SPEC|ADR)-a\.md$");
345 }
346
347 #[test]
348 fn several_includes_render_as_one_alternation() {
349 let rendered = render_patterns(&["a.md", "b/*.md"], "_docs").expect("two patterns render");
350 assert_eq!(rendered, r"^(?:a\.md|b/[^/]*\.md)$");
351 assert_eq!(render_patterns(&[], "_docs"), None);
352 }
353
354 #[test]
359 fn the_rendered_pattern_is_a_superset_of_the_matcher() {
360 use crate::domain::path_filter::{Layer, PathFilter, Pattern};
361
362 let corpus = [
363 "README.md",
364 "AGENTS.md",
365 "method/AGENTS.md",
366 "method/gates.md",
367 "CHANGELOG.md",
368 "_docs/specs/SPEC-release.md",
369 "_docs/decisions/ADR-a-choice.md",
370 "_docs/reference/known-issues/KI-a-case.md",
371 "_docs/reference/tracking.yaml",
372 "_docs/guides/release.md",
373 "comparison-docs/COMPARISON-tools.md",
374 ".spec-driven-docs/manifest.json",
375 "src/gates.rs",
376 "vendor/third/lib.rs",
377 ];
378
379 for gate in GATES {
380 let filter = PathFilter::build(
381 gate.include
382 .iter()
383 .map(|g| Pattern::new(substitute_root(g, "_docs"), Layer::Registry))
384 .collect(),
385 gate.exclude
386 .iter()
387 .map(|g| Pattern::new(substitute_root(g, "_docs"), Layer::Registry))
388 .collect(),
389 )
390 .expect("every registry pattern compiles");
391
392 let files = render_patterns(gate.include, "_docs");
393 let excludes = render_patterns(gate.exclude, "_docs");
394
395 for path in corpus {
396 if !filter.judges(camino::Utf8Path::new(path)) {
397 continue;
398 }
399 if let Some(files) = &files {
400 let selector = regex::Regex::new(files).expect("the projection compiles");
401 assert!(
402 selector.is_match(path),
403 "{}: the matcher judges {path} and the rendered files: {files} does not select it",
404 gate.id
405 );
406 }
407 if let Some(excludes) = &excludes {
408 let selector = regex::Regex::new(excludes).expect("the projection compiles");
409 assert!(
410 !selector.is_match(path),
411 "{}: the matcher judges {path} and the rendered exclude: {excludes} drops it",
412 gate.id
413 );
414 }
415 }
416 }
417 }
418
419 #[test]
420 fn always_run_gates_do_not_take_filenames() {
421 let out = render_gates(&RenderOptions::default());
422 assert_eq!(
423 out.matches("always_run: true").count(),
424 out.matches("pass_filenames: false").count()
425 );
426 }
427
428 #[test]
429 fn an_apostrophe_in_a_name_would_be_doubled() {
430 assert_eq!(quoted("it's"), "'it''s'");
431 }
432
433 #[test]
434 fn the_block_splices_into_a_plain_config() {
435 let block = render_block(&RenderOptions::default());
436 let spliced = crate::domain::marker::splice("repos:\n", &block).unwrap();
437 let (base, found) = crate::domain::marker::split_block(&spliced).unwrap();
438 assert_eq!(base, "repos:\n");
439 assert_eq!(found.as_deref(), Some(block.as_str()));
440 }
441}