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::marker;
17use crate::gates::GATES;
18
19/// The pre-commit language every entry declares.
20///
21/// An instance runs `sdd` from its own PATH, which is what `system` means;
22/// no other language has a caller.
23const LANGUAGE: &str = "system";
24
25/// Everything a render depends on.
26#[derive(Debug, Clone)]
27pub struct RenderOptions {
28    /// What replaces `{docs_root}` in wiring patterns — the literal root
29    /// the instance's profile selected.
30    pub docs_root: String,
31    /// The command prefix an entry invokes, e.g. `sdd` or `cargo run -q --`.
32    pub entry: String,
33    /// The sequence-item indentation of the consumer's `repos:` entries.
34    pub indent: String,
35}
36
37impl Default for RenderOptions {
38    fn default() -> Self {
39        Self {
40            docs_root: "_docs".to_string(),
41            entry: "sdd".to_string(),
42            indent: "  ".to_string(),
43        }
44    }
45}
46
47/// A single-quoted YAML scalar; an apostrophe is escaped by doubling it.
48fn quoted(value: &str) -> String {
49    format!("'{}'", value.replace('\'', "''"))
50}
51
52// The braces are the wiring template's placeholder, not a formatting argument.
53#[allow(clippy::literal_string_with_formatting_args)]
54fn substitute_root(pattern: &str, docs_root: &str) -> String {
55    pattern.replace("{docs_root}", docs_root)
56}
57
58/// Render the gate entries alone, without the markers or the verifier.
59fn render_gates(options: &RenderOptions) -> String {
60    let item = format!("{0}    - ", options.indent);
61    let field = format!("{0}      ", options.indent);
62    let mut out = String::new();
63    for gate in GATES {
64        let _ = writeln!(out, "{item}id: {}", gate.id);
65        let _ = writeln!(out, "{field}name: {}", quoted(gate.name));
66        let _ = writeln!(out, "{field}entry: {} gate {}", options.entry, gate.id);
67        let _ = writeln!(out, "{field}language: {LANGUAGE}");
68        if let Some(files) = gate.files {
69            let _ = writeln!(
70                out,
71                "{field}files: {}",
72                quoted(&substitute_root(files, &options.docs_root))
73            );
74        }
75        if let Some(types) = gate.types {
76            let _ = writeln!(out, "{field}types: [{types}]");
77        }
78        if let Some(exclude) = gate.exclude {
79            let _ = writeln!(
80                out,
81                "{field}exclude: {}",
82                quoted(&substitute_root(exclude, &options.docs_root))
83            );
84        }
85        if gate.always_run {
86            let _ = writeln!(out, "{field}always_run: true");
87            let _ = writeln!(out, "{field}pass_filenames: false");
88        }
89    }
90    out
91}
92
93/// Render the complete managed block an instance's configuration carries:
94/// markers, the verifier hook, and every gate.
95#[must_use]
96pub fn render_block(options: &RenderOptions) -> String {
97    let indent = &options.indent;
98    let mut out = String::new();
99    out.push_str(marker::BEGIN);
100    out.push('\n');
101    let _ = writeln!(out, "{indent}- repo: local");
102    let _ = writeln!(out, "{indent}  hooks:");
103    let _ = writeln!(out, "{indent}    - id: spec-driven-docs-verify");
104    let _ = writeln!(out, "{indent}      name: verify spec-driven docs instance");
105    let _ = writeln!(out, "{indent}      entry: {} verify", options.entry);
106    let _ = writeln!(out, "{indent}      language: {LANGUAGE}");
107    let _ = writeln!(out, "{indent}      always_run: true");
108    let _ = writeln!(out, "{indent}      pass_filenames: false");
109    out.push_str(&render_gates(options));
110    out.push_str(marker::END);
111    out.push('\n');
112    out
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn every_gate_renders_its_wiring_fields() {
121        let out = render_gates(&RenderOptions::default());
122        assert!(out.starts_with("      - id: adr-cites-a-live-rule\n"));
123        assert!(out.contains("        entry: sdd gate adr-filename-shape\n"));
124        assert!(out.contains("        language: system\n"));
125        assert!(out.contains("        types: [markdown]\n"));
126        assert_eq!(out.matches("- id: ").count(), crate::gates::GATES.len());
127    }
128
129    /// The profile picks the root, so a `docs` instance wires `docs` paths.
130    #[test]
131    fn the_docs_root_reaches_every_templated_pattern() {
132        let out = render_gates(&RenderOptions {
133            docs_root: "docs".to_string(),
134            ..RenderOptions::default()
135        });
136        assert!(out.contains("        files: '^docs/decisions/.*\\.md$'\n"));
137        assert!(out.contains("        exclude: '^docs/decisions/'\n"));
138        assert!(!out.contains("{docs_root}"));
139    }
140
141    #[test]
142    fn block_style_carries_the_markers_and_the_verifier() {
143        let out = render_block(&RenderOptions::default());
144        assert!(out.starts_with("# BEGIN spec-driven-docs managed\n"));
145        assert!(out.ends_with("# END spec-driven-docs managed\n"));
146        assert!(out.contains("      - id: spec-driven-docs-verify\n"));
147        assert!(out.contains("        entry: sdd verify\n"));
148        assert!(out.contains("      - id: adr-filename-shape\n"));
149        assert!(out.contains("        files: '^_docs/decisions/.*\\.md$'\n"));
150    }
151
152    #[test]
153    fn always_run_gates_do_not_take_filenames() {
154        let out = render_gates(&RenderOptions::default());
155        assert_eq!(
156            out.matches("always_run: true").count(),
157            out.matches("pass_filenames: false").count()
158        );
159    }
160
161    #[test]
162    fn an_apostrophe_in_a_name_would_be_doubled() {
163        assert_eq!(quoted("it's"), "'it''s'");
164    }
165
166    #[test]
167    fn the_block_splices_into_a_plain_config() {
168        let block = render_block(&RenderOptions::default());
169        let spliced = crate::domain::marker::splice("repos:\n", &block).unwrap();
170        let (base, found) = crate::domain::marker::split_block(&spliced).unwrap();
171        assert_eq!(base, "repos:\n");
172        assert_eq!(found.as_deref(), Some(block.as_str()));
173    }
174}