spec_driven_docs/services/
hooks_render.rs1use std::fmt::Write as _;
15
16use crate::domain::marker;
17use crate::gates::GATES;
18
19const LANGUAGE: &str = "system";
24
25#[derive(Debug, Clone)]
27pub struct RenderOptions {
28 pub docs_root: String,
31 pub entry: String,
33 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
47fn quoted(value: &str) -> String {
49 format!("'{}'", value.replace('\'', "''"))
50}
51
52#[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
58fn 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#[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 #[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}