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