Skip to main content

spec_driven_docs/services/
hooks_render.rs

1//! Render the gate registry as pre-commit hook entries.
2//!
3//! One renderer serves both deliveries: the managed block spliced into an
4//! instance's configuration, and the manifest published to consumers who
5//! install this repository as a pre-commit repo. Rendering both from the
6//! registry is what keeps a gate from reaching one delivery and missing the
7//! other. What the registry contains is `gates`' business; where the output
8//! lands is the caller's.
9
10use std::fmt::Write as _;
11
12use clap::ValueEnum;
13
14use crate::domain::marker;
15use crate::gates::GATES;
16
17/// The two output shapes.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
19pub enum Style {
20    /// Entries nested under a `hooks:` key inside a consumer's `repos:`
21    /// sequence.
22    Block,
23    /// The top-level sequence a `.pre-commit-hooks.yaml` is.
24    Manifest,
25}
26
27/// Everything a render depends on.
28#[derive(Debug, Clone)]
29pub struct RenderOptions {
30    /// The output shape.
31    pub style: Style,
32    /// What replaces `{docs_root}` in wiring patterns — a literal root for
33    /// an instance, a pattern for the published manifest.
34    pub docs_root: String,
35    /// The command prefix an entry invokes, e.g. `sdd` or `cargo run -q --`.
36    pub entry: String,
37    /// The pre-commit language the entries declare.
38    pub language: String,
39    /// The sequence-item indentation of the consumer's `repos:` entries;
40    /// only the block style reads it.
41    pub indent: String,
42}
43
44impl Default for RenderOptions {
45    fn default() -> Self {
46        Self {
47            style: Style::Block,
48            docs_root: "_docs".to_string(),
49            entry: "sdd".to_string(),
50            language: "system".to_string(),
51            indent: "  ".to_string(),
52        }
53    }
54}
55
56/// A single-quoted YAML scalar; an apostrophe is escaped by doubling it.
57fn quoted(value: &str) -> String {
58    format!("'{}'", value.replace('\'', "''"))
59}
60
61// The braces are the wiring template's placeholder, not a formatting argument.
62#[allow(clippy::literal_string_with_formatting_args)]
63fn substitute_root(pattern: &str, docs_root: &str) -> String {
64    pattern.replace("{docs_root}", docs_root)
65}
66
67/// Render the gate entries alone, in the requested style.
68#[must_use]
69pub fn render_gates(options: &RenderOptions) -> String {
70    let (item, field) = match options.style {
71        Style::Block => (
72            format!("{0}    - ", options.indent),
73            format!("{0}      ", options.indent),
74        ),
75        Style::Manifest => ("- ".to_string(), "  ".to_string()),
76    };
77    let mut out = String::new();
78    for gate in GATES {
79        let _ = writeln!(out, "{item}id: {}", gate.id);
80        let _ = writeln!(out, "{field}name: {}", quoted(gate.name));
81        let _ = writeln!(out, "{field}entry: {} gate {}", options.entry, gate.id);
82        let _ = writeln!(out, "{field}language: {}", options.language);
83        if let Some(files) = gate.files {
84            let _ = writeln!(
85                out,
86                "{field}files: {}",
87                quoted(&substitute_root(files, &options.docs_root))
88            );
89        }
90        if let Some(types) = gate.types {
91            let _ = writeln!(out, "{field}types: [{types}]");
92        }
93        if let Some(exclude) = gate.exclude {
94            let _ = writeln!(
95                out,
96                "{field}exclude: {}",
97                quoted(&substitute_root(exclude, &options.docs_root))
98            );
99        }
100        if gate.always_run {
101            let _ = writeln!(out, "{field}always_run: true");
102            let _ = writeln!(out, "{field}pass_filenames: false");
103        }
104    }
105    out
106}
107
108/// Render the complete managed block an instance's configuration carries:
109/// markers, the verifier hook, and every gate.
110#[must_use]
111pub fn render_block(options: &RenderOptions) -> String {
112    let indent = &options.indent;
113    let mut out = String::new();
114    out.push_str(marker::BEGIN);
115    out.push('\n');
116    let _ = writeln!(out, "{indent}- repo: local");
117    let _ = writeln!(out, "{indent}  hooks:");
118    let _ = writeln!(out, "{indent}    - id: spec-driven-docs-verify");
119    let _ = writeln!(out, "{indent}      name: verify spec-driven docs instance");
120    let _ = writeln!(out, "{indent}      entry: {} verify", options.entry);
121    let _ = writeln!(out, "{indent}      language: {}", options.language);
122    let _ = writeln!(out, "{indent}      always_run: true");
123    let _ = writeln!(out, "{indent}      pass_filenames: false");
124    out.push_str(&render_gates(options));
125    out.push_str(marker::END);
126    out.push('\n');
127    out
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn manifest_style_renders_top_level_entries() {
136        let options = RenderOptions {
137            style: Style::Manifest,
138            docs_root: "_?docs".to_string(),
139            language: "rust".to_string(),
140            ..RenderOptions::default()
141        };
142        let out = render_gates(&options);
143        assert!(out.starts_with("- id: adr-filename-shape\n"));
144        assert!(out.contains("  entry: sdd gate adr-filename-shape\n"));
145        assert!(out.contains("  language: rust\n"));
146        assert!(out.contains("  files: '^_?docs/decisions/.*\\.md$'\n"));
147        assert!(out.contains("  types: [markdown]\n"));
148        assert_eq!(out.matches("- id: ").count(), crate::gates::GATES.len());
149    }
150
151    #[test]
152    fn block_style_carries_the_markers_and_the_verifier() {
153        let out = render_block(&RenderOptions::default());
154        assert!(out.starts_with("# BEGIN spec-driven-docs managed\n"));
155        assert!(out.ends_with("# END spec-driven-docs managed\n"));
156        assert!(out.contains("      - id: spec-driven-docs-verify\n"));
157        assert!(out.contains("        entry: sdd verify\n"));
158        assert!(out.contains("      - id: adr-filename-shape\n"));
159        assert!(out.contains("        files: '^_docs/decisions/.*\\.md$'\n"));
160    }
161
162    #[test]
163    fn always_run_gates_do_not_take_filenames() {
164        let out = render_gates(&RenderOptions::default());
165        assert_eq!(
166            out.matches("always_run: true").count(),
167            out.matches("pass_filenames: false").count()
168        );
169    }
170
171    #[test]
172    fn an_apostrophe_in_a_name_would_be_doubled() {
173        assert_eq!(quoted("it's"), "'it''s'");
174    }
175
176    #[test]
177    fn the_block_splices_into_a_plain_config() {
178        let block = render_block(&RenderOptions::default());
179        let spliced = crate::domain::marker::splice("repos:\n", &block).unwrap();
180        let (base, found) = crate::domain::marker::split_block(&spliced).unwrap();
181        assert_eq!(base, "repos:\n");
182        assert_eq!(found.as_deref(), Some(block.as_str()));
183    }
184}