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
51pub use crate::domain::paths::CONFIG_PATH;
52
53/// The filters one named gate takes.
54#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
55#[serde(deny_unknown_fields)]
56pub struct GateFilters {
57    /// Replaces the registry's include list for this gate.
58    #[serde(default)]
59    pub include: Vec<String>,
60    /// Extends the registry's exclude list for this gate.
61    #[serde(default)]
62    pub exclude: Vec<String>,
63}
64
65/// Where a project's writing style comes from.
66#[derive(Debug, Default, Clone, Copy, Deserialize, PartialEq, Eq)]
67#[serde(rename_all = "lowercase")]
68pub enum WritingSource {
69    /// This convention's chapter, served by `sdd method writing-style`.
70    #[default]
71    Builtin,
72    /// A document of the project's own, named by `path`.
73    Project,
74    /// No route and no conversion obligation.
75    None,
76}
77
78/// The writing-style selection as written.
79#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
80#[serde(deny_unknown_fields)]
81pub struct WritingStyle {
82    /// Which source.
83    #[serde(default)]
84    pub source: WritingSource,
85    /// The project's own document, relative to the repository root, where
86    /// the source is `project`.
87    #[serde(default)]
88    pub path: Option<String>,
89}
90
91impl WritingStyle {
92    /// Read the `--writing-style` argument: `builtin`, `none`, or
93    /// `project:<path>`.
94    ///
95    /// # Errors
96    ///
97    /// [`ConfigError::WritingStyle`] for any other form, or a path the
98    /// selection refuses.
99    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    /// Hold the combination, not just each field.
123    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    /// The document authors are routed to, relative to the repository, or
152    /// `None` where the selection installs no route.
153    #[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    /// The selection as the declaration file spells it.
163    #[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/// The declaration as written.
180#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
181#[serde(deny_unknown_fields)]
182pub struct InstanceConfig {
183    /// Paths no delivered gate judges. Another tool owns these.
184    #[serde(default)]
185    pub reserved: Vec<String>,
186    /// Per-gate filters, keyed by gate id. A gate not named here takes its
187    /// registry default.
188    #[serde(default)]
189    pub gates: BTreeMap<String, GateFilters>,
190    /// Where the writing style comes from. Absent, the convention's own
191    /// chapter.
192    #[serde(default)]
193    pub writing_style: WritingStyle,
194}
195
196/// A declaration that does not parse, or that names something no gate has.
197#[derive(Debug, thiserror::Error)]
198pub enum ConfigError {
199    /// The file is not YAML of this shape.
200    #[error("{CONFIG_PATH} does not parse: {0}")]
201    Shape(String),
202    /// A `gates:` key is not a delivered gate id.
203    #[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    /// A pattern the filter grammar refuses.
208    #[error("{CONFIG_PATH}: {0}")]
209    Pattern(#[from] PathFilterError),
210    /// A writing-style selection whose fields do not agree.
211    #[error("{CONFIG_PATH}: writing_style: {0}")]
212    WritingStyle(String),
213}
214
215impl InstanceConfig {
216    /// Parse a declaration.
217    ///
218    /// # Errors
219    ///
220    /// [`ConfigError::Shape`] when the text is not a declaration, and
221    /// [`ConfigError::UnknownGate`] when a `gates:` key names no delivered
222    /// gate. Neither falls back to the default: a filter that quietly stops
223    /// applying is worse than one that fails loudly.
224    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        // Compile every pattern here, at the one boundary, rather than
233        // where a gate runs. A malformed pattern that reached `render_block`
234        // would be written into the managed block and blessed by `verify`,
235        // then fail separately from every gate that ran.
236        parsed.check_patterns()?;
237        parsed.writing_style.check()?;
238        Ok(parsed)
239    }
240
241    /// Read the declaration an instance carries.
242    ///
243    /// A missing file is the empty declaration and never an error: an
244    /// instance that declares nothing is a valid instance.
245    ///
246    /// # Errors
247    ///
248    /// See [`Self::parse`]. An unreadable file that exists is a shape error
249    /// rather than a silent default.
250    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    /// Compile every declared pattern, so a bad one fails once and here.
260    ///
261    /// # Errors
262    ///
263    /// [`ConfigError::Pattern`] naming the pattern the grammar refuses.
264    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    /// This gate's declared filters, if the project named it.
277    #[must_use]
278    pub fn for_gate(&self, id: GateId) -> Option<&GateFilters> {
279        self.gates.get(&id.to_string())
280    }
281}
282
283/// Map a `gates:` key onto a delivered gate.
284fn resolve_id(key: &str) -> Option<GateId> {
285    GateId::ALL.iter().copied().find(|id| id.to_string() == key)
286}
287
288/// Build one gate's filter from every layer.
289///
290/// `registry_include` and `registry_exclude` arrive already templated
291/// against the instance's documentation root, because the root is the
292/// caller's to resolve.
293///
294/// # Errors
295///
296/// [`ConfigError::Pattern`] naming the pattern the grammar refuses.
297pub 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    // A project include replaces the registry include; extending a
306    // whitelist could only widen what the gate judges.
307    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    // Every exclude layer extends, and `reserved` is last so nothing that
332    // follows can reopen it.
333    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
357/// One glob as a YAML scalar that reads back as itself.
358///
359/// A glob and YAML disagree about several leading characters: `*` opens an
360/// alias, `[` and `{` open a flow collection, and `#` opens a comment. The
361/// filter grammar accepts `**/generated.md`, so writing it bare would
362/// produce a file the next read refuses. Single quotes make every glob a
363/// scalar, with an apostrophe doubled.
364fn quoted(glob: &str) -> String {
365    format!("'{}'", glob.replace('\'', "''"))
366}
367
368/// Record a writing-style selection in a declaration, keeping every comment.
369///
370/// The `writing_style:` key and its indented children are replaced as a
371/// block; a declaration written before the key existed gets it appended.
372#[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/// Record reserved paths in a declaration, keeping every comment.
408///
409/// The file is hand-edited and its comments carry the whole explanation of
410/// the four layers, so this rewrites one key textually rather than
411/// round-tripping the YAML. A path already recorded is left alone, which is
412/// what makes a repeated `--reserve` idempotent.
413#[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            // Drop the previous list items, which the rewritten key carries.
440            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        // `*`, `[`, `{`, and `#` all open something in YAML, and the filter
620        // grammar accepts globs starting with the first three.
621        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        // Not where a gate runs, and not after the managed block carries it.
636        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        // A declaration written before the key existed gets it appended.
716        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        // The grammar carries no negation, so there is nothing to write that
725        // would undo an earlier exclude. This asserts the refusal rather
726        // than the absence.
727        let error = resolve(&[], &[], None, &[], &["!a.md".to_string()], &[])
728            .expect_err("a negation is refused");
729        assert!(matches!(error, ConfigError::Pattern(_)));
730    }
731}