Skip to main content

spec_spine_types/
config.rs

1//! The `spec-spine.toml` configuration model.
2//!
3//! Everything the reference repos had to fork over is a knob here. An absent
4//! config yields a working default for a single-Cargo-workspace repo with
5//! `specs/` at the root ([`Config::default`]). Every struct is
6//! `#[serde(default, deny_unknown_fields)]`: missing keys default, and a
7//! *misspelled* knob is a loud [`Error::Config`] rather than a silently-ignored
8//! setting: the exact failure class that left template-encore blind to its npm
9//! packages. See `docs/design/00-architecture.md` §3.
10
11use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::error::{Error, Result};
16
17/// The full configuration. All sections are optional. `Default` is derived;
18/// each field's own `Default` supplies the conventional value.
19#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
20#[serde(default, deny_unknown_fields)]
21pub struct Config {
22    pub manifest: ManifestConfig,
23    /// Opt-in `domain` taxonomy (empty `allowed` ⇒ free-text/disabled).
24    pub domains: AllowlistConfig,
25    /// Opt-in `kind` taxonomy, symmetric with `domains` (empty ⇒ disabled).
26    pub kind: AllowlistConfig,
27    pub layout: LayoutConfig,
28    pub index: IndexConfig,
29    pub branding: BrandingConfig,
30    pub coupling: CouplingConfig,
31    pub provenance: ProvenanceConfig,
32    pub frontmatter: FrontmatterConfig,
33}
34
35/// `[manifest]`: how a manifest links a compilation unit back to its spec.
36#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
37#[serde(default, deny_unknown_fields)]
38pub struct ManifestConfig {
39    /// Drives both `[package.metadata.<ns>].spec` (Cargo) and `"<ns>".spec`
40    /// (package.json). OAP used `oap`; aide/encore used `spec`.
41    pub metadata_namespace: String,
42}
43
44impl Default for ManifestConfig {
45    fn default() -> Self {
46        ManifestConfig {
47            metadata_namespace: "spec-spine".to_string(),
48        }
49    }
50}
51
52/// A reusable opt-in categorical allowlist (used by `[domains]` and `[kind]`).
53///
54/// Empty ⇒ the field is free-text / disabled (no enum check). Non-empty ⇒ a
55/// closed enum: the field value, *when present*, must be a member (a `V`-error
56/// otherwise). Field absence is allowed.
57#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
58#[serde(default, deny_unknown_fields)]
59pub struct AllowlistConfig {
60    pub allowed: Vec<String>,
61}
62
63impl AllowlistConfig {
64    /// True if this taxonomy is disabled (no allowlist configured).
65    pub fn is_disabled(&self) -> bool {
66        self.allowed.is_empty()
67    }
68
69    /// True if `value` is permitted: always when disabled, else membership.
70    pub fn permits(&self, value: &str) -> bool {
71        self.is_disabled() || self.allowed.iter().any(|a| a == value)
72    }
73}
74
75/// `[layout]`: path conventions. Never hardcode `specs/`, `.derived/`, etc.
76#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
77#[serde(default, deny_unknown_fields)]
78pub struct LayoutConfig {
79    pub specs_dir: String,
80    pub derived_dir: String,
81    pub standards_dir: String,
82    pub schemas_dir: String,
83    /// Root Cargo workspace manifest (relative to repo root).
84    pub cargo_workspace: String,
85    /// Manifests that DECLARE npm/pnpm workspace members. The indexer reads
86    /// member globs from whichever exists. The default reads root
87    /// `package.json#workspaces`, fixing the template-encore bug where a
88    /// hardcoded `public/pnpm-workspace.yaml` made all npm packages invisible.
89    pub npm_workspaces: Vec<String>,
90    /// Crates outside the root Cargo workspace.
91    pub standalone_rust_workspaces: Vec<String>,
92    /// npm packages outside the declared workspaces.
93    pub standalone_npm_packages: Vec<String>,
94}
95
96impl Default for LayoutConfig {
97    fn default() -> Self {
98        LayoutConfig {
99            specs_dir: "specs".to_string(),
100            derived_dir: ".derived".to_string(),
101            standards_dir: "standards/spec".to_string(),
102            schemas_dir: "standards/schemas".to_string(),
103            cargo_workspace: "Cargo.toml".to_string(),
104            npm_workspaces: vec![
105                "package.json".to_string(),
106                "pnpm-workspace.yaml".to_string(),
107            ],
108            standalone_rust_workspaces: Vec::new(),
109            standalone_npm_packages: Vec::new(),
110        }
111    }
112}
113
114/// `[index]`: inputs and exclusions for the codebase indexer.
115#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
116#[serde(default, deny_unknown_fields)]
117pub struct IndexConfig {
118    /// Globs folded into the content-hash beyond the always-hashed core
119    /// (all spec.md + discovered manifests + `spec-spine.toml`).
120    pub extra_hashed_inputs: Vec<String>,
121    /// Directory names pruned from symbol/section resolution walks.
122    pub resolver_exclusions: Vec<String>,
123    /// `[index.slices]` (spec 012): named glob groups, each emitted as a
124    /// `build.sliceHashes` entry and gated by `index check --slice <name>`.
125    /// Names match `[a-z0-9][a-z0-9-]*`; each list is non-empty, with
126    /// `extra_hashed_inputs` pattern semantics. Slices are independent of the
127    /// global hash: listing a file here does NOT fold it into `contentHash`.
128    pub slices: BTreeMap<String, Vec<String>>,
129}
130
131impl Default for IndexConfig {
132    fn default() -> Self {
133        IndexConfig {
134            extra_hashed_inputs: vec![
135                "standards/**".to_string(),
136                ".github/workflows/**".to_string(),
137            ],
138            slices: BTreeMap::new(),
139            resolver_exclusions: vec![
140                "target".to_string(),
141                "node_modules".to_string(),
142                ".derived".to_string(),
143                "dist".to_string(),
144                "build".to_string(),
145                ".next".to_string(),
146            ],
147        }
148    }
149}
150
151/// `[branding]`: identifiers stamped into emitted `build` metadata.
152#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
153#[serde(default, deny_unknown_fields)]
154pub struct BrandingConfig {
155    pub compiler_id: String,
156    pub indexer_id: String,
157}
158
159impl Default for BrandingConfig {
160    fn default() -> Self {
161        BrandingConfig {
162            compiler_id: "spec-spine".to_string(),
163            indexer_id: "spec-spine".to_string(),
164        }
165    }
166}
167
168/// `[coupling]`: the PR-time gate's exemptions and waiver keyword.
169#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
170#[serde(default, deny_unknown_fields)]
171pub struct CouplingConfig {
172    /// **Additional** paths exempt from the gate, on top of the always-applied
173    /// hardcoded floor (`spec_spine_core::DEFAULT_BYPASS_PREFIXES`). Match rules:
174    /// trailing `/` ⇒ dir prefix; leading `**/` ⇒ tail-suffix anywhere; else
175    /// exact file. This list is **additive**: it adds to the floor and can never
176    /// remove a floor entry. The default is **empty**: the floor is the single
177    /// built-in source, so an adopter declares only their own additions rather
178    /// than restating (and seeming able to override) the floor.
179    pub bypass_prefixes: Vec<String>,
180    /// The PR-body waiver keyword; the free-text reason follows the colon.
181    pub waiver_keyword: String,
182    /// Opt-in mechanical auto-waiver for dependency-only diffs (spec 005
183    /// §3.5). When `true` and no PR-body waiver is present, the CLI compares
184    /// the parsed base/head JSON of every non-bypassed changed path: if all
185    /// are `package.json` manifests whose only differences are version
186    /// strings inside the standard dependency tables (same package keys),
187    /// the gate self-waives: the path dependabot-class PRs cannot take
188    /// (they can edit neither specs nor PR bodies). Anything beyond a
189    /// version string (a new package, a `scripts` edit, spec-binding
190    /// metadata) refuses the auto-waiver, fail-closed. Default `false`.
191    pub auto_waive_dependency_only: bool,
192}
193
194impl Default for CouplingConfig {
195    fn default() -> Self {
196        CouplingConfig {
197            // Empty by design: the floor lives in `couple.rs` and is always
198            // unioned in; duplicating it here was redundant and misleadingly
199            // implied it was overridable.
200            bypass_prefixes: Vec::new(),
201            waiver_keyword: "Spec-Drift-Waiver:".to_string(),
202            auto_waive_dependency_only: false,
203        }
204    }
205}
206
207/// `[provenance]`: the OPEN provenance-scheme registry (kind → URI scheme).
208#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
209#[serde(default, deny_unknown_fields)]
210pub struct ProvenanceConfig {
211    pub uri_schemes: BTreeMap<String, String>,
212}
213
214impl Default for ProvenanceConfig {
215    fn default() -> Self {
216        let mut uri_schemes = BTreeMap::new();
217        uri_schemes.insert("knowledge".to_string(), "knowledge://".to_string());
218        uri_schemes.insert("code-fingerprint".to_string(), "fingerprint://".to_string());
219        ProvenanceConfig { uri_schemes }
220    }
221}
222
223/// `[frontmatter]`: recognized-key extensions.
224#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
225#[serde(default, deny_unknown_fields)]
226pub struct FrontmatterConfig {
227    /// Keys an adopter recognizes (suppresses the lint's unknown-key warning);
228    /// they still overflow into `extra_frontmatter`.
229    pub extra_known_keys: Vec<String>,
230}
231
232/// Load and validate a `spec-spine.toml` from its source text.
233///
234/// Returns [`Error::Config`] (mapped to exit code 3) on any malformed or
235/// unknown-key error; never panics.
236pub fn load_config(toml_src: &str) -> Result<Config> {
237    let config: Config = toml::from_str(toml_src).map_err(|e| Error::Config(e.to_string()))?;
238    validate_slices(&config)?;
239    Ok(config)
240}
241
242/// `[index.slices]` grammar (spec 012 §3.1): names match
243/// `[a-z0-9][a-z0-9-]*`, glob lists are non-empty.
244fn validate_slices(config: &Config) -> Result<()> {
245    for (name, globs) in &config.index.slices {
246        let mut chars = name.chars();
247        let head_ok = chars
248            .next()
249            .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
250        let tail_ok = chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
251        if !(head_ok && tail_ok) {
252            return Err(Error::Config(format!(
253                "[index.slices] name '{name}' must match [a-z0-9][a-z0-9-]*"
254            )));
255        }
256        if globs.is_empty() {
257            return Err(Error::Config(format!(
258                "[index.slices] '{name}' must list at least one glob"
259            )));
260        }
261    }
262    Ok(())
263}