use std::collections::BTreeMap;
use camino::Utf8Path;
use serde::Deserialize;
use crate::domain::gate_id::GateId;
use crate::domain::path_filter::{Layer, PathFilter, PathFilterError, Pattern};
pub const CONFIG_PATH: &str = ".spec-driven-docs/config.yaml";
#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct GateFilters {
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
}
#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct InstanceConfig {
#[serde(default)]
pub reserved: Vec<String>,
#[serde(default)]
pub gates: BTreeMap<String, GateFilters>,
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("{CONFIG_PATH} does not parse: {0}")]
Shape(String),
#[error(
"{CONFIG_PATH} names the gate `{0}`, which this version does not deliver: `sdd gate --list` names every one"
)]
UnknownGate(String),
#[error("{CONFIG_PATH}: {0}")]
Pattern(#[from] PathFilterError),
}
impl InstanceConfig {
pub fn parse(text: &str) -> Result<Self, ConfigError> {
let parsed: Self =
yaml_serde::from_str(text).map_err(|error| ConfigError::Shape(error.to_string()))?;
for key in parsed.gates.keys() {
if resolve_id(key).is_none() {
return Err(ConfigError::UnknownGate(key.clone()));
}
}
parsed.check_patterns()?;
Ok(parsed)
}
pub fn read(repo_root: &Utf8Path) -> Result<Self, ConfigError> {
let path = repo_root.join(CONFIG_PATH);
match std::fs::read_to_string(&path) {
Ok(text) => Self::parse(&text),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(error) => Err(ConfigError::Shape(error.to_string())),
}
}
fn check_patterns(&self) -> Result<(), ConfigError> {
let every = self.reserved.iter().chain(
self.gates
.values()
.flat_map(|filters| filters.include.iter().chain(&filters.exclude)),
);
for glob in every {
PathFilter::build(Vec::new(), vec![Pattern::new(glob.clone(), Layer::Project)])?;
}
Ok(())
}
#[must_use]
pub fn for_gate(&self, id: GateId) -> Option<&GateFilters> {
self.gates.get(&id.to_string())
}
}
fn resolve_id(key: &str) -> Option<GateId> {
GateId::ALL.iter().copied().find(|id| id.to_string() == key)
}
pub fn resolve(
registry_include: &[String],
registry_exclude: &[String],
declared: Option<&GateFilters>,
flag_include: &[String],
flag_exclude: &[String],
reserved: &[String],
) -> Result<PathFilter, ConfigError> {
let includes: Vec<Pattern> = declared.filter(|d| !d.include.is_empty()).map_or_else(
|| {
registry_include
.iter()
.map(|glob| Pattern::new(glob.clone(), Layer::Registry))
.collect()
},
|declared| {
declared
.include
.iter()
.map(|glob| Pattern::new(glob.clone(), Layer::Project))
.collect()
},
);
let includes = includes
.into_iter()
.chain(
flag_include
.iter()
.map(|glob| Pattern::new(glob.clone(), Layer::Flag)),
)
.collect();
let excludes: Vec<Pattern> = registry_exclude
.iter()
.map(|glob| Pattern::new(glob.clone(), Layer::Registry))
.chain(
declared
.into_iter()
.flat_map(|d| &d.exclude)
.map(|glob| Pattern::new(glob.clone(), Layer::Project)),
)
.chain(
flag_exclude
.iter()
.map(|glob| Pattern::new(glob.clone(), Layer::Flag)),
)
.chain(
reserved
.iter()
.map(|glob| Pattern::new(glob.clone(), Layer::Reserved)),
)
.collect();
Ok(PathFilter::build(includes, excludes)?)
}
fn quoted(glob: &str) -> String {
format!("'{}'", glob.replace('\'', "''"))
}
#[must_use]
pub fn with_reserved(text: &str, paths: &[String]) -> String {
if paths.is_empty() {
return text.to_string();
}
let existing = InstanceConfig::parse(text).unwrap_or_default().reserved;
let mut added: Vec<&String> = paths
.iter()
.filter(|path| !existing.contains(path))
.collect();
added.dedup();
if added.is_empty() {
return text.to_string();
}
let entries: String = existing
.iter()
.map(|path| format!(" - {}\n", quoted(path)))
.chain(added.iter().map(|path| format!(" - {}\n", quoted(path))))
.collect();
let mut out = String::new();
let mut wrote = false;
let mut skipping = false;
for line in text.lines() {
if skipping {
if line.starts_with(" - ") || line.trim().is_empty() && !wrote {
continue;
}
skipping = false;
}
if !wrote && (line.starts_with("reserved:")) {
out.push_str("reserved:\n");
out.push_str(&entries);
wrote = true;
skipping = true;
continue;
}
out.push_str(line);
out.push('\n');
}
if !wrote {
out.push_str("reserved:\n");
out.push_str(&entries);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::path_filter::Decision;
fn config(text: &str) -> InstanceConfig {
InstanceConfig::parse(text).expect("the fixture parses")
}
#[test]
fn an_absent_file_is_the_empty_declaration() {
let dir = tempfile::tempdir().expect("a scratch directory");
let root = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
.expect("the scratch path is UTF-8");
let read = InstanceConfig::read(&root).expect("an absent file is not an error");
assert_eq!(read, InstanceConfig::default());
}
#[test]
fn an_empty_file_is_the_empty_declaration() {
assert_eq!(config("{}\n"), InstanceConfig::default());
}
#[test]
fn a_malformed_key_is_an_error_naming_the_key() {
let error = InstanceConfig::parse("reserved: AGENTS.md\n")
.expect_err("a scalar where a list belongs is refused");
assert!(
error.to_string().contains("reserved"),
"the error does not name the key: {error}"
);
}
#[test]
fn an_unknown_key_is_refused() {
assert!(InstanceConfig::parse("reservd:\n - a.md\n").is_err());
}
#[test]
fn an_unknown_gate_id_is_an_error_naming_the_key() {
let error = InstanceConfig::parse("gates:\n no-such-gate:\n exclude: [a]\n")
.expect_err("an unknown gate is refused");
assert!(matches!(error, ConfigError::UnknownGate(ref key) if key == "no-such-gate"));
}
#[test]
fn a_known_gate_id_parses() {
let parsed = config("gates:\n no-personal-path:\n exclude:\n - vendor/**\n");
assert_eq!(
parsed
.for_gate(GateId::NoPersonalPath)
.map(|f| f.exclude.clone()),
Some(vec!["vendor/**".to_string()])
);
}
#[test]
fn a_path_leaving_the_repository_is_refused() {
let error = resolve(&[], &[], None, &[], &[], &["../outside/**".to_string()])
.expect_err("a pattern that climbs out is refused");
assert!(matches!(error, ConfigError::Pattern(_)));
}
#[test]
fn a_project_include_replaces_the_registry_include() {
let filter = resolve(
&["_docs/**/*.md".to_string()],
&[],
Some(&GateFilters {
include: vec!["method/**/*.md".to_string()],
exclude: Vec::new(),
}),
&[],
&[],
&[],
)
.expect("resolves");
assert_eq!(
filter.decide(Utf8Path::new("method/08-gates.md")),
Decision::Read
);
assert_eq!(
filter.decide(Utf8Path::new("_docs/specs/SPEC-a.md")),
Decision::NotIncluded,
"the registry include survived a project include that replaces it"
);
}
#[test]
fn every_exclude_layer_extends() {
let filter = resolve(
&[],
&["a.md".to_string()],
Some(&GateFilters {
include: Vec::new(),
exclude: vec!["b.md".to_string()],
}),
&[],
&["c.md".to_string()],
&["d.md".to_string()],
)
.expect("resolves");
for path in ["a.md", "b.md", "c.md", "d.md"] {
assert!(
matches!(filter.decide(Utf8Path::new(path)), Decision::Skipped(_)),
"{path} survived its exclude layer"
);
}
}
#[test]
fn reserved_wins_over_a_gate_entry_that_includes_it() {
let filter = resolve(
&[],
&[],
Some(&GateFilters {
include: vec!["AGENTS.md".to_string()],
exclude: Vec::new(),
}),
&[],
&[],
&["AGENTS.md".to_string()],
)
.expect("resolves");
match filter.decide(Utf8Path::new("AGENTS.md")) {
Decision::Skipped(pattern) => assert_eq!(pattern.layer, Layer::Reserved),
other => panic!("reserved did not win: {other:?}"),
}
}
#[test]
fn reserving_a_path_keeps_every_comment() {
let seed = "# why this file exists\nreserved: []\n\n# per gate\ngates: {}\n";
let out = with_reserved(seed, &["AGENTS.md".to_string()]);
assert!(
out.contains("# why this file exists"),
"a comment was lost:\n{out}"
);
assert!(out.contains("# per gate"), "a comment was lost:\n{out}");
assert!(
out.contains(" - 'AGENTS.md'"),
"the path is missing:\n{out}"
);
assert_eq!(
InstanceConfig::parse(&out).expect("still parses").reserved,
vec!["AGENTS.md".to_string()]
);
}
#[test]
fn reserving_a_recorded_path_changes_nothing() {
let text = "reserved:\n - 'AGENTS.md'\ngates: {}\n";
assert_eq!(with_reserved(text, &["AGENTS.md".to_string()]), text);
}
#[test]
fn a_glob_is_written_as_a_yaml_scalar_that_reads_back() {
for glob in ["**/generated.md", "[ab]/x.md", "{a,b}/x.md", "it's/x.md"] {
let out = with_reserved("reserved: []\ngates: {}\n", &[glob.to_string()]);
assert_eq!(
InstanceConfig::parse(&out)
.unwrap_or_else(|e| panic!("{glob} did not read back: {e}"))
.reserved,
vec![glob.to_string()],
"for {glob}"
);
}
}
#[test]
fn a_refused_pattern_fails_at_the_declaration_boundary() {
let error = InstanceConfig::parse("reserved:\n - '!negated'\ngates: {}\n")
.expect_err("a negation is refused at parse");
assert!(matches!(error, ConfigError::Pattern(_)));
assert!(
InstanceConfig::parse("gates:\n no-personal-path:\n exclude: ['a[']\n").is_err()
);
}
#[test]
fn reserving_adds_beside_what_is_recorded() {
let text = "reserved:\n - AGENTS.md\ngates: {}\n";
let out = with_reserved(text, &["vendor/**".to_string()]);
assert_eq!(
InstanceConfig::parse(&out).expect("parses").reserved,
vec!["AGENTS.md".to_string(), "vendor/**".to_string()]
);
}
#[test]
fn no_layer_can_reopen_an_exclusion() {
let error = resolve(&[], &[], None, &[], &["!a.md".to_string()], &[])
.expect_err("a negation is refused");
assert!(matches!(error, ConfigError::Pattern(_)));
}
}