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#[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
67/// Escape one literal character for a regex.
68fn escape(out: &mut String, ch: char) {
69    if ".^$+()|[]{}\\*?".contains(ch) {
70        out.push('\\');
71    }
72    out.push(ch);
73}
74
75/// Project one glob onto a Python regex for pre-commit's `files:` field.
76///
77/// # The contract is one-directional
78///
79/// The rendered selection MUST match every path the matcher judges, and MAY
80/// match more. `globset` compiles to a Rust byte regex and pre-commit
81/// applies Python `re.search`, and the two languages are not the same, so
82/// promising equivalence would mean writing and maintaining a converter
83/// between them. `PathFilter` stays authoritative at runtime and drops the
84/// excess, which makes a lossy projection cost a wasted invocation and never
85/// a missed violation.
86///
87/// The restricted grammar `crate::domain::path_filter` enforces is what
88/// makes the projection small: a leading `!` or `#`, an absolute path, and a
89/// `..` component are already refused, so this handles `*`, `**`, `?`, a
90/// character class, a brace alternation, and literals.
91fn 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                    // `**/` matches zero or more leading directories.
100                    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
167/// Project many globs onto one alternation, as pre-commit takes one regex.
168fn 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
186/// Render the gate entries alone, without the markers or the verifier.
187fn 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        // An `always_run` row takes no filenames, so pre-commit ignores
197        // `files:` and `exclude:` there. The registry still declares what
198        // the row judges, and `PathFilter` still applies it; emitting a
199        // selector pre-commit never reads would tell a reader of the block
200        // something untrue.
201        let declared = options.declaration.for_gate(gate.id);
202        // A project `include` replaces the registry's, and every `exclude`
203        // layer extends. `instance_config` states the algorithm; this only
204        // projects the result.
205        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/// The `files:` and `exclude:` a hook entry declares, by hook id.
244///
245/// Read from rendered or hand-maintained YAML alike, so the two can be
246/// compared. A region may carry hooks this renderer never emits — this
247/// repository's own does — so the comparison is per gate rather than over
248/// the whole region.
249#[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/// Render the complete managed block an instance's configuration carries:
277/// markers, the verifier hook, and every gate.
278#[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    /// The profile picks the root, so a `docs` instance wires `docs` paths.
313    #[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    /// The one-directional contract, asserted over every row: pre-commit's
355    /// rendered selection matches everything the matcher judges.
356    ///
357    /// SATISFIES release:a-delivered-gate-reads-what-the-convention-owns
358    #[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}