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
33use 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
41/// Where an instance keeps its declaration.
42pub const CONFIG_PATH: &str = ".spec-driven-docs/config.yaml";
43
44/// The filters one named gate takes.
45#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
46#[serde(deny_unknown_fields)]
47pub struct GateFilters {
48    /// Replaces the registry's include list for this gate.
49    #[serde(default)]
50    pub include: Vec<String>,
51    /// Extends the registry's exclude list for this gate.
52    #[serde(default)]
53    pub exclude: Vec<String>,
54}
55
56/// The declaration as written.
57#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
58#[serde(deny_unknown_fields)]
59pub struct InstanceConfig {
60    /// Paths no delivered gate judges. Another tool owns these.
61    #[serde(default)]
62    pub reserved: Vec<String>,
63    /// Per-gate filters, keyed by gate id. A gate not named here takes its
64    /// registry default.
65    #[serde(default)]
66    pub gates: BTreeMap<String, GateFilters>,
67}
68
69/// A declaration that does not parse, or that names something no gate has.
70#[derive(Debug, thiserror::Error)]
71pub enum ConfigError {
72    /// The file is not YAML of this shape.
73    #[error("{CONFIG_PATH} does not parse: {0}")]
74    Shape(String),
75    /// A `gates:` key is not a delivered gate id.
76    #[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    /// A pattern the filter grammar refuses.
81    #[error("{CONFIG_PATH}: {0}")]
82    Pattern(#[from] PathFilterError),
83}
84
85impl InstanceConfig {
86    /// Parse a declaration.
87    ///
88    /// # Errors
89    ///
90    /// [`ConfigError::Shape`] when the text is not a declaration, and
91    /// [`ConfigError::UnknownGate`] when a `gates:` key names no delivered
92    /// gate. Neither falls back to the default: a filter that quietly stops
93    /// applying is worse than one that fails loudly.
94    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        // Compile every pattern here, at the one boundary, rather than
103        // where a gate runs. A malformed pattern that reached `render_block`
104        // would be written into the managed block and blessed by `verify`,
105        // then fail separately from every gate that ran.
106        parsed.check_patterns()?;
107        Ok(parsed)
108    }
109
110    /// Read the declaration an instance carries.
111    ///
112    /// A missing file is the empty declaration and never an error: an
113    /// instance that declares nothing is a valid instance.
114    ///
115    /// # Errors
116    ///
117    /// See [`Self::parse`]. An unreadable file that exists is a shape error
118    /// rather than a silent default.
119    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    /// Compile every declared pattern, so a bad one fails once and here.
129    ///
130    /// # Errors
131    ///
132    /// [`ConfigError::Pattern`] naming the pattern the grammar refuses.
133    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    /// This gate's declared filters, if the project named it.
146    #[must_use]
147    pub fn for_gate(&self, id: GateId) -> Option<&GateFilters> {
148        self.gates.get(&id.to_string())
149    }
150}
151
152/// Map a `gates:` key onto a delivered gate.
153fn resolve_id(key: &str) -> Option<GateId> {
154    GateId::ALL.iter().copied().find(|id| id.to_string() == key)
155}
156
157/// Build one gate's filter from every layer.
158///
159/// `registry_include` and `registry_exclude` arrive already templated
160/// against the instance's documentation root, because the root is the
161/// caller's to resolve.
162///
163/// # Errors
164///
165/// [`ConfigError::Pattern`] naming the pattern the grammar refuses.
166pub 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    // A project include replaces the registry include; extending a
175    // whitelist could only widen what the gate judges.
176    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    // Every exclude layer extends, and `reserved` is last so nothing that
201    // follows can reopen it.
202    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
226/// One glob as a YAML scalar that reads back as itself.
227///
228/// A glob and YAML disagree about several leading characters: `*` opens an
229/// alias, `[` and `{` open a flow collection, and `#` opens a comment. The
230/// filter grammar accepts `**/generated.md`, so writing it bare would
231/// produce a file the next read refuses. Single quotes make every glob a
232/// scalar, with an apostrophe doubled.
233fn quoted(glob: &str) -> String {
234    format!("'{}'", glob.replace('\'', "''"))
235}
236
237/// Record reserved paths in a declaration, keeping every comment.
238///
239/// The file is hand-edited and its comments carry the whole explanation of
240/// the four layers, so this rewrites one key textually rather than
241/// round-tripping the YAML. A path already recorded is left alone, which is
242/// what makes a repeated `--reserve` idempotent.
243#[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            // Drop the previous list items, which the rewritten key carries.
270            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        // `*`, `[`, `{`, and `#` all open something in YAML, and the filter
450        // grammar accepts globs starting with the first three.
451        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        // Not where a gate runs, and not after the managed block carries it.
466        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        // The grammar carries no negation, so there is nothing to write that
487        // would undo an earlier exclude. This asserts the refusal rather
488        // than the absence.
489        let error = resolve(&[], &[], None, &[], &["!a.md".to_string()], &[])
490            .expect_err("a negation is refused");
491        assert!(matches!(error, ConfigError::Pattern(_)));
492    }
493}