1use std::collections::BTreeMap;
34
35use camino::Utf8Path;
36use serde::Deserialize;
37
38use crate::domain::gate_id::GateId;
39use crate::domain::path_filter::{Layer, PathFilter, PathFilterError, Pattern};
40
41pub const CONFIG_PATH: &str = ".spec-driven-docs/config.yaml";
43
44#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
46#[serde(deny_unknown_fields)]
47pub struct GateFilters {
48 #[serde(default)]
50 pub include: Vec<String>,
51 #[serde(default)]
53 pub exclude: Vec<String>,
54}
55
56#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
58#[serde(deny_unknown_fields)]
59pub struct InstanceConfig {
60 #[serde(default)]
62 pub reserved: Vec<String>,
63 #[serde(default)]
66 pub gates: BTreeMap<String, GateFilters>,
67}
68
69#[derive(Debug, thiserror::Error)]
71pub enum ConfigError {
72 #[error("{CONFIG_PATH} does not parse: {0}")]
74 Shape(String),
75 #[error(
77 "{CONFIG_PATH} names the gate `{0}`, which this version does not deliver: `sdd gate --list` names every one"
78 )]
79 UnknownGate(String),
80 #[error("{CONFIG_PATH}: {0}")]
82 Pattern(#[from] PathFilterError),
83}
84
85impl InstanceConfig {
86 pub fn parse(text: &str) -> Result<Self, ConfigError> {
95 let parsed: Self =
96 yaml_serde::from_str(text).map_err(|error| ConfigError::Shape(error.to_string()))?;
97 for key in parsed.gates.keys() {
98 if resolve_id(key).is_none() {
99 return Err(ConfigError::UnknownGate(key.clone()));
100 }
101 }
102 parsed.check_patterns()?;
107 Ok(parsed)
108 }
109
110 pub fn read(repo_root: &Utf8Path) -> Result<Self, ConfigError> {
120 let path = repo_root.join(CONFIG_PATH);
121 match std::fs::read_to_string(&path) {
122 Ok(text) => Self::parse(&text),
123 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
124 Err(error) => Err(ConfigError::Shape(error.to_string())),
125 }
126 }
127
128 fn check_patterns(&self) -> Result<(), ConfigError> {
134 let every = self.reserved.iter().chain(
135 self.gates
136 .values()
137 .flat_map(|filters| filters.include.iter().chain(&filters.exclude)),
138 );
139 for glob in every {
140 PathFilter::build(Vec::new(), vec![Pattern::new(glob.clone(), Layer::Project)])?;
141 }
142 Ok(())
143 }
144
145 #[must_use]
147 pub fn for_gate(&self, id: GateId) -> Option<&GateFilters> {
148 self.gates.get(&id.to_string())
149 }
150}
151
152fn resolve_id(key: &str) -> Option<GateId> {
154 GateId::ALL.iter().copied().find(|id| id.to_string() == key)
155}
156
157pub fn resolve(
167 registry_include: &[String],
168 registry_exclude: &[String],
169 declared: Option<&GateFilters>,
170 flag_include: &[String],
171 flag_exclude: &[String],
172 reserved: &[String],
173) -> Result<PathFilter, ConfigError> {
174 let includes: Vec<Pattern> = declared.filter(|d| !d.include.is_empty()).map_or_else(
177 || {
178 registry_include
179 .iter()
180 .map(|glob| Pattern::new(glob.clone(), Layer::Registry))
181 .collect()
182 },
183 |declared| {
184 declared
185 .include
186 .iter()
187 .map(|glob| Pattern::new(glob.clone(), Layer::Project))
188 .collect()
189 },
190 );
191 let includes = includes
192 .into_iter()
193 .chain(
194 flag_include
195 .iter()
196 .map(|glob| Pattern::new(glob.clone(), Layer::Flag)),
197 )
198 .collect();
199
200 let excludes: Vec<Pattern> = registry_exclude
203 .iter()
204 .map(|glob| Pattern::new(glob.clone(), Layer::Registry))
205 .chain(
206 declared
207 .into_iter()
208 .flat_map(|d| &d.exclude)
209 .map(|glob| Pattern::new(glob.clone(), Layer::Project)),
210 )
211 .chain(
212 flag_exclude
213 .iter()
214 .map(|glob| Pattern::new(glob.clone(), Layer::Flag)),
215 )
216 .chain(
217 reserved
218 .iter()
219 .map(|glob| Pattern::new(glob.clone(), Layer::Reserved)),
220 )
221 .collect();
222
223 Ok(PathFilter::build(includes, excludes)?)
224}
225
226fn quoted(glob: &str) -> String {
234 format!("'{}'", glob.replace('\'', "''"))
235}
236
237#[must_use]
244pub fn with_reserved(text: &str, paths: &[String]) -> String {
245 if paths.is_empty() {
246 return text.to_string();
247 }
248 let existing = InstanceConfig::parse(text).unwrap_or_default().reserved;
249 let mut added: Vec<&String> = paths
250 .iter()
251 .filter(|path| !existing.contains(path))
252 .collect();
253 added.dedup();
254 if added.is_empty() {
255 return text.to_string();
256 }
257
258 let entries: String = existing
259 .iter()
260 .map(|path| format!(" - {}\n", quoted(path)))
261 .chain(added.iter().map(|path| format!(" - {}\n", quoted(path))))
262 .collect();
263
264 let mut out = String::new();
265 let mut wrote = false;
266 let mut skipping = false;
267 for line in text.lines() {
268 if skipping {
269 if line.starts_with(" - ") || line.trim().is_empty() && !wrote {
271 continue;
272 }
273 skipping = false;
274 }
275 if !wrote && (line.starts_with("reserved:")) {
276 out.push_str("reserved:\n");
277 out.push_str(&entries);
278 wrote = true;
279 skipping = true;
280 continue;
281 }
282 out.push_str(line);
283 out.push('\n');
284 }
285 if !wrote {
286 out.push_str("reserved:\n");
287 out.push_str(&entries);
288 }
289 out
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use crate::domain::path_filter::Decision;
296
297 fn config(text: &str) -> InstanceConfig {
298 InstanceConfig::parse(text).expect("the fixture parses")
299 }
300
301 #[test]
302 fn an_absent_file_is_the_empty_declaration() {
303 let dir = tempfile::tempdir().expect("a scratch directory");
304 let root = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
305 .expect("the scratch path is UTF-8");
306 let read = InstanceConfig::read(&root).expect("an absent file is not an error");
307 assert_eq!(read, InstanceConfig::default());
308 }
309
310 #[test]
311 fn an_empty_file_is_the_empty_declaration() {
312 assert_eq!(config("{}\n"), InstanceConfig::default());
313 }
314
315 #[test]
316 fn a_malformed_key_is_an_error_naming_the_key() {
317 let error = InstanceConfig::parse("reserved: AGENTS.md\n")
318 .expect_err("a scalar where a list belongs is refused");
319 assert!(
320 error.to_string().contains("reserved"),
321 "the error does not name the key: {error}"
322 );
323 }
324
325 #[test]
326 fn an_unknown_key_is_refused() {
327 assert!(InstanceConfig::parse("reservd:\n - a.md\n").is_err());
328 }
329
330 #[test]
331 fn an_unknown_gate_id_is_an_error_naming_the_key() {
332 let error = InstanceConfig::parse("gates:\n no-such-gate:\n exclude: [a]\n")
333 .expect_err("an unknown gate is refused");
334 assert!(matches!(error, ConfigError::UnknownGate(ref key) if key == "no-such-gate"));
335 }
336
337 #[test]
338 fn a_known_gate_id_parses() {
339 let parsed = config("gates:\n no-personal-path:\n exclude:\n - vendor/**\n");
340 assert_eq!(
341 parsed
342 .for_gate(GateId::NoPersonalPath)
343 .map(|f| f.exclude.clone()),
344 Some(vec!["vendor/**".to_string()])
345 );
346 }
347
348 #[test]
349 fn a_path_leaving_the_repository_is_refused() {
350 let error = resolve(&[], &[], None, &[], &[], &["../outside/**".to_string()])
351 .expect_err("a pattern that climbs out is refused");
352 assert!(matches!(error, ConfigError::Pattern(_)));
353 }
354
355 #[test]
356 fn a_project_include_replaces_the_registry_include() {
357 let filter = resolve(
358 &["_docs/**/*.md".to_string()],
359 &[],
360 Some(&GateFilters {
361 include: vec!["method/**/*.md".to_string()],
362 exclude: Vec::new(),
363 }),
364 &[],
365 &[],
366 &[],
367 )
368 .expect("resolves");
369 assert_eq!(
370 filter.decide(Utf8Path::new("method/08-gates.md")),
371 Decision::Read
372 );
373 assert_eq!(
374 filter.decide(Utf8Path::new("_docs/specs/SPEC-a.md")),
375 Decision::NotIncluded,
376 "the registry include survived a project include that replaces it"
377 );
378 }
379
380 #[test]
381 fn every_exclude_layer_extends() {
382 let filter = resolve(
383 &[],
384 &["a.md".to_string()],
385 Some(&GateFilters {
386 include: Vec::new(),
387 exclude: vec!["b.md".to_string()],
388 }),
389 &[],
390 &["c.md".to_string()],
391 &["d.md".to_string()],
392 )
393 .expect("resolves");
394 for path in ["a.md", "b.md", "c.md", "d.md"] {
395 assert!(
396 matches!(filter.decide(Utf8Path::new(path)), Decision::Skipped(_)),
397 "{path} survived its exclude layer"
398 );
399 }
400 }
401
402 #[test]
403 fn reserved_wins_over_a_gate_entry_that_includes_it() {
404 let filter = resolve(
405 &[],
406 &[],
407 Some(&GateFilters {
408 include: vec!["AGENTS.md".to_string()],
409 exclude: Vec::new(),
410 }),
411 &[],
412 &[],
413 &["AGENTS.md".to_string()],
414 )
415 .expect("resolves");
416 match filter.decide(Utf8Path::new("AGENTS.md")) {
417 Decision::Skipped(pattern) => assert_eq!(pattern.layer, Layer::Reserved),
418 other => panic!("reserved did not win: {other:?}"),
419 }
420 }
421
422 #[test]
423 fn reserving_a_path_keeps_every_comment() {
424 let seed = "# why this file exists\nreserved: []\n\n# per gate\ngates: {}\n";
425 let out = with_reserved(seed, &["AGENTS.md".to_string()]);
426 assert!(
427 out.contains("# why this file exists"),
428 "a comment was lost:\n{out}"
429 );
430 assert!(out.contains("# per gate"), "a comment was lost:\n{out}");
431 assert!(
432 out.contains(" - 'AGENTS.md'"),
433 "the path is missing:\n{out}"
434 );
435 assert_eq!(
436 InstanceConfig::parse(&out).expect("still parses").reserved,
437 vec!["AGENTS.md".to_string()]
438 );
439 }
440
441 #[test]
442 fn reserving_a_recorded_path_changes_nothing() {
443 let text = "reserved:\n - 'AGENTS.md'\ngates: {}\n";
444 assert_eq!(with_reserved(text, &["AGENTS.md".to_string()]), text);
445 }
446
447 #[test]
448 fn a_glob_is_written_as_a_yaml_scalar_that_reads_back() {
449 for glob in ["**/generated.md", "[ab]/x.md", "{a,b}/x.md", "it's/x.md"] {
452 let out = with_reserved("reserved: []\ngates: {}\n", &[glob.to_string()]);
453 assert_eq!(
454 InstanceConfig::parse(&out)
455 .unwrap_or_else(|e| panic!("{glob} did not read back: {e}"))
456 .reserved,
457 vec![glob.to_string()],
458 "for {glob}"
459 );
460 }
461 }
462
463 #[test]
464 fn a_refused_pattern_fails_at_the_declaration_boundary() {
465 let error = InstanceConfig::parse("reserved:\n - '!negated'\ngates: {}\n")
467 .expect_err("a negation is refused at parse");
468 assert!(matches!(error, ConfigError::Pattern(_)));
469 assert!(
470 InstanceConfig::parse("gates:\n no-personal-path:\n exclude: ['a[']\n").is_err()
471 );
472 }
473
474 #[test]
475 fn reserving_adds_beside_what_is_recorded() {
476 let text = "reserved:\n - AGENTS.md\ngates: {}\n";
477 let out = with_reserved(text, &["vendor/**".to_string()]);
478 assert_eq!(
479 InstanceConfig::parse(&out).expect("parses").reserved,
480 vec!["AGENTS.md".to_string(), "vendor/**".to_string()]
481 );
482 }
483
484 #[test]
485 fn no_layer_can_reopen_an_exclusion() {
486 let error = resolve(&[], &[], None, &[], &["!a.md".to_string()], &[])
490 .expect_err("a negation is refused");
491 assert!(matches!(error, ConfigError::Pattern(_)));
492 }
493}