use std::fmt::Write as _;
use crate::domain::instance_config::InstanceConfig;
use crate::domain::marker;
use crate::gates::GATES;
const LANGUAGE: &str = "system";
#[derive(Debug, Clone)]
pub struct RenderOptions {
pub docs_root: String,
pub entry: String,
pub indent: String,
pub declaration: InstanceConfig,
}
impl Default for RenderOptions {
fn default() -> Self {
Self {
docs_root: "_docs".to_string(),
entry: "sdd".to_string(),
indent: " ".to_string(),
declaration: InstanceConfig::default(),
}
}
}
fn quoted(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
#[allow(
clippy::literal_string_with_formatting_args,
reason = "the braces are the wiring template's placeholder, not a formatting argument"
)]
fn substitute_root(pattern: &str, docs_root: &str) -> String {
pattern.replace("{docs_root}", docs_root)
}
fn escape(out: &mut String, ch: char) {
if ".^$+()|[]{}\\*?".contains(ch) {
out.push('\\');
}
out.push(ch);
}
fn glob_to_regex(glob: &str) -> String {
let mut out = String::from("^");
let bytes: Vec<char> = glob.chars().collect();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
'*' if bytes.get(i + 1) == Some(&'*') => {
if bytes.get(i + 2) == Some(&'/') {
out.push_str("(?:.*/)?");
i += 3;
} else {
out.push_str(".*");
i += 2;
}
}
'*' => {
out.push_str("[^/]*");
i += 1;
}
'?' => {
out.push_str("[^/]");
i += 1;
}
'[' => {
let close = bytes[i..].iter().position(|c| *c == ']').map(|p| i + p);
if let Some(close) = close {
out.push('[');
let mut j = i + 1;
if bytes.get(j) == Some(&'!') {
out.push('^');
j += 1;
}
for ch in &bytes[j..close] {
out.push(*ch);
}
out.push(']');
i = close + 1;
} else {
escape(&mut out, '[');
i += 1;
}
}
'{' => {
let close = bytes[i..].iter().position(|c| *c == '}').map(|p| i + p);
if let Some(close) = close {
out.push_str("(?:");
let branches: String = bytes[i + 1..close].iter().collect();
let rendered: Vec<String> = branches
.split(',')
.map(|branch| {
let whole = glob_to_regex(branch);
whole
.trim_start_matches('^')
.trim_end_matches('$')
.to_string()
})
.collect();
out.push_str(&rendered.join("|"));
out.push(')');
i = close + 1;
} else {
escape(&mut out, '{');
i += 1;
}
}
ch => {
escape(&mut out, ch);
i += 1;
}
}
}
out.push('$');
out
}
fn render_patterns(globs: &[&str], docs_root: &str) -> Option<String> {
let rendered: Vec<String> = globs
.iter()
.map(|glob| glob_to_regex(&substitute_root(glob, docs_root)))
.collect();
match rendered.len() {
0 => None,
1 => Some(rendered[0].clone()),
_ => {
let inner: Vec<String> = rendered
.iter()
.map(|r| r.trim_start_matches('^').trim_end_matches('$').to_string())
.collect();
Some(format!("^(?:{})$", inner.join("|")))
}
}
}
fn render_gates(options: &RenderOptions) -> String {
let item = format!("{0} - ", options.indent);
let field = format!("{0} ", options.indent);
let mut out = String::new();
for gate in GATES {
let _ = writeln!(out, "{item}id: {}", gate.id);
let _ = writeln!(out, "{field}name: {}", quoted(gate.name));
let _ = writeln!(out, "{field}entry: {} gate {}", options.entry, gate.id);
let _ = writeln!(out, "{field}language: {LANGUAGE}");
let declared = options.declaration.for_gate(gate.id);
let include: Vec<&str> = declared.filter(|d| !d.include.is_empty()).map_or_else(
|| gate.include.to_vec(),
|declared| declared.include.iter().map(String::as_str).collect(),
);
let exclude: Vec<&str> = gate
.exclude
.iter()
.copied()
.chain(
declared
.into_iter()
.flat_map(|d| &d.exclude)
.map(String::as_str),
)
.chain(options.declaration.reserved.iter().map(String::as_str))
.collect();
if !gate.always_run {
if let Some(files) = render_patterns(&include, &options.docs_root) {
let _ = writeln!(out, "{field}files: {}", quoted(&files));
}
}
if let Some(types) = gate.types {
let _ = writeln!(out, "{field}types: [{types}]");
}
if !gate.always_run {
if let Some(exclude) = render_patterns(&exclude, &options.docs_root) {
let _ = writeln!(out, "{field}exclude: {}", quoted(&exclude));
}
}
if gate.always_run {
let _ = writeln!(out, "{field}always_run: true");
let _ = writeln!(out, "{field}pass_filenames: false");
}
}
out
}
#[must_use]
pub fn selectors(
block: &str,
) -> std::collections::BTreeMap<String, (Option<String>, Option<String>)> {
let mut found = std::collections::BTreeMap::new();
let mut current: Option<String> = None;
for line in block.lines() {
let trimmed = line.trim_start();
if let Some(id) = trimmed.strip_prefix("- id: ") {
current = Some(id.trim().to_string());
found.insert(id.trim().to_string(), (None, None));
continue;
}
let Some(id) = current.as_ref() else { continue };
if let Some(value) = trimmed.strip_prefix("files: ") {
if let Some(entry) = found.get_mut(id) {
entry.0 = Some(value.trim().to_string());
}
} else if let Some(value) = trimmed.strip_prefix("exclude: ") {
if let Some(entry) = found.get_mut(id) {
entry.1 = Some(value.trim().to_string());
}
}
}
found
}
#[must_use]
pub fn render_block(options: &RenderOptions) -> String {
let indent = &options.indent;
let mut out = String::new();
out.push_str(marker::BEGIN);
out.push('\n');
let _ = writeln!(out, "{indent}- repo: local");
let _ = writeln!(out, "{indent} hooks:");
let _ = writeln!(out, "{indent} - id: spec-driven-docs-verify");
let _ = writeln!(out, "{indent} name: verify spec-driven docs instance");
let _ = writeln!(out, "{indent} entry: {} verify", options.entry);
let _ = writeln!(out, "{indent} language: {LANGUAGE}");
let _ = writeln!(out, "{indent} always_run: true");
let _ = writeln!(out, "{indent} pass_filenames: false");
out.push_str(&render_gates(options));
out.push_str(marker::END);
out.push('\n');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_gate_renders_its_wiring_fields() {
let out = render_gates(&RenderOptions::default());
assert!(out.starts_with(" - id: adr-cites-a-live-rule\n"));
assert!(out.contains(" entry: sdd gate adr-filename-shape\n"));
assert!(out.contains(" language: system\n"));
assert!(out.contains(" types: [markdown]\n"));
assert_eq!(out.matches("- id: ").count(), crate::gates::GATES.len());
}
#[test]
fn the_docs_root_reaches_every_templated_pattern() {
let out = render_gates(&RenderOptions {
docs_root: "docs".to_string(),
..RenderOptions::default()
});
assert!(out.contains(" files: '^docs/decisions/[^/]*\\.md$'\n"));
assert!(out.contains(" exclude: '^docs/decisions/.*$'\n"));
assert!(!out.contains("{docs_root}"));
}
#[test]
fn block_style_carries_the_markers_and_the_verifier() {
let out = render_block(&RenderOptions::default());
assert!(out.starts_with("# BEGIN spec-driven-docs managed\n"));
assert!(out.ends_with("# END spec-driven-docs managed\n"));
assert!(out.contains(" - id: spec-driven-docs-verify\n"));
assert!(out.contains(" entry: sdd verify\n"));
assert!(out.contains(" - id: adr-filename-shape\n"));
assert!(out.contains(" files: '^_docs/decisions/[^/]*\\.md$'\n"));
}
#[test]
fn a_glob_projects_onto_an_anchored_regex() {
assert_eq!(glob_to_regex("README.md"), r"^README\.md$");
assert_eq!(glob_to_regex("_docs/*.md"), r"^_docs/[^/]*\.md$");
assert_eq!(glob_to_regex("_docs/**/*.md"), r"^_docs/(?:.*/)?[^/]*\.md$");
assert_eq!(glob_to_regex("**/AGENTS.md"), r"^(?:.*/)?AGENTS\.md$");
assert_eq!(glob_to_regex("vendor/**"), r"^vendor/.*$");
assert_eq!(glob_to_regex("a?.md"), r"^a[^/]\.md$");
assert_eq!(glob_to_regex("[abc].md"), r"^[abc]\.md$");
assert_eq!(glob_to_regex("{SPEC,ADR}-a.md"), r"^(?:SPEC|ADR)-a\.md$");
}
#[test]
fn several_includes_render_as_one_alternation() {
let rendered = render_patterns(&["a.md", "b/*.md"], "_docs").expect("two patterns render");
assert_eq!(rendered, r"^(?:a\.md|b/[^/]*\.md)$");
assert_eq!(render_patterns(&[], "_docs"), None);
}
#[test]
fn the_rendered_pattern_is_a_superset_of_the_matcher() {
use crate::domain::path_filter::{Layer, PathFilter, Pattern};
let corpus = [
"README.md",
"AGENTS.md",
"method/AGENTS.md",
"method/gates.md",
"CHANGELOG.md",
"_docs/specs/SPEC-release.md",
"_docs/decisions/ADR-a-choice.md",
"_docs/reference/known-issues/KI-a-case.md",
"_docs/reference/tracking.yaml",
"_docs/guides/release.md",
"comparison-docs/COMPARISON-tools.md",
".spec-driven-docs/manifest.json",
"src/gates.rs",
"vendor/third/lib.rs",
];
for gate in GATES {
let filter = PathFilter::build(
gate.include
.iter()
.map(|g| Pattern::new(substitute_root(g, "_docs"), Layer::Registry))
.collect(),
gate.exclude
.iter()
.map(|g| Pattern::new(substitute_root(g, "_docs"), Layer::Registry))
.collect(),
)
.expect("every registry pattern compiles");
let files = render_patterns(gate.include, "_docs");
let excludes = render_patterns(gate.exclude, "_docs");
for path in corpus {
if !filter.judges(camino::Utf8Path::new(path)) {
continue;
}
if let Some(files) = &files {
let selector = regex::Regex::new(files).expect("the projection compiles");
assert!(
selector.is_match(path),
"{}: the matcher judges {path} and the rendered files: {files} does not select it",
gate.id
);
}
if let Some(excludes) = &excludes {
let selector = regex::Regex::new(excludes).expect("the projection compiles");
assert!(
!selector.is_match(path),
"{}: the matcher judges {path} and the rendered exclude: {excludes} drops it",
gate.id
);
}
}
}
}
#[test]
fn always_run_gates_do_not_take_filenames() {
let out = render_gates(&RenderOptions::default());
assert_eq!(
out.matches("always_run: true").count(),
out.matches("pass_filenames: false").count()
);
}
#[test]
fn an_apostrophe_in_a_name_would_be_doubled() {
assert_eq!(quoted("it's"), "'it''s'");
}
#[test]
fn the_block_splices_into_a_plain_config() {
let block = render_block(&RenderOptions::default());
let spliced = crate::domain::marker::splice("repos:\n", &block).unwrap();
let (base, found) = crate::domain::marker::split_block(&spliced).unwrap();
assert_eq!(base, "repos:\n");
assert_eq!(found.as_deref(), Some(block.as_str()));
}
}