1use std::collections::BTreeMap;
44
45use camino::Utf8Path;
46use serde::Deserialize;
47
48use crate::domain::gate_id::GateId;
49use crate::domain::path_filter::{Layer, PathFilter, PathFilterError, Pattern};
50
51pub const CONFIG_PATH: &str = ".spec-driven-docs/config.yaml";
53
54#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
56#[serde(deny_unknown_fields)]
57pub struct GateFilters {
58 #[serde(default)]
60 pub include: Vec<String>,
61 #[serde(default)]
63 pub exclude: Vec<String>,
64}
65
66#[derive(Debug, Default, Clone, Copy, Deserialize, PartialEq, Eq)]
68#[serde(rename_all = "lowercase")]
69pub enum WritingSource {
70 #[default]
72 Builtin,
73 Project,
75 None,
77}
78
79#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
81#[serde(deny_unknown_fields)]
82pub struct WritingStyle {
83 #[serde(default)]
85 pub source: WritingSource,
86 #[serde(default)]
89 pub path: Option<String>,
90}
91
92impl WritingStyle {
93 pub fn parse_flag(value: &str) -> Result<Self, ConfigError> {
101 let selection = match value.trim() {
102 "builtin" => Self::default(),
103 "none" => Self {
104 source: WritingSource::None,
105 path: None,
106 },
107 other => match other.strip_prefix("project:") {
108 Some(path) => Self {
109 source: WritingSource::Project,
110 path: Some(path.trim().to_string()),
111 },
112 None => {
113 return Err(ConfigError::WritingStyle(format!(
114 "`{other}` is not a selection; write `builtin`, `none`, or `project:<path>`"
115 )));
116 }
117 },
118 };
119 selection.check()?;
120 Ok(selection)
121 }
122
123 fn check(&self) -> Result<(), ConfigError> {
125 match (self.source, self.path.as_deref()) {
126 (WritingSource::Project, None | Some("")) => Err(ConfigError::WritingStyle(
127 "`source: project` names no `path`".to_string(),
128 )),
129 (WritingSource::Builtin | WritingSource::None, Some(path)) if !path.is_empty() => {
130 Err(ConfigError::WritingStyle(format!(
131 "`path: {path}` is set and the source is not `project`, so nothing reads it"
132 )))
133 }
134 (WritingSource::Project, Some(path)) => {
135 let candidate = Utf8Path::new(path);
136 if candidate.is_absolute() {
137 return Err(ConfigError::WritingStyle(format!(
138 "`path: {path}` is absolute; name the document relative to the repository"
139 )));
140 }
141 if candidate.components().any(|part| part.as_str() == "..") {
142 return Err(ConfigError::WritingStyle(format!(
143 "`path: {path}` leaves the repository"
144 )));
145 }
146 Ok(())
147 }
148 _ => Ok(()),
149 }
150 }
151
152 #[must_use]
155 pub fn route(&self) -> Option<String> {
156 match self.source {
157 WritingSource::Builtin => Some("`sdd method writing-style`".to_string()),
158 WritingSource::Project => self.path.as_ref().map(|path| format!("`{path}`")),
159 WritingSource::None => None,
160 }
161 }
162
163 #[must_use]
165 pub fn render(&self) -> String {
166 let source = match self.source {
167 WritingSource::Builtin => "builtin",
168 WritingSource::Project => "project",
169 WritingSource::None => "none",
170 };
171 let path = self
172 .path
173 .as_deref()
174 .filter(|path| !path.is_empty())
175 .map_or_else(|| "null".to_string(), quoted);
176 format!("writing_style:\n source: {source}\n path: {path}\n")
177 }
178}
179
180#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
182#[serde(deny_unknown_fields)]
183pub struct InstanceConfig {
184 #[serde(default)]
186 pub reserved: Vec<String>,
187 #[serde(default)]
190 pub gates: BTreeMap<String, GateFilters>,
191 #[serde(default)]
194 pub writing_style: WritingStyle,
195}
196
197#[derive(Debug, thiserror::Error)]
199pub enum ConfigError {
200 #[error("{CONFIG_PATH} does not parse: {0}")]
202 Shape(String),
203 #[error(
205 "{CONFIG_PATH} names the gate `{0}`, which this version does not deliver: `sdd gate --list` names every one"
206 )]
207 UnknownGate(String),
208 #[error("{CONFIG_PATH}: {0}")]
210 Pattern(#[from] PathFilterError),
211 #[error("{CONFIG_PATH}: writing_style: {0}")]
213 WritingStyle(String),
214}
215
216impl InstanceConfig {
217 pub fn parse(text: &str) -> Result<Self, ConfigError> {
226 let parsed: Self =
227 yaml_serde::from_str(text).map_err(|error| ConfigError::Shape(error.to_string()))?;
228 for key in parsed.gates.keys() {
229 if resolve_id(key).is_none() {
230 return Err(ConfigError::UnknownGate(key.clone()));
231 }
232 }
233 parsed.check_patterns()?;
238 parsed.writing_style.check()?;
239 Ok(parsed)
240 }
241
242 pub fn read(repo_root: &Utf8Path) -> Result<Self, ConfigError> {
252 let path = repo_root.join(CONFIG_PATH);
253 match std::fs::read_to_string(&path) {
254 Ok(text) => Self::parse(&text),
255 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
256 Err(error) => Err(ConfigError::Shape(error.to_string())),
257 }
258 }
259
260 fn check_patterns(&self) -> Result<(), ConfigError> {
266 let every = self.reserved.iter().chain(
267 self.gates
268 .values()
269 .flat_map(|filters| filters.include.iter().chain(&filters.exclude)),
270 );
271 for glob in every {
272 PathFilter::build(Vec::new(), vec![Pattern::new(glob.clone(), Layer::Project)])?;
273 }
274 Ok(())
275 }
276
277 #[must_use]
279 pub fn for_gate(&self, id: GateId) -> Option<&GateFilters> {
280 self.gates.get(&id.to_string())
281 }
282}
283
284fn resolve_id(key: &str) -> Option<GateId> {
286 GateId::ALL.iter().copied().find(|id| id.to_string() == key)
287}
288
289pub fn resolve(
299 registry_include: &[String],
300 registry_exclude: &[String],
301 declared: Option<&GateFilters>,
302 flag_include: &[String],
303 flag_exclude: &[String],
304 reserved: &[String],
305) -> Result<PathFilter, ConfigError> {
306 let includes: Vec<Pattern> = declared.filter(|d| !d.include.is_empty()).map_or_else(
309 || {
310 registry_include
311 .iter()
312 .map(|glob| Pattern::new(glob.clone(), Layer::Registry))
313 .collect()
314 },
315 |declared| {
316 declared
317 .include
318 .iter()
319 .map(|glob| Pattern::new(glob.clone(), Layer::Project))
320 .collect()
321 },
322 );
323 let includes = includes
324 .into_iter()
325 .chain(
326 flag_include
327 .iter()
328 .map(|glob| Pattern::new(glob.clone(), Layer::Flag)),
329 )
330 .collect();
331
332 let excludes: Vec<Pattern> = registry_exclude
335 .iter()
336 .map(|glob| Pattern::new(glob.clone(), Layer::Registry))
337 .chain(
338 declared
339 .into_iter()
340 .flat_map(|d| &d.exclude)
341 .map(|glob| Pattern::new(glob.clone(), Layer::Project)),
342 )
343 .chain(
344 flag_exclude
345 .iter()
346 .map(|glob| Pattern::new(glob.clone(), Layer::Flag)),
347 )
348 .chain(
349 reserved
350 .iter()
351 .map(|glob| Pattern::new(glob.clone(), Layer::Reserved)),
352 )
353 .collect();
354
355 Ok(PathFilter::build(includes, excludes)?)
356}
357
358fn quoted(glob: &str) -> String {
366 format!("'{}'", glob.replace('\'', "''"))
367}
368
369#[must_use]
374pub fn with_writing_style(text: &str, selection: &WritingStyle) -> String {
375 let block = selection.render();
376 let mut out = String::new();
377 let mut wrote = false;
378 let mut skipping = false;
379 for line in text.lines() {
380 if skipping {
381 if line.starts_with(' ') || line.starts_with('\t') {
382 continue;
383 }
384 skipping = false;
385 }
386 if !wrote && line.starts_with("writing_style:") {
387 out.push_str(&block);
388 wrote = true;
389 skipping = true;
390 continue;
391 }
392 out.push_str(line);
393 out.push('\n');
394 }
395 if !wrote {
396 if !out.is_empty() && !out.ends_with("\n\n") {
397 out.push('\n');
398 }
399 out.push_str("# Where the writing style comes from: `builtin` routes authors to\n");
400 out.push_str("# `sdd method writing-style`, `project` routes them to the document\n");
401 out.push_str("# `path` names, and `none` installs no route and imposes no conversion\n");
402 out.push_str("# obligation.\n");
403 out.push_str(&block);
404 }
405 out
406}
407
408#[must_use]
415pub fn with_reserved(text: &str, paths: &[String]) -> String {
416 if paths.is_empty() {
417 return text.to_string();
418 }
419 let existing = InstanceConfig::parse(text).unwrap_or_default().reserved;
420 let mut added: Vec<&String> = paths
421 .iter()
422 .filter(|path| !existing.contains(path))
423 .collect();
424 added.dedup();
425 if added.is_empty() {
426 return text.to_string();
427 }
428
429 let entries: String = existing
430 .iter()
431 .map(|path| format!(" - {}\n", quoted(path)))
432 .chain(added.iter().map(|path| format!(" - {}\n", quoted(path))))
433 .collect();
434
435 let mut out = String::new();
436 let mut wrote = false;
437 let mut skipping = false;
438 for line in text.lines() {
439 if skipping {
440 if line.starts_with(" - ") || line.trim().is_empty() && !wrote {
442 continue;
443 }
444 skipping = false;
445 }
446 if !wrote && (line.starts_with("reserved:")) {
447 out.push_str("reserved:\n");
448 out.push_str(&entries);
449 wrote = true;
450 skipping = true;
451 continue;
452 }
453 out.push_str(line);
454 out.push('\n');
455 }
456 if !wrote {
457 out.push_str("reserved:\n");
458 out.push_str(&entries);
459 }
460 out
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use crate::domain::path_filter::Decision;
467
468 fn config(text: &str) -> InstanceConfig {
469 InstanceConfig::parse(text).expect("the fixture parses")
470 }
471
472 #[test]
473 fn an_absent_file_is_the_empty_declaration() {
474 let dir = tempfile::tempdir().expect("a scratch directory");
475 let root = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
476 .expect("the scratch path is UTF-8");
477 let read = InstanceConfig::read(&root).expect("an absent file is not an error");
478 assert_eq!(read, InstanceConfig::default());
479 }
480
481 #[test]
482 fn an_empty_file_is_the_empty_declaration() {
483 assert_eq!(config("{}\n"), InstanceConfig::default());
484 }
485
486 #[test]
487 fn a_malformed_key_is_an_error_naming_the_key() {
488 let error = InstanceConfig::parse("reserved: AGENTS.md\n")
489 .expect_err("a scalar where a list belongs is refused");
490 assert!(
491 error.to_string().contains("reserved"),
492 "the error does not name the key: {error}"
493 );
494 }
495
496 #[test]
497 fn an_unknown_key_is_refused() {
498 assert!(InstanceConfig::parse("reservd:\n - a.md\n").is_err());
499 }
500
501 #[test]
502 fn an_unknown_gate_id_is_an_error_naming_the_key() {
503 let error = InstanceConfig::parse("gates:\n no-such-gate:\n exclude: [a]\n")
504 .expect_err("an unknown gate is refused");
505 assert!(matches!(error, ConfigError::UnknownGate(ref key) if key == "no-such-gate"));
506 }
507
508 #[test]
509 fn a_known_gate_id_parses() {
510 let parsed = config("gates:\n no-personal-path:\n exclude:\n - vendor/**\n");
511 assert_eq!(
512 parsed
513 .for_gate(GateId::NoPersonalPath)
514 .map(|f| f.exclude.clone()),
515 Some(vec!["vendor/**".to_string()])
516 );
517 }
518
519 #[test]
520 fn a_path_leaving_the_repository_is_refused() {
521 let error = resolve(&[], &[], None, &[], &[], &["../outside/**".to_string()])
522 .expect_err("a pattern that climbs out is refused");
523 assert!(matches!(error, ConfigError::Pattern(_)));
524 }
525
526 #[test]
527 fn a_project_include_replaces_the_registry_include() {
528 let filter = resolve(
529 &["_docs/**/*.md".to_string()],
530 &[],
531 Some(&GateFilters {
532 include: vec!["method/**/*.md".to_string()],
533 exclude: Vec::new(),
534 }),
535 &[],
536 &[],
537 &[],
538 )
539 .expect("resolves");
540 assert_eq!(
541 filter.decide(Utf8Path::new("method/gates.md")),
542 Decision::Read
543 );
544 assert_eq!(
545 filter.decide(Utf8Path::new("_docs/specs/SPEC-a.md")),
546 Decision::NotIncluded,
547 "the registry include survived a project include that replaces it"
548 );
549 }
550
551 #[test]
552 fn every_exclude_layer_extends() {
553 let filter = resolve(
554 &[],
555 &["a.md".to_string()],
556 Some(&GateFilters {
557 include: Vec::new(),
558 exclude: vec!["b.md".to_string()],
559 }),
560 &[],
561 &["c.md".to_string()],
562 &["d.md".to_string()],
563 )
564 .expect("resolves");
565 for path in ["a.md", "b.md", "c.md", "d.md"] {
566 assert!(
567 matches!(filter.decide(Utf8Path::new(path)), Decision::Skipped(_)),
568 "{path} survived its exclude layer"
569 );
570 }
571 }
572
573 #[test]
574 fn reserved_wins_over_a_gate_entry_that_includes_it() {
575 let filter = resolve(
576 &[],
577 &[],
578 Some(&GateFilters {
579 include: vec!["AGENTS.md".to_string()],
580 exclude: Vec::new(),
581 }),
582 &[],
583 &[],
584 &["AGENTS.md".to_string()],
585 )
586 .expect("resolves");
587 match filter.decide(Utf8Path::new("AGENTS.md")) {
588 Decision::Skipped(pattern) => assert_eq!(pattern.layer, Layer::Reserved),
589 other => panic!("reserved did not win: {other:?}"),
590 }
591 }
592
593 #[test]
594 fn reserving_a_path_keeps_every_comment() {
595 let seed = "# why this file exists\nreserved: []\n\n# per gate\ngates: {}\n";
596 let out = with_reserved(seed, &["AGENTS.md".to_string()]);
597 assert!(
598 out.contains("# why this file exists"),
599 "a comment was lost:\n{out}"
600 );
601 assert!(out.contains("# per gate"), "a comment was lost:\n{out}");
602 assert!(
603 out.contains(" - 'AGENTS.md'"),
604 "the path is missing:\n{out}"
605 );
606 assert_eq!(
607 InstanceConfig::parse(&out).expect("still parses").reserved,
608 vec!["AGENTS.md".to_string()]
609 );
610 }
611
612 #[test]
613 fn reserving_a_recorded_path_changes_nothing() {
614 let text = "reserved:\n - 'AGENTS.md'\ngates: {}\n";
615 assert_eq!(with_reserved(text, &["AGENTS.md".to_string()]), text);
616 }
617
618 #[test]
619 fn a_glob_is_written_as_a_yaml_scalar_that_reads_back() {
620 for glob in ["**/generated.md", "[ab]/x.md", "{a,b}/x.md", "it's/x.md"] {
623 let out = with_reserved("reserved: []\ngates: {}\n", &[glob.to_string()]);
624 assert_eq!(
625 InstanceConfig::parse(&out)
626 .unwrap_or_else(|e| panic!("{glob} did not read back: {e}"))
627 .reserved,
628 vec![glob.to_string()],
629 "for {glob}"
630 );
631 }
632 }
633
634 #[test]
635 fn a_refused_pattern_fails_at_the_declaration_boundary() {
636 let error = InstanceConfig::parse("reserved:\n - '!negated'\ngates: {}\n")
638 .expect_err("a negation is refused at parse");
639 assert!(matches!(error, ConfigError::Pattern(_)));
640 assert!(
641 InstanceConfig::parse("gates:\n no-personal-path:\n exclude: ['a[']\n").is_err()
642 );
643 }
644
645 #[test]
646 fn reserving_adds_beside_what_is_recorded() {
647 let text = "reserved:\n - AGENTS.md\ngates: {}\n";
648 let out = with_reserved(text, &["vendor/**".to_string()]);
649 assert_eq!(
650 InstanceConfig::parse(&out).expect("parses").reserved,
651 vec!["AGENTS.md".to_string(), "vendor/**".to_string()]
652 );
653 }
654
655 #[test]
656 fn an_absent_key_is_builtin() {
657 assert_eq!(
658 config("reserved: []\ngates: {}\n").writing_style,
659 WritingStyle::default()
660 );
661 assert_eq!(
662 config("writing_style:\n source: builtin\n path: null\n")
663 .writing_style
664 .route(),
665 Some("`sdd method writing-style`".to_string())
666 );
667 }
668
669 #[test]
670 fn project_without_a_path_is_an_error_naming_the_key() {
671 let error = InstanceConfig::parse("writing_style:\n source: project\n")
672 .expect_err("a project source needs a path");
673 assert!(matches!(error, ConfigError::WritingStyle(_)));
674 assert!(error.to_string().contains("writing_style"), "{error}");
675 assert!(error.to_string().contains("path"), "{error}");
676 }
677
678 #[test]
679 fn a_path_without_the_project_source_is_an_error() {
680 for source in ["builtin", "none"] {
681 let error = InstanceConfig::parse(&format!(
682 "writing_style:\n source: {source}\n path: docs/style.md\n"
683 ))
684 .expect_err("a path nothing reads is refused");
685 assert!(error.to_string().contains("nothing reads it"), "{error}");
686 }
687 }
688
689 #[test]
690 fn a_writing_style_path_leaving_the_repository_is_refused() {
691 for path in ["/etc/style.md", "../style.md", "docs/../../style.md"] {
692 assert!(
693 WritingStyle::parse_flag(&format!("project:{path}")).is_err(),
694 "{path} was accepted"
695 );
696 }
697 assert_eq!(
698 WritingStyle::parse_flag("project:docs/STYLE.md")
699 .unwrap()
700 .route(),
701 Some("`docs/STYLE.md`".to_string())
702 );
703 assert_eq!(WritingStyle::parse_flag("none").unwrap().route(), None);
704 assert!(WritingStyle::parse_flag("house").is_err());
705 }
706
707 #[test]
708 fn a_selection_is_written_into_the_declaration_and_reads_back() {
709 let seed = "reserved: []\n\n# how to write\nwriting_style:\n source: builtin\n path: null\n\ngates: {}\n";
710 let selection = WritingStyle::parse_flag("project:docs/STYLE.md").unwrap();
711 let out = with_writing_style(seed, &selection);
712 assert!(out.contains("# how to write"), "a comment was lost:\n{out}");
713 assert!(out.contains("gates: {}"), "a later key was lost:\n{out}");
714 assert_eq!(config(&out).writing_style, selection);
715
716 let older = "reserved: []\ngates: {}\n";
718 let out = with_writing_style(older, &WritingStyle::parse_flag("none").unwrap());
719 assert_eq!(config(&out).writing_style.source, WritingSource::None);
720 assert!(config(&out).reserved.is_empty());
721 }
722
723 #[test]
724 fn no_layer_can_reopen_an_exclusion() {
725 let error = resolve(&[], &[], None, &[], &["!a.md".to_string()], &[])
729 .expect_err("a negation is refused");
730 assert!(matches!(error, ConfigError::Pattern(_)));
731 }
732}