spec_driven_docs/services/
hooks_render.rs1use std::fmt::Write as _;
11
12use clap::ValueEnum;
13
14use crate::domain::marker;
15use crate::gates::GATES;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
19pub enum Style {
20 Block,
23 Manifest,
25}
26
27#[derive(Debug, Clone)]
29pub struct RenderOptions {
30 pub style: Style,
32 pub docs_root: String,
35 pub entry: String,
37 pub language: String,
39 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
56fn quoted(value: &str) -> String {
58 format!("'{}'", value.replace('\'', "''"))
59}
60
61#[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#[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#[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}