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