Skip to main content

spec_driven_docs/domain/
instance_config.rs

1//! What a project declares about the files its gates judge.
2//!
3//! One hand-edited file, `.spec-driven-docs/config.yaml`, states which paths
4//! no delivered gate judges and which filters each named gate takes. The
5//! managed pre-commit block is rendered from it, so the block stays wholly
6//! owned by `sdd` and an upgrade never meets a project's edit.
7//!
8//! # The two keys are named apart
9//!
10//! `ESLint`'s flat config overloads one `ignores` key whose meaning changes
11//! with what else sits beside it, and it is a documented, repeated source of
12//! user confusion. `reserved` and `gates` mean one thing each.
13//!
14//! # The composition algorithm
15//!
16//! Four layers, in this order, and one function implements it:
17//!
18//! ```text
19//! layer 1  registry   the row's include and exclude in GATES
20//! layer 2  project    the gates: entry for that gate
21//! layer 3  flag       --include and --exclude on the command line
22//! layer 4  reserved   the reserved: list, as excludes only
23//! ```
24//!
25//! Per field, a later layer extends rather than replaces, with one stated
26//! exception: a project `include` list replaces the registry `include` list.
27//! A registry include is a whitelist, so extending it can only widen what a
28//! gate judges, which is the opposite of what a project asking for `include`
29//! wants. Every `exclude` layer extends, and `reserved` is last, so nothing
30//! reopens it. No layer can reopen an exclusion at all, because the grammar
31//! carries no negation: `crate::domain::path_filter` refuses a leading `!`.
32//!
33//! # The writing style is the project's to select
34//!
35//! `writing_style` states where the writing convention comes from: this
36//! convention's chapter, a document of the project's own, or none. The
37//! managed documentation block routes authors to the selection, and `none`
38//! installs no route and imposes no conversion obligation. The combination
39//! is validated, not just the field: `project` without a `path` names
40//! nothing, and a `path` beside another source is a value nothing reads,
41//! which is a value that drifts.
42
43use 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
51/// Where an instance keeps its declaration.
52pub const CONFIG_PATH: &str = ".spec-driven-docs/config.yaml";
53
54/// The filters one named gate takes.
55#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
56#[serde(deny_unknown_fields)]
57pub struct GateFilters {
58    /// Replaces the registry's include list for this gate.
59    #[serde(default)]
60    pub include: Vec<String>,
61    /// Extends the registry's exclude list for this gate.
62    #[serde(default)]
63    pub exclude: Vec<String>,
64}
65
66/// Where a project's writing style comes from.
67#[derive(Debug, Default, Clone, Copy, Deserialize, PartialEq, Eq)]
68#[serde(rename_all = "lowercase")]
69pub enum WritingSource {
70    /// This convention's chapter, served by `sdd method writing-style`.
71    #[default]
72    Builtin,
73    /// A document of the project's own, named by `path`.
74    Project,
75    /// No route and no conversion obligation.
76    None,
77}
78
79/// The writing-style selection as written.
80#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
81#[serde(deny_unknown_fields)]
82pub struct WritingStyle {
83    /// Which source.
84    #[serde(default)]
85    pub source: WritingSource,
86    /// The project's own document, relative to the repository root, where
87    /// the source is `project`.
88    #[serde(default)]
89    pub path: Option<String>,
90}
91
92impl WritingStyle {
93    /// Read the `--writing-style` argument: `builtin`, `none`, or
94    /// `project:<path>`.
95    ///
96    /// # Errors
97    ///
98    /// [`ConfigError::WritingStyle`] for any other form, or a path the
99    /// selection refuses.
100    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    /// Hold the combination, not just each field.
124    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    /// The document authors are routed to, relative to the repository, or
153    /// `None` where the selection installs no route.
154    #[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    /// The selection as the declaration file spells it.
164    #[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/// The declaration as written.
181#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
182#[serde(deny_unknown_fields)]
183pub struct InstanceConfig {
184    /// Paths no delivered gate judges. Another tool owns these.
185    #[serde(default)]
186    pub reserved: Vec<String>,
187    /// Per-gate filters, keyed by gate id. A gate not named here takes its
188    /// registry default.
189    #[serde(default)]
190    pub gates: BTreeMap<String, GateFilters>,
191    /// Where the writing style comes from. Absent, the convention's own
192    /// chapter.
193    #[serde(default)]
194    pub writing_style: WritingStyle,
195}
196
197/// A declaration that does not parse, or that names something no gate has.
198#[derive(Debug, thiserror::Error)]
199pub enum ConfigError {
200    /// The file is not YAML of this shape.
201    #[error("{CONFIG_PATH} does not parse: {0}")]
202    Shape(String),
203    /// A `gates:` key is not a delivered gate id.
204    #[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    /// A pattern the filter grammar refuses.
209    #[error("{CONFIG_PATH}: {0}")]
210    Pattern(#[from] PathFilterError),
211    /// A writing-style selection whose fields do not agree.
212    #[error("{CONFIG_PATH}: writing_style: {0}")]
213    WritingStyle(String),
214}
215
216impl InstanceConfig {
217    /// Parse a declaration.
218    ///
219    /// # Errors
220    ///
221    /// [`ConfigError::Shape`] when the text is not a declaration, and
222    /// [`ConfigError::UnknownGate`] when a `gates:` key names no delivered
223    /// gate. Neither falls back to the default: a filter that quietly stops
224    /// applying is worse than one that fails loudly.
225    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        // Compile every pattern here, at the one boundary, rather than
234        // where a gate runs. A malformed pattern that reached `render_block`
235        // would be written into the managed block and blessed by `verify`,
236        // then fail separately from every gate that ran.
237        parsed.check_patterns()?;
238        parsed.writing_style.check()?;
239        Ok(parsed)
240    }
241
242    /// Read the declaration an instance carries.
243    ///
244    /// A missing file is the empty declaration and never an error: an
245    /// instance that declares nothing is a valid instance.
246    ///
247    /// # Errors
248    ///
249    /// See [`Self::parse`]. An unreadable file that exists is a shape error
250    /// rather than a silent default.
251    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    /// Compile every declared pattern, so a bad one fails once and here.
261    ///
262    /// # Errors
263    ///
264    /// [`ConfigError::Pattern`] naming the pattern the grammar refuses.
265    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    /// This gate's declared filters, if the project named it.
278    #[must_use]
279    pub fn for_gate(&self, id: GateId) -> Option<&GateFilters> {
280        self.gates.get(&id.to_string())
281    }
282}
283
284/// Map a `gates:` key onto a delivered gate.
285fn resolve_id(key: &str) -> Option<GateId> {
286    GateId::ALL.iter().copied().find(|id| id.to_string() == key)
287}
288
289/// Build one gate's filter from every layer.
290///
291/// `registry_include` and `registry_exclude` arrive already templated
292/// against the instance's documentation root, because the root is the
293/// caller's to resolve.
294///
295/// # Errors
296///
297/// [`ConfigError::Pattern`] naming the pattern the grammar refuses.
298pub 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    // A project include replaces the registry include; extending a
307    // whitelist could only widen what the gate judges.
308    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    // Every exclude layer extends, and `reserved` is last so nothing that
333    // follows can reopen it.
334    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
358/// One glob as a YAML scalar that reads back as itself.
359///
360/// A glob and YAML disagree about several leading characters: `*` opens an
361/// alias, `[` and `{` open a flow collection, and `#` opens a comment. The
362/// filter grammar accepts `**/generated.md`, so writing it bare would
363/// produce a file the next read refuses. Single quotes make every glob a
364/// scalar, with an apostrophe doubled.
365fn quoted(glob: &str) -> String {
366    format!("'{}'", glob.replace('\'', "''"))
367}
368
369/// Record a writing-style selection in a declaration, keeping every comment.
370///
371/// The `writing_style:` key and its indented children are replaced as a
372/// block; a declaration written before the key existed gets it appended.
373#[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/// Record reserved paths in a declaration, keeping every comment.
409///
410/// The file is hand-edited and its comments carry the whole explanation of
411/// the four layers, so this rewrites one key textually rather than
412/// round-tripping the YAML. A path already recorded is left alone, which is
413/// what makes a repeated `--reserve` idempotent.
414#[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            // Drop the previous list items, which the rewritten key carries.
441            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        // `*`, `[`, `{`, and `#` all open something in YAML, and the filter
621        // grammar accepts globs starting with the first three.
622        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        // Not where a gate runs, and not after the managed block carries it.
637        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        // A declaration written before the key existed gets it appended.
717        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        // The grammar carries no negation, so there is nothing to write that
726        // would undo an earlier exclude. This asserts the refusal rather
727        // than the absence.
728        let error = resolve(&[], &[], None, &[], &["!a.md".to_string()], &[])
729            .expect_err("a negation is refused");
730        assert!(matches!(error, ConfigError::Pattern(_)));
731    }
732}