Skip to main content

spec_driven_docs/services/
hooks_render.rs

1//! Render the gate registry as the managed pre-commit block.
2//!
3//! The registry is the one declaration and this is its one delivery: the
4//! block an instance's configuration carries, rendered at install time and
5//! never committed anywhere in between. A gate reaches an instance because
6//! it is in the registry, so it cannot reach the payload and miss the
7//! wiring. What the registry contains is `gates`' business; where the
8//! output lands is the caller's.
9//!
10//! There is deliberately no second shape. A `.pre-commit-hooks.yaml` would
11//! serve repositories that never adopt this framework, and most gates read
12//! an instance layout those repositories do not have.
13
14use std::fmt::Write as _;
15
16use crate::domain::instance_config::InstanceConfig;
17use crate::domain::marker;
18use crate::gates::GATES;
19
20/// The pre-commit language every entry declares.
21///
22/// An instance runs `sdd` from its own PATH, which is what `system` means;
23/// no other language has a caller.
24const LANGUAGE: &str = "system";
25
26/// Everything a render depends on.
27#[derive(Debug, Clone)]
28pub struct RenderOptions {
29    /// What replaces `{docs_root}` in wiring patterns — the literal root
30    /// the instance's profile selected.
31    pub docs_root: String,
32    /// The command prefix an entry invokes, e.g. `sdd` or `cargo run -q --`.
33    pub entry: String,
34    /// The sequence-item indentation of the consumer's `repos:` entries.
35    pub indent: String,
36    /// What the project declared about what its gates judge.
37    ///
38    /// The block is rendered from this, so the project never edits the
39    /// block and an upgrade never meets its choice.
40    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
54/// A single-quoted YAML scalar; an apostrophe is escaped by doubling it.
55fn quoted(value: &str) -> String {
56    format!("'{}'", value.replace('\'', "''"))
57}
58
59// sdd: permanent the braces are the wiring template's placeholder, not a formatting argument
60#[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
65/// Escape one literal character for a regex.
66fn escape(out: &mut String, ch: char) {
67    if ".^$+()|[]{}\\*?".contains(ch) {
68        out.push('\\');
69    }
70    out.push(ch);
71}
72
73/// Project one glob onto a Python regex for pre-commit's `files:` field.
74///
75/// # The contract is one-directional
76///
77/// The rendered selection MUST match every path the matcher judges, and MAY
78/// match more. `globset` compiles to a Rust byte regex and pre-commit
79/// applies Python `re.search`, and the two languages are not the same, so
80/// promising equivalence would mean writing and maintaining a converter
81/// between them. `PathFilter` stays authoritative at runtime and drops the
82/// excess, which makes a lossy projection cost a wasted invocation and never
83/// a missed violation.
84///
85/// The restricted grammar `crate::domain::path_filter` enforces is what
86/// makes the projection small: a leading `!` or `#`, an absolute path, and a
87/// `..` component are already refused, so this handles `*`, `**`, `?`, a
88/// character class, a brace alternation, and literals.
89fn 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                    // `**/` matches zero or more leading directories.
98                    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
165/// Project many globs onto one alternation, as pre-commit takes one regex.
166fn 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
184/// Render the gate entries alone, without the markers or the verifier.
185fn 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        // An `always_run` row takes no filenames, so pre-commit ignores
195        // `files:` and `exclude:` there. The registry still declares what
196        // the row judges, and `PathFilter` still applies it; emitting a
197        // selector pre-commit never reads would tell a reader of the block
198        // something untrue.
199        let declared = options.declaration.for_gate(gate.id);
200        // A project `include` replaces the registry's, and every `exclude`
201        // layer extends. `instance_config` states the algorithm; this only
202        // projects the result.
203        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/// The `files:` and `exclude:` a hook entry declares, by hook id.
242///
243/// Read from rendered or hand-maintained YAML alike, so the two can be
244/// compared. A region may carry hooks this renderer never emits — this
245/// repository's own does — so the comparison is per gate rather than over
246/// the whole region.
247#[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/// Render the complete managed block an instance's configuration carries:
275/// markers, the verifier hook, and every gate.
276#[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    /// The profile picks the root, so a `docs` instance wires `docs` paths.
311    #[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    /// The one-directional contract, asserted over every row: pre-commit's
353    /// rendered selection matches everything the matcher judges.
354    ///
355    /// SATISFIES release:a-delivered-gate-reads-what-the-convention-owns
356    #[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}